Backend Development/Spring

Spring - 09. transaction

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

생각해 볼 문제

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

참고

'Backend Development > 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