Spring 트랜잭션과 전파
PlatformTransactionManager 추상화부터 전파 옵션, 롤백 규칙, rollback-only 함정까지 정리합니다.
Contents
트랜잭션 경계는 서비스 계층에 두고, 전파 옵션이 호출 사이에서 트랜잭션을 합칠지 분리할지 결정합니다.
트랜잭션 경계를 어디에 둘지
트랜잭션은 여러 데이터 변경을 하나의 업무 단위로 묶는 장치입니다. 서비스 계층의 한 메서드가 여러 저장소 작업을 호출하는 경우가 많아서, 경계를 서비스 계층에 두는 편이 가장 자연스럽습니다. 회원 저장과 로그 저장을 한 번에 성공하거나 한 번에 되돌리려면 둘을 같은 경계 안에 넣어야 합니다.
경계를 어디에 두느냐에 따라 어떤 작업이 함께 커밋되고 함께 롤백되는지가 갈립니다. 컨트롤러에 경계를 두면 화면 로직까지 트랜잭션에 묶이고, 저장소마다 따로 두면 부분 실패가 생깁니다. 그래서 업무 단위가 끝나는 지점, 즉 서비스 메서드 단위가 기본 후보가 됩니다.
PlatformTransactionManager로 기술 차이를 감춘다
JDBC와 JPA는 트랜잭션을 시작하고 끝내는 코드가 서로 다릅니다. JDBC는 Connection을, JPA는 EntityManager를 직접 다루므로, 기술을 바꾸면 트랜잭션 코드도 함께 바뀝니다. 스프링은 이 차이를 PlatformTransactionManager 인터페이스로 추상화합니다.
package org.springframework.transaction;
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
throws TransactionException;
void commit(TransactionStatus status) throws TransactionException;
void rollback(TransactionStatus status) throws TransactionException;
}인터페이스가 노출하는 동작은 시작, 커밋, 롤백 세 가지입니다. getTransaction()은 트랜잭션을 새로 시작할 수도 있고, 진행 중인 트랜잭션이 있으면 거기에 참여할 수도 있습니다. 이 참여 여부를 정하는 규칙이 뒤에서 다룰 전파입니다.
구현체는 데이터 접근 기술별로 준비되어 있습니다. JdbcTemplate이나 MyBatis는 DataSourceTransactionManager를, JPA는 JpaTransactionManager를 사용합니다. 스프링 부트는 사용하는 기술을 인식해 적절한 매니저를 빈으로 자동 등록하므로, 직접 고르는 단계까지 줄어듭니다.
참고로 스프링 5.3부터는 JdbcTransactionManager가 추가됐습니다(Spring Framework 5.3 레퍼런스 기준). 이는 DataSourceTransactionManager를 상속해 기능을 보탠 구현체입니다.
선언적 트랜잭션은 프록시로 동작한다
@Transactional을 메서드나 클래스에 붙이면 관점 지향 프로그래밍(Aspect-Oriented Programming, AOP)이 트랜잭션 처리에 적용됩니다. 스프링은 대상 객체 대신 트랜잭션을 처리하는 프록시 객체를 빈으로 등록하고, 의존성 주입 시점에도 프록시를 주입합니다. 호출은 프록시를 먼저 거치며, 프록시가 트랜잭션을 시작한 뒤 실제 객체를 호출합니다.
위 그림은 프록시가 트랜잭션 시작과 종료를 호출 경계에서 감싸는 흐름을 보여줍니다. 인터페이스가 없으면 클래스 기반 프록시인 CGLIB가 쓰이고, 인터페이스가 있으면 JDK 동적 프록시가 쓰입니다. 어느 쪽이든 트랜잭션이 적용되려면 호출이 반드시 프록시를 통과해야 합니다.
물리 트랜잭션과 논리 트랜잭션
전파를 이해하려면 두 개념을 구분해야 합니다. 물리 트랜잭션은 실제 데이터베이스 커넥션의 커밋과 롤백 단위입니다. 논리 트랜잭션은 @Transactional이 붙은 메서드 하나하나가 만드는 트랜잭션 경계입니다.
기본 전파에서는 외부 메서드가 트랜잭션을 시작하면 물리 트랜잭션이 하나 열립니다. 그 안에서 호출되는 내부 메서드는 새 트랜잭션을 만들지 않고 기존 트랜잭션에 참여합니다. 이때 외부와 내부는 각각 논리 트랜잭션이지만, 묶이는 물리 트랜잭션은 하나입니다. 그래서 커밋과 롤백은 외부(신규 트랜잭션)가 끝날 때 한 번만 실제로 일어납니다.
전파 옵션 일곱 가지
전파(propagation)는 기존 트랜잭션이 있을 때 참여할지, 새로 만들지, 거부할지를 정하는 규칙입니다. 스프링이 제공하는 옵션은 다음과 같습니다(Spring Framework 레퍼런스의 Propagation enum 기준).
| 옵션 | 기존 트랜잭션 없음 | 기존 트랜잭션 있음 |
|---|---|---|
REQUIRED(기본) | 새 트랜잭션 시작 | 기존에 참여 |
REQUIRES_NEW | 새 트랜잭션 시작 | 기존을 보류하고 별도 물리 트랜잭션 시작 |
SUPPORTS | 트랜잭션 없이 진행 | 기존에 참여 |
NOT_SUPPORTED | 트랜잭션 없이 진행 | 기존을 보류하고 트랜잭션 없이 진행 |
MANDATORY | IllegalTransactionStateException | 기존에 참여 |
NEVER | 트랜잭션 없이 진행 | IllegalTransactionStateException |
NESTED | 새 트랜잭션 시작 | 세이브포인트로 중첩 트랜잭션 생성 |
표의 대부분은 REQUIRED로 해결됩니다. 일반 서비스 호출은 하나의 업무 단위로 묶는 것이 자연스럽기 때문입니다. MANDATORY와 NEVER는 트랜잭션 존재 여부를 강제로 검증하고 싶을 때, NESTED는 JDBC 세이브포인트로 부분 롤백이 필요할 때 고려합니다. 다만 세이브포인트는 드라이버가 지원해야 동작합니다.
REQUIRED와 REQUIRES_NEW의 차이
REQUIRED는 내부 호출이 외부 트랜잭션에 합류해 물리 트랜잭션 하나를 공유합니다. REQUIRES_NEW는 외부 트랜잭션을 잠시 보류하고, 내부가 자신만의 물리 트랜잭션을 새로 엽니다. 두 옵션은 부분 실패를 다루는 방식이 다릅니다.
로그 저장이 실패해도 회원 가입은 유지되어야 하는 경우를 가정합니다. REQUIRED로 묶으면 로그 실패가 전체 물리 트랜잭션을 롤백시키므로 회원 가입까지 사라집니다. 독립된 커밋이 필요하다면 로그 저장만 REQUIRES_NEW로 분리합니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
em.persist(logMessage);
}REQUIRES_NEW는 외부 트랜잭션을 보류한 채 별도 커넥션을 추가로 점유합니다. 커넥션을 동시에 두 개 쓰는 셈이라, 호출이 잦으면 커넥션 풀이 빨리 고갈됩니다. 그래서 감사 로그나 아웃박스처럼 독립 기록이 필요한 경우에만 제한적으로 씁니다.
롤백 규칙과 예외 타입
트랜잭션 AOP는 메서드 밖으로 던져진 예외의 종류를 보고 커밋과 롤백을 정합니다. 기본 정책은 예외 계층에 따라 갈립니다.
- 언체크 예외(
RuntimeException,Error와 그 하위): 롤백합니다. - 체크 예외(
Exception과 그 하위): 커밋합니다.
이 정책은 체크 예외를 비즈니스 의미가 있는 신호로, 언체크 예외를 복구 불가능한 오류로 가정한 것입니다. 정책을 바꾸려면 rollbackFor로 롤백 대상 예외를 추가합니다.
@Transactional(rollbackFor = Exception.class)
public void order() {
// 체크 예외가 던져져도 롤백된다
}rollbackFor = Exception.class로 지정하면 체크 예외가 발생해도 롤백되며, 하위 예외까지 대상에 포함됩니다. 반대로 특정 예외만 롤백에서 빼려면 noRollbackFor를 씁니다. 기본 정책은 예외 타입에 묶여 있으므로, 의도가 다르면 옵션으로 명시해야 합니다.
rollback-only가 만드는 함정
내부 논리 트랜잭션이 롤백되면, 그 트랜잭션은 신규 물리 트랜잭션이 아니므로 즉시 롤백하지 못합니다. 대신 트랜잭션 동기화 매니저에 rollback-only 표시만 남깁니다. 물리 트랜잭션의 커밋과 롤백은 외부(신규 트랜잭션)가 끝낼 때 결정되기 때문입니다.
여기서 함정이 생깁니다. 외부에서 내부 예외를 잡아 정상 흐름으로 바꿔도, 외부가 커밋을 시도하는 순간 트랜잭션 매니저가 rollback-only 표시를 발견합니다. 이때 물리 트랜잭션은 롤백되고 UnexpectedRollbackException이 던져집니다.
이 동작은 모호함을 없애려는 설계입니다. 커밋을 호출했는데 내부에서 이미 롤백이 결정된 상태라면, 조용히 넘어가는 대신 예외로 명확히 알리는 편이 안전합니다. 로그 실패와 회원 가입을 분리하려면 예외를 잡는 것만으로는 부족하고, 앞서 본 REQUIRES_NEW로 물리 트랜잭션 자체를 떼어 놓아야 합니다.
self-invocation과 프록시 우회
트랜잭션이 적용되려면 호출이 프록시를 통과해야 한다는 원칙에는 흔한 예외가 있습니다. 같은 객체 안에서 자기 메서드를 직접 부르는 경우(self-invocation)입니다. 이때 호출은 프록시를 거치지 않고 대상 객체의 this로 직접 이어지므로 트랜잭션 AOP가 끼어들 틈이 없습니다.
@Service
public class OrderService {
public void external() {
internal(); // this.internal() — 프록시를 우회한다
}
@Transactional
public void internal() {
// 여기에 트랜잭션이 적용되지 않을 수 있다
}
}external()이 internal()을 내부에서 호출하면 @Transactional이 무시될 수 있습니다. 프록시를 거치지 않아 트랜잭션 시작 코드가 실행되지 않기 때문입니다. 해결책은 트랜잭션이 필요한 메서드를 별도 빈으로 분리해 프록시를 통해 호출하는 것입니다. 클래스 분리가 부담스러우면 자기 자신을 빈으로 주입받아 호출하는 우회도 있지만, 보통은 책임을 나누는 편이 깔끔합니다.
정리
스프링 트랜잭션은 경계를 서비스 계층에 두고 PlatformTransactionManager로 데이터 접근 기술의 차이를 감춥니다. 선언적 트랜잭션은 프록시를 거쳐 동작하므로, 프록시를 우회하는 self-invocation에서는 트랜잭션이 빠질 수 있습니다. 전파는 기존 트랜잭션에 참여할지 새로 만들지를 정하며, 대부분 REQUIRED로 충분합니다. 독립 커밋이 필요할 때만 REQUIRES_NEW를 제한적으로 씁니다.
롤백은 언체크 예외에서 일어나고 체크 예외에서는 커밋되므로, 의도가 다르면 rollbackFor로 명시합니다. 내부 롤백이 남긴 rollback-only 표시는 외부 커밋 시점에 UnexpectedRollbackException으로 드러납니다. 그래서 부분 실패를 다룰 때는 물리 트랜잭션의 경계를 먼저 점검합니다.