@Transactional 롤백 규칙과 예외 처리 함정

이전 글에서는 Spring AOP가 프록시로 동작하기 때문에 내부 호출이나 private 메서드에는 적용되지 않는다는 점을 정리했습니다.
@Transactional도 같은 프록시 방식으로 동작하지만, 예외가 발생했을 때 롤백할지 커밋할지는 AOP만으로는 설명되지 않는, 트랜잭션에만 존재하는 규칙입니다. 이 글에서는 그 롤백 규칙과, 규칙을 모르면 빠지기 쉬운 함정을 정리합니다.
롤백은 누가, 어떻게 판단할까
@Transactional이 붙은 메서드는 프록시를 거쳐서 호출됩니다.
Client → [프록시] → 실제 메서드
│
├─ 호출 전: 트랜잭션 시작
├─ 정상 종료: 커밋
└─ 예외 발생: 예외 종류를 보고 롤백 or 커밋 결정
- 롤백 여부는 프록시가 판단합니다.
- 판단 기준은 실제 메서드가 프록시 바깥으로 어떤 예외를 던졌는가입니다.
이 두 가지를 기억하면, 뒤에서 설명할 규칙과 함정을 모두 이해할 수 있습니다.
기본 규칙: Checked 예외는 롤백되지 않는다
| 예외 종류 | 예시 | 기본 동작 |
|---|---|---|
Unchecked (RuntimeException, Error) | IllegalArgumentException, NullPointerException | 롤백 |
Checked (Exception) | IOException, 직접 만든 extends Exception 예외 | 커밋 |
@Transactional
public void order() throws IOException {
orderRepository.save(order);
throw new IOException("파일 저장 실패"); // 예외가 발생했지만 주문은 커밋된다
}예외가 발생했으니 당연히 롤백될 것이라고 생각하기 쉽지만, Checked 예외가 발생하면 기본적으로 트랜잭션이 커밋됩니다.
왜 이렇게 정했을까
| 예외 | Spring이 바라보는 관점 | 그 결과 |
|---|---|---|
| Checked | 비즈니스 측면에서 예상할 수 있고 복구할 수 있는 예외입니다 (예: 잔액 부족) | 호출한 쪽이 예외를 처리하도록 맡기고, 작업 결과는 커밋합니다 |
| Unchecked | 프로그래밍 오류나 시스템 오류처럼 복구할 수 없는 예외입니다 | 작업을 되돌리기 위해 롤백합니다 |
이 규칙은 EJB 시절부터 이어진 관례를 Spring이 그대로 따른 것입니다.
롤백 규칙 바꾸기: rollbackFor, noRollbackFor
// Checked 예외도 롤백
@Transactional(rollbackFor = Exception.class)
public void order() throws IOException { ... }
// 이 예외는 RuntimeException이어도 롤백하지 않음
@Transactional(noRollbackFor = BusinessException.class)
public void order() { ... }| 옵션 | 의미 |
|---|---|
rollbackFor | 지정한 예외(와 그 하위 예외)가 발생하면 롤백합니다 |
noRollbackFor | 지정한 예외(와 그 하위 예외)가 발생해도 롤백하지 않습니다 |
실무에서는 비즈니스 예외를 RuntimeException을 상속해서 만드는 경우가 많습니다. 이렇게 만들면 별도의 옵션을 지정하지 않아도 기본 규칙만으로 롤백됩니다.
Kotlin에서는 특히 주의해야 합니다
Kotlin에는 Checked 예외라는 개념이 없어서, 컴파일러가 IOException 같은 예외를 처리하라고 강제하지 않습니다. 그래서 Java 라이브러리가 Checked 예외를 던진다는 사실을 모르고 지나치기 쉽고, 그 결과 예외가 발생했는데도 트랜잭션이 커밋됩니다.
@Transactional(rollbackFor = [Exception::class]) // Kotlin 프로젝트에서 자주 쓰는 설정
fun order() { ... }함정: try-catch로 예외를 삼키면 커밋된다
@Transactional
public void order() {
orderRepository.save(order);
try {
paymentRepository.save(payment); // 여기서 RuntimeException 발생
} catch (RuntimeException e) {
log.error("결제 실패", e); // 예외를 삼킴
}
}
// 결과: 주문은 저장되고, 결제는 실패한 상태로 커밋된다왜 커밋될까
앞에서 살펴본 판단 기준이 그대로 적용됩니다. 롤백 여부는 프록시가 전달받은 예외를 보고 판단합니다.
실제 메서드 안에서 예외 발생 → catch로 잡음 → 메서드는 정상 종료
↓
프록시: "예외가 없었네" → 커밋
메서드 안에서 예외를 잡아서 처리해 버리면, 프록시가 보기에는 아무 일도 일어나지 않은 것과 같습니다.
해결 방법
1. 예외를 다시 던집니다 (권장)
} catch (RuntimeException e) {
log.error("결제 실패", e);
throw e; // 프록시까지 예외가 전달됨 → 롤백
}2. 예외를 삼켜야 한다면 롤백을 직접 표시합니다
} catch (RuntimeException e) {
log.error("결제 실패", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // 커밋 시점에 롤백됨
}두 번째 방법을 사용하면 코드가 Spring API에 직접 의존하게 되므로, 가능하면 첫 번째 방법을 사용합니다.
반대로, 같은 트랜잭션에 참여한 다른 빈의
@Transactional메서드에서 발생한 예외를 catch하면, 예외를 삼켰는데도 커밋에 실패(UnexpectedRollbackException)하는 경우가 있습니다. 이 문제는 트랜잭션 전파와 관련되어 있으므로, 트랜잭션 전파를 다루는 다음 글에서 정리할 예정입니다.
정리
| 상황 | 결과 |
|---|---|
RuntimeException, Error 발생 | 롤백 |
| Checked 예외 발생 | 커밋 |
rollbackFor = Exception.class 지정 후 Checked 예외 발생 | 롤백 |
noRollbackFor에 지정한 예외 발생 | 커밋 |
| 메서드 안에서 예외를 catch하고 다시 던지지 않음 | 커밋 |
catch한 뒤 setRollbackOnly() 호출 | 롤백 |
- 롤백 여부는 프록시가 전달받은 예외를 보고 판단합니다.
- 기본적으로 Unchecked 예외는 롤백하고, Checked 예외는 커밋합니다.
- 메서드 안에서 예외를 삼키면 프록시는 예외가 발생했다는 사실을 알 수 없으므로 커밋합니다.