@Transactional이 rollback하지 않는 예외 — 트랜잭션 관리의 함정

@Service
public class TransferService {
    @Transactional
    public void transfer(Long from, Long to, long amount) {
        accountRepository.withdraw(from, amount);
        try {
            externalService.notify(to);   // 외부 API 호출 실패
        } catch (Exception e) {
            log.warn("알림 실패", e);       // 예외를 잡아서 처리함
        }
        accountRepository.deposit(to, amount);
    }
}

이 코드에서 deposit이 실패하면 — withdraw도 롤백되어야 한다. @Transactional이 이것을 보장한다. 하지만 예외를 잡아서(catch) 처리하면 트랜잭션에 어떤 영향을 주는가? 그리고 checked exception은 기본적으로 롤백을 발생시키지 않는다는 것을 아는가?

이 글은 @Transactional의 작동 원리, 전파(propagation), 격리 수준(isolation), 롤백 규칙을 풀어간다. (Spring Framework Reference - Transaction)

@Transactional — 선언적 트랜잭션 관리

// Spring 7 / Java 25 — @Transactional
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {
    private final OrderRepository orderRepo;
    private final PaymentRepository paymentRepo;

    public OrderService(OrderRepository orderRepo, PaymentRepository paymentRepo) {
        this.orderRepo = orderRepo;
        this.paymentRepo = paymentRepo;
    }

    @Transactional
    public void placeOrder(Order order) {
        orderRepo.save(order);
        paymentRepo.charge(order);   // 여기서 실패하면 save도 롤백됨
    }
}

@Transactional은 AOP 프록시(장 06)를 통해 트랜잭션 경계를 만든다:

flowchart TD
    A["호출자"] -->|"@Transactional 메서드 호출"| P["트랜잭션 프록시"]
    P -->|"1. 트랜잭션 시작"| T["begin tx"]
    T -->|"2. 비즈니스 메서드 실행"| B["placeOrder()"]
    B -->|"성공"| C["commit"]
    B -->|"RuntimeException"| R["rollback"]
    C --> RES["호출자에게 반환"]
    R --> RES

롤백 규칙 — 어떤 예외가 rollback을 발생시키는가

예외 타입 기본 동작 비고
RuntimeException rollback NullPointerException, IllegalStateException
Error rollback OutOfMemoryError
Checked Exception 커밋(!) IOException, SQLException

checked exception은 기본적으로 트랜잭션을 롤백하지 않는다. 이것은 EJB 시절의 관례를 따른 것 — checked exception은 "비즈니스적으로 예상 가능한 상황"으로 간주하여 커밋을 유지한다. (Spring Framework Reference - Rollback)

왜 checked와 unchecked를 다르게 처리하는가 — 설계 이유: checked exception은 컴파일러가 throws를 강제하는 예외다 — 즉, 개발자가 "이 예외가 발생할 수 있다"는 것을 인지하고 있었다. 인지한 예외는 비즈니스 흐름의 일부일 가능성이 높으므로, 롤백보다는 커밋이 합리적일 수 있다. 반면 RuntimeException은 프로그래밍 오류(NPE, 인덱스 초과)로, 예상치 못한 상황이므로 롤백이 안전하다. 물론 현대에서는 rollbackFor = Exception.class로 모든 예외를 롤백하는 것이 더 직관적이다.

비유: checked exception은 배송 지연이다 — 예상 가능한 상황이므로 주문을 취소(롤백)하지 않고 진행(커밋)할 수 있다. RuntimeException창고 화재다 — 예상치 못한 사고이므로 즉시 주문을 취소(롤백)해야 한다.

롤백 규칙 커스터마이징

// Spring 7 / Java 25 — 롤백 규칙 커스터마이징
@Transactional(rollbackFor = IOException.class)   // checked exception도 롤백
public void importData(File file) throws IOException { ... }

@Transactional(noRollbackFor = IllegalStateException.class)  // 이 예외는 롤백 안 함
public void process(Order order) { ... }

프로덕션에서는 rollbackFor = Exception.class를 쓰는 것이 안전한 경우가 많다 — 모든 예외(checked 포함)가 롤백을 발생시키도록 한다.

전파(Propagation) — 중첩 트랜잭션의 동작

@Transactional 메서드가 다른 @Transactional 메서드를 호출할 때, 트랜잭션을 어떻게 처리할지를 전파가 결정한다. (Spring Framework Reference - Propagation)

전파 동작
REQUIRED (기본) 기존 트랜잭션이 있으면 참여, 없으면 새로 생성
REQUIRES_NEW 기존 트랜잭션을 일시 정지하고 새 트랜잭션 생성
SUPPORTS 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
MANDATORY 트랜잭션이 반드시 있어야 함 (없으면 예외)
NEVER 트랜잭션이 없어야 함 (있으면 예외)
NESTED 중첩 트랜잭션 (savepoint 기반)
// Spring 7 / Java 25 — 전파 예
@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logTransfer(String detail) {
        // 메인 트랜잭션이 롤백되어도 감사 로그는 커밋됨
        auditRepo.save(detail);
    }
}

@Service
public class TransferService {
    @Transactional
    public void transfer(Long from, Long to, long amount) {
        withdraw(from, amount);
        deposit(to, amount);
        auditService.logTransfer("이체 완료");   // REQUIRES_NEW → 별도 트랜잭션
        // 메인 트랜잭션이 롤백되어도 감사 로그는 살아있음
    }
}

격리 수준(Isolation)

// Spring 7 / Java 25 — 격리 수준 지정
@Transactional(isolation = Isolation.READ_COMMITTED)
public Order getOrder(Long id) { ... }
수준 더티 읽기 반복 불가 읽기 팬텀 읽기
READ_UNCOMMITTED 허용 허용 허용
READ_COMMITTED 차단 허용 허용
REPEATABLE_READ 차단 차단 허용
SERIALIZABLE 차단 차단 차단

격리 수준이 높을수록 일관성은 좋아지지만 동시성은 떨어진다. 대부분의 애플리케이션은 READ_COMMITTED(DB 기본값)로 충분하다.

readOnly — 읽기 전용 트랜잭션

// Spring 7 / Java 25 — readOnly 힌트
@Transactional(readOnly = true)
public Order findById(Long id) {
    return orderRepo.findById(id).orElseThrow();
}

readOnly = true는 Hibernate에 flush를 생략하고, DB에 읽기 전용 힌트를 전달한다. 성능이 미세하게 개선된다.

자기 호출 함정 — @Transactional과 AOP

장 06에서 다뤘듯, 같은 클래스 내부에서 @Transactional 메서드를 호출하면 AOP 프록시를 우회하여 트랜잭션이 적용되지 않는다. 이것은 트랜잭션 관리에서 가장 흔한 버그 원인이다.

// Spring 7 / Java 25 — 자기 호출 버그
@Service
public class OrderService {
    @Transactional
    public void placeOrder(Order order) { ... }

    public void bulkProcess(List<Order> orders) {
        for (Order order : orders) {
            this.placeOrder(order);   // this = 프록시 아님 → 트랜잭션 미적용!
        }
    }
}

실습 — 트랜잭션 없이 vs 있을 때

// Spring 7 / Java 25 — TransactionPatternDemo.java
import java.util.*;

public class TransactionPatternDemo {
    static class Account {
        long balance;
        Account(long balance) { this.balance = balance; }
    }

    // 트랜잭션 없음: withdraw 후 deposit 실패 → 잔액 불일치
    static void transferWithoutTx(Account from, Account to, long amount) {
        from.balance -= amount;   // 출금 성공
        if (to.balance < 0) throw new RuntimeException("수신자 오류");
        to.balance += amount;     // 입금 실패 → from만 차감됨
    }

    // 트랜잭션 있음: 실패 시 둘 다 롤백
    static void transferWithTx(Account from, Account to, long amount) {
        var fromSnapshot = from.balance;
        var toSnapshot = to.balance;
        try {
            from.balance -= amount;
            if (to.balance < 0) throw new RuntimeException("수신자 오류");
            to.balance += amount;
        } catch (RuntimeException e) {
            from.balance = fromSnapshot;   // 롤백
            to.balance = toSnapshot;
            System.out.println("롤백됨: " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        var a = new Account(10000);
        var b = new Account(-1);

        transferWithoutTx(a, b, 3000);
        System.out.println("TX 없음: A=" + a.balance + ", B=" + b.balance);

        a.balance = 10000;
        transferWithTx(a, b, 3000);
        System.out.println("TX 있음: A=" + a.balance + ", B=" + b.balance);
    }
}
java TransactionPatternDemo.java
Exception in thread "main" java.lang.RuntimeException: 수신자 오류

확인할 것: 트랜잭션 없이는 출금만 성공하고 입금이 실패하여 잔액이 불일치한다. 트랜잭션 있으면 실패 시 롤백되어 원래 상태로 복구된다. Spring의 @Transactional이 이 롤백을 자동화한다.

프로그래밍 방식 트랜잭션 — TransactionTemplate

선언적(@Transactional)이 아니라 코드에서 직접 트랜잭션 경계를 제어해야 할 때:

// Spring 7 / Java 25 — TransactionTemplate
import org.springframework.transaction.support.TransactionTemplate;

@Service
public class BatchProcessor {
    private final TransactionTemplate txTemplate;
    private final OrderRepository repo;

    public BatchProcessor(PlatformTransactionManager txManager,
                          OrderRepository repo) {
        this.txTemplate = new TransactionTemplate(txManager);
        this.repo = repo;
    }

    public void processInBatches(List<Order> orders) {
        for (List<Order> batch : partition(orders, 100)) {
            txTemplate.execute(status -> {
                for (Order order : batch) {
                    repo.save(order);
                }
                return null;   // 정상 반환 → commit
                // 예외 발생 → 자동 rollback
            });
        }
    }
}

프로그래밍 방식은 배치 처리에서 "100건마다 커밋" 같은 세밀한 제어가 필요할 때 유용하다. 일반적인 경우 @Transactional로 충분하다.

@Transactional 속성 전체 참조

@Transactional(
    propagation = Propagation.REQUIRED,       // 전파 (기본: REQUIRED)
    isolation = Isolation.DEFAULT,            // 격리 수준 (기본: DB 기본값)
    timeout = 30,                             // 초 단위 타임아웃 (기본: 무제한)
    readOnly = false,                         // 읽기 전용 (기본: false)
    rollbackFor = Exception.class,           // 롤백 대상 예외 (기본: RuntimeException)
    noRollbackFor = NoRollbackException.class,// 롤백 제외 예외
    label = "order-transaction"               // 트랜잭션 라벨 (Spring 5.3+, 모니터링용)
)
public void complexOperation() { ... }

요약 — 이 글의 결론

  • @Transactional은 AOP 프록시로 트랜잭션 경계를 만든다. 메서드 시작 시 트랜잭션을 시작하고, 정상 종료 시 커밋, 예외 시 롤백한다.
  • RuntimeExceptionError는 롤백, checked exception은 커밋(기본값). checked exception을 롤백하려면 rollbackFor = Exception.class를 지정한다.
  • 전파(propagation)가 중첩 트랜잭션의 동작을 결정한다. REQUIRED(기본)는 기존 트랜잭션에 참여, REQUIRES_NEW는 별도 트랜잭션을 생성한다.
  • 자기 호출은 @Transactional을 우회한다. 같은 클래스 내부에서 this.method()를 호출하면 프록시를 통하지 않으므로 트랜잭션이 적용되지 않는다.
  • readOnly = true로 읽기 전용 트랜잭션을 최적화한다. Hibernate flush 생략, DB 읽기 힌트로 성능 개선.

생각해 볼 문제

  1. REQUIRES_NEW 전파에서 부모 트랜잭션이 롤백되면 자식 트랜잭션도 롤백되는가?
  2. @Transactionalprivate 메서드에 붙으면 어떻게 되는가? (CGLIB 프록시의 한계)
  3. 트랜잭션 내에서 외부 API 호출(HTTP)을 하면 어떤 위험이 있는가?
  4. NESTED 전파와 REQUIRES_NEW의 차이는? savepoint는 어떻게 작동하는가?
  5. Spring 7에서 JTA(Java Transaction API) 기반 분산 트랜잭션의 지원 상태는?

참고

'Develop Artifacts > Spring' 카테고리의 다른 글

Spring - 10. data-access  (0) 2026.07.12
Spring - 08. validation exception  (0) 2026.07.12
Spring - 07. spring mvc  (0) 2026.07.12
Spring - 06. aop  (0) 2026.07.12
Spring - 05. bean scope profile  (0) 2026.07.12