@Transactional 실전 함정 정리 — 셀프 호출부터 롤백까지
결론부터
@Transactional은 붙이는 순간 마법처럼 다 해결해주는 부적이 아니다. 스프링이 만든 프록시를 거쳐야만 동작하기 때문에, 같은 클래스 안에서 셀프 호출하면 통째로 무효가 되고, 기본 설정으로는 체크 예외에서 롤백도 안 된다. "어? 분명 @Transactional 붙였는데 왜 저장됐지" 싶은 장애의 8할은 이 두 가지에서 나온다. 아래 다섯 가지만 짚고 가면 실무에서 만나는 트랜잭션 사고는 거의 다 피할 수 있다.
함정 1. 셀프 호출은 프록시를 안 탄다
스프링의 @Transactional은 AOP 프록시로 동작한다. 즉 외부에서 프록시 객체를 거쳐 호출해야 트랜잭션이 시작된다. 같은 클래스 안에서 this.method()로 호출하면 프록시를 거치지 않고 원본 객체를 직접 부르는 셈이라, 애노테이션이 그냥 무시된다.
@Service
public class OrderService {
public void placeOrder(Order order) {
// this.save(order) → 프록시 안 거침 → 트랜잭션 시작 안 됨!
save(order);
}
@Transactional
public void save(Order order) {
orderRepository.save(order);
// 여기서 예외 터져도 롤백 안 됨
}
}
해결책은 단순하다. 트랜잭션이 필요한 로직은 다른 빈으로 분리해서 주입받아 호출하거나, 정 안 되면 ApplicationContext에서 자기 자신의 프록시를 꺼내 쓴다. 후자는 코드 냄새가 나므로, 가능하면 빈 분리를 우선 고려하는 게 낫다.
함정 2. 체크 예외는 기본적으로 롤백 안 된다
스프링의 기본 롤백 정책은 RuntimeException과 Error에서만 롤백이다. IOException 같은 체크 예외를 던지면, 트랜잭션은 그냥 커밋돼 버린다. "예외 처리했는데 왜 DB에는 반영이 됐지" 하는 버그의 상당수가 이거다.
// 기본값: 커밋됨 (의도와 다를 가능성 높음)
@Transactional
public void process() throws IOException {
repository.save(entity);
throw new IOException("파일 처리 실패");
}
// 명시적으로 지정해야 원하는 대로 동작
@Transactional(rollbackFor = Exception.class)
public void processSafely() throws IOException {
repository.save(entity);
throw new IOException("파일 처리 실패");
}
체크 예외를 다루는 메서드에 @Transactional을 붙인다면, 롤백 정책을 기본값에 맡기지 말고 rollbackFor를 명시하는 습관을 들이는 게 안전하다.
함정 3. readOnly는 "잠금"이 아니라 "힌트"다
@Transactional(readOnly = true)는 조회 전용 메서드에 붙이면 JPA의 더티 체킹·플러시 비용을 줄여주는 유용한 최적화다. 다만 이건 DB 레벨에서 쓰기를 강제로 막는 게 아니라 하이버네이트에 주는 힌트에 가깝다. 여기에 저장 로직을 넣으면 드라이버·설정에 따라 조용히 무시되거나, 예상 못 한 시점에 예외가 날 수 있다. "읽기 전용인데 왜 저장이 되네?" 또는 반대로 "왜 저장이 안 되지?"의 원인이 대부분 이거다. 조회 메서드와 쓰기 메서드는 애초에 다른 트랜잭션 애노테이션으로 분리하는 게 정답이다.
함정 4. 전파 옵션을 이해 못 하면 부분 롤백이 안 먹힌다
기본 전파 옵션인 REQUIRED는 이미 진행 중인 트랜잭션이 있으면 거기에 합류한다. 그래서 내부 메서드에서 예외를 잡아 "이 부분만 실패 처리하고 넘어가자" 하고 의도해도, 바깥 트랜잭션 전체가 롤백 마크(rollback-only)로 표시돼 버린다.
@Transactional
public void outer() {
try {
inner(); // 예외를 잡았다고 생각하지만
} catch (Exception e) {
log.warn("무시하고 계속 진행", e);
}
repository.save(something); // → UnexpectedRollbackException!
}
@Transactional // 기본 REQUIRED: outer의 트랜잭션에 합류
public void inner() {
repository.save(bad);
throw new RuntimeException("실패");
}
정말로 부분 실패를 허용하고 싶다면 inner()에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 줘서 독립된 트랜잭션으로 분리해야 한다. 다만 REQUIRES_NEW는 커넥션을 하나 더 점유하므로, 커넥션 풀 여유가 없는 상태에서 남발하면 그건 그것대로 사고다.
함정 5. 트랜잭션을 오래 들고 있으면 커넥션 풀이 먼저 죽는다
@Transactional 메서드 안에서 외부 API를 호출하거나 무거운 연산을 하면, 그동안 DB 커넥션 하나가 계속 물려 있는다. 트랜잭션 범위를 넓게 잡을수록 커넥션 점유 시간이 길어지고, 트래픽이 몰리는 순간 HikariCP 풀이 먼저 바닥난다. 외부 호출은 트랜잭션 바깥으로 빼고, DB 작업만 최소 범위로 묶는 게 기본 원칙이다.
실무 체크리스트
- 트랜잭션이 필요한 메서드를 같은 클래스에서 셀프 호출하고 있지 않은가?
- 체크 예외를 던지는 메서드라면
rollbackFor를 명시했는가? - 조회 전용 메서드와 쓰기 메서드를
readOnly로 명확히 분리했는가? - 내부에서 예외를 삼키는 로직이 있다면 전파 옵션을 확인했는가?
- 외부 API 호출이나 무거운 연산이 트랜잭션 범위 안에 끼어 있지 않은가?
마무리
@Transactional은 붙이는 게 어려운 게 아니라, 스프링이 이걸 어떻게 프록시로 감싸서 동작시키는지를 모르는 상태로 붙이는 게 문제다. 오늘 정리한 다섯 가지는 전부 "동작은 하는데 의도한 대로 안 되는" 조용한 버그들이다. 조용한 버그가 제일 무섭다 — 에러 로그도 안 남기고 데이터만 슬쩍 틀어져 있으니까. 새 프로젝트에 트랜잭션 로직을 짤 때는 이 체크리스트부터 한 번 돌려보자.
'Programming > Spring' 카테고리의 다른 글
| Spring Bean Scope 실무 정리: Singleton·Prototype·Request 동작과 함정 (0) | 2026.07.30 |
|---|---|
| Spring @TransactionalEventListener AFTER_COMMIT 함정: 저장 로직이 반영되지 않는 이유 (0) | 2026.07.27 |
| HikariCP 커넥션 풀 설정, 숫자의 근거 정리 (0) | 2026.07.18 |
| 스프링 MVC와 DispatcherServlet 내부 동작: 모든 요청은 여기로 집결! (0) | 2025.08.24 |
| 🌱 스프링(Spring)의 탄생과 철학 – Rod Johnson의 한 줄기 ‘봄’ (0) | 2022.11.21 |