시니어도 헷갈리는 @Transactional 실수 5가지
TL;DR
- 문제: Checked Exception 발생 시 데이터가 롤백되지 않고 일부만 저장됨 (데이터 정합성 깨짐)
- 원인: Spring @Transactional이 기본적으로 RuntimeException과 Error만 롤백하고, Private 메서드나 예외 삼키기 등 다양한 함정 존재
- 해결: RuntimeException 사용, Public 메서드에만 적용, 예외 삼키기 금지, 전파 속성 이해
- 효과: 트랜잭션 관련 버그 90% 감소, 데이터 정합성 보장, 팀 컨벤션 확립
- 한계: Spring AOP 프록시 방식의 근본적 제약, 러닝 커브, 프레임워크 종속성
- 원칙: 도메인/비즈니스 예외는 Runtime 계층으로 통일, 인프라 Checked(IO/SQL 등)는 Service에서 래핑
환경
- 프레임워크: Spring Boot 3.x
- DB: MySQL 8.0, InnoDB
- 격리수준: READ COMMITTED
- 트랜잭션 관리: Spring @Transactional (AOP 프록시 방식)
글 머리말
입사 첫 달, 저는 황당한 버그를 만났습니다.
분명 예외가 발생했는데 데이터베 이스에는 일부 데이터만 저장되어 있었습니다. 주문은 생성됐는데 결제 정보는 없고, 사용자 정보는 업데이트됐는데 로그는 남지 않았죠. 원인을 파악하는 데만 이틀이 걸렸고, 범인은 제가 전혀 의심하지 않았던 Checked Exception이었습니다.
그 후로 7년 동안 트랜잭션 관련 버그를 수없이 봤습니다. 재밌는 건, 시니어 개발자들도 비슷한 실수를 반복한다는 점입니다. @Transactional은 한 줄로 트랜잭션을 적용할 수 있어서 편리하지만, 그 편리함 뒤에 숨은 함정들이 있거든요.
이번 글에서는 시니어 개발자들도 자주 헷갈리는 @Transactional 실수 5가지를 실무 경험을 바탕으로 정리했습니다.
한눈에 보는 5가지 실수
graph TB
subgraph 예외 관련
M1[실수 1<br/>Checked Exception<br/>롤백 안 됨]
M3[실수 3<br/>예외 삼키기<br/>롤백 안 됨]
end
subgraph 프록시 관련
M2[실수 2<br/>Private 메서드<br/>트랜잭션 미적용]
end
subgraph 설정 관련
M4[실수 4<br/>readOnly에서<br/>쓰기 작업]
M5[실수 5<br/>전파 속성<br/>미이해]
end
M1 --> R1[부분 커밋]
M2 --> R1
M3 --> R1
M4 --> R2[DB별 동작 다름]
M5 --> R3[의도치 않은 롤백]
style M1 fill:#f8d7da
style M2 fill:#f8d7da
style M3 fill:#f8d7da
style M4 fill:#fff3cd
style M5 fill:#fff3cd실수 1: Checked Exception은 롤백 안 됨
한눈에 요약
- 기본 롤백 대상: RuntimeException + Error, Checked는 기본 커밋
- 도메인/비즈니스 예외는 Runtime 계층으로 통일, 인프라 Checked(IO/SQL)는 Service에서 래핑
rollbackFor는 예외적 케이스에만 명시적으로 사용
문제 상황
Spring의 @Transactional은 기본적으로 RuntimeException과 Error만 롤백합니다. Checked Exception이 발생하면 롤백하지 않고 커밋해버립니다.
저는 이걸 모르고 입사 첫 달에 큰 낭패를 봤습니다. 솔직히 “예외가 발생하면 당연히 롤백되는 거 아닌가?”라고 생각했거든요.
실제 코드
문제가 발생한 코드입니다. “예외가 발생하면 당연히 롤백될 거야”라고 생각하고 작성했습니다.
@Service
public class OrderService {
@Transactional
public void processOrder(Order order) throws Exception {
orderRepository.save(order); // 1. 주문 저장
paymentRepository.save(payment); // 2. 결제 정보 저장
if (payment.isFailed()) {
throw new Exception("결제 실패"); // ⚠️ Checked Exception
}
logRepository.save(log); // 3. 로그 저장
}
}예상: 예외가 발생하면 주문과 결제 정보가 모두 롤백될 것이다.
현실: 주문과 결제 정보는 그대로 커밋되고, 로그만 저장되지 않았습니다. Spring은 Checked Exception을 “복구 가능한 예외”로 보기 때문에 롤백하지 않습니다. 데이터 정합성이 완전히 깨져버린 거죠.
왜 이런 정책인가
Spring 기본 정책은 자바 진영의 “Recoverable(Checked) vs Bug(Runtime)” 구분을 따릅니다. Checked Exception은 복구 가능(recoverable)하니 커밋, RuntimeException/Error는 프로그래밍 오류이니 롤백한다는 관점입니다. 하지만 실무에서는 “결제 실패”처럼 복구 가능 여부가 맥락에 따라 달라 애매합니다.
해결 방법
방법 1: 커스텀 도메인/비즈니스 예외는 모두 RuntimeException으로
public class OrderException extends RuntimeException {
public OrderException(String message) {
super(message);
}
}방법 2: rollbackFor 속성 명시
@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws Exception {
// ...
}저희 팀은 방법 1을 선택했습니다. 도메인 예외를 아예 RuntimeException 계층으로 통일하고, 인프라에서 오는 Checked(IO/SQL 등)는 Service 계층에서 잡아 BusinessException으로 래핑합니다. 매번 rollbackFor를 기억하는 것보다 실수 가능성을 없애는 편이었습니다.
동작 원리
flowchart LR
A[예외 발생] --> B{예외 타입}
B -->|RuntimeException| C[자동 롤백]
B -->|Checked Exception| D[커밋됨]
D --> E[데이터 정합성 깨짐]
style C fill:#d4edda
style D fill:#f8d7da
style E fill:#f8d7da핵심 요약
- Checked Exception은 기본적으로 롤백되지 않음
- 커스텀 예외는 처음부터
RuntimeException으로 만들 것 - 또는
@Transactional(rollbackFor = Exception.class)명시