Spring 트랜잭션은 어떻게 비즈니스 코드를 깨끗하게 유지하는가
PlatformTransactionManager 추상화부터 Propagation 7가지, Isolation Level, readOnly 최적화, Rollback 규칙, afterCommit 훅까지 — Spring 트랜잭션 설계 철학의 일관된 원리를 추적한다.
- 01 Spring Data JPA는 인터페이스 선언만으로 어떻게 동작하는가
- 02 Spring 트랜잭션은 어떻게 비즈니스 코드를 깨끗하게 유지하는가
- 03 JPA는 어떻게 객체와 DB를 동기화하는가
- 04 Spring Data JPA의 쿼리 전략은 왜 이렇게 많은가
- 05 JdbcTemplate이 JDBC 보일러플레이트를 제거하는 방법
- 06 HikariCP는 왜 다른 Connection Pool보다 빠른가
- 07 Spring Data 테스트는 왜 이렇게 설계됐는가
Spring 트랜잭션은 @Transactional 어노테이션 하나로 시작한다. 그런데 이 한 줄 뒤에는 PlatformTransactionManager 추상화, CGLIB 프록시, ThreadLocal 기반 자원 관리, Propagation 분기, Isolation Level 설정, 롤백 규칙 평가, 커밋 후 훅까지 — 수십 개의 설계 결정이 겹쳐있다. 이 모든 결정은 과연 하나의 철학에서 나오는가?
추상화의 출발점 — 기술 종속을 끊다
JDBC, Hibernate, JTA는 트랜잭션 API가 전부 다르다. Spring은 PlatformTransactionManager 인터페이스 세 개의 메서드(getTransaction, commit, rollback)로 이 차이를 숨긴다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(@Nullable TransactionDefinition definition);
void commit(TransactionStatus status);
void rollback(TransactionStatus status);
}
JpaTransactionManager와 DataSourceTransactionManager는 같은 인터페이스를 구현하지만 내부 동작이 다르다. JpaTransactionManager.doBegin()은 EntityManager를 생성하고, JdbcTemplate과 Connection을 공유하기 위해 DataSource 키로도 ThreadLocal에 등록한다. JPA와 JdbcTemplate을 함께 쓰는 환경에서 DataSourceTransactionManager를 따로 추가하면 두 매니저가 각자 Connection을 쓰게 되어 같은 트랜잭션에 묶이지 않는다. JpaTransactionManager 하나로 충분하다.
프록시가 만드는 경계 — 그리고 그것이 만드는 함정
@EnableTransactionManagement는 BeanFactoryTransactionAttributeSourceAdvisor와 TransactionInterceptor를 등록한다. @Transactional이 붙은 빈은 CGLIB 서브클래스 프록시로 교체되고, 메서드 호출마다 TransactionInterceptor.invoke()가 끼어든다.
클라이언트 → 프록시.method()
→ TransactionInterceptor.invoke()
→ PlatformTransactionManager.getTransaction() ← 트랜잭션 시작
→ 실제 메서드 실행
→ commit() 또는 rollback()
CGLIB은 원본 클래스를 상속해 서브클래스를 만든다. Java 상속 규칙상 private 메서드와 final 메서드는 오버라이딩이 불가하다. 따라서 이 두 경우에는 @Transactional이 조용히 무시된다 — 컴파일 오류도, 런타임 경고도 없이.
더 흔한 함정은 Self-Invocation이다.
@Service
public class OrderService {
@Transactional
public void process(Order order) {
save(order); // this.save() — 프록시가 아닌 실제 객체 직접 호출
}
@Transactional(propagation = REQUIRES_NEW) // 무시됨
public void save(Order order) { ... }
}
process() 내부의 this는 프록시가 아니라 실제 OrderService 인스턴스다. TransactionInterceptor를 거치지 않으므로 REQUIRES_NEW는 적용되지 않는다. 해결책은 하나 — 메서드를 별도 빈으로 분리하는 것이다. 클래스 안에서 책임이 뒤섞여 Self-Invocation이 필요해진다면 그것은 설계 개선의 신호다.
private 메서드 (CGLIB 오버라이딩 불가), final 메서드 (오버라이딩 금지), 같은 클래스 내 this 호출 (프록시 우회), Spring 빈이 아닌 객체 (new로 직접 생성). 네 경우 모두 예외 없이 조용히 무시된다.
Propagation — 물리 트랜잭션과 논리 트랜잭션
AbstractPlatformTransactionManager는 getTransaction() 호출 시 현재 스레드에 트랜잭션이 있는지 확인하고, Propagation에 따라 분기한다.
물리 트랜잭션은 실제 JDBC Connection의 트랜잭션이다. 논리 트랜잭션은 @Transactional 메서드 참여 단위다. REQUIRED(기본값)는 여러 논리 트랜잭션이 하나의 물리 트랜잭션을 공유한다. REQUIRES_NEW는 기존 트랜잭션을 suspend()로 ThreadLocal에서 제거하고 새 Connection을 획득해 새 물리 트랜잭션을 시작한다.
REQUIRED에서 자주 만나는 함정이 있다. 내부 메서드에서 RuntimeException이 발생하면 TransactionInterceptor는 직접 롤백하는 대신 TransactionStatus.setRollbackOnly()만 마킹한다. 외부에서 try-catch로 예외를 잡아도 이 마킹은 해제되지 않는다. 외부 메서드가 정상 완료되어 commit()을 시도하는 순간 isGlobalRollbackOnly() = true를 감지하고 UnexpectedRollbackException을 던진다.
REQUIRES_NEW는 독립성을 보장하지만 Connection을 동시에 두 개 점유한다. Pool 크기를 고려하지 않으면 Pool 고갈과 데드락으로 이어진다. 감사 로그나 알림 발송 같은 비핵심 작업에만 제한적으로 써야 한다.
Isolation Level과 Lock — 동시성의 두 축
@Transactional(isolation = REPEATABLE_READ)는 doBegin() 안에서 Connection.setTransactionIsolation()으로 JDBC에 전달된다. Spring의 기본값은 ISOLATION_DEFAULT(-1) — DB 기본값을 그대로 쓴다.
동시성 문제는 두 축으로 다룬다. Isolation Level은 “얼마나 다른 트랜잭션의 변경을 볼 수 있는가”를 조절하고, Lock은 물리적 접근을 막는다. MySQL InnoDB는 REPEATABLE_READ가 기본이고 Gap Lock으로 Phantom Read도 방지한다. PostgreSQL은 READ_COMMITTED가 기본이고 SSI로 SERIALIZABLE 오버헤드를 최소화한다.
Lost Update(갱신 손실)는 Isolation Level만으로 해결되지 않는다.
- 충돌이 드문 환경 →
@Version(Optimistic Lock): version 컬럼을 조건절에 포함해 0 rows 반환 시OptimisticLockingFailureException발생. 재시도 비용만 있다. - 충돌이 잦은 환경 →
@Lock(LockModeType.PESSIMISTIC_WRITE):SELECT ... FOR UPDATE로 배타 락 획득. Lock 대기 비용이 있지만 재시도가 없다.
Optimistic Lock은 충돌 빈도가 낮을 때 오버헤드가 거의 없다. Pessimistic Lock은 충돌이 잦은 환경에서 오히려 효율적이다. 재시도 폭증과 Lock 대기 중 어느 쪽이 더 비싼지는 측정으로 결정해야 한다.
readOnly=true — 작은 선언, 구체적인 효과
@Transactional(readOnly = true)는 세 곳에 영향을 준다.
첫째, JpaTransactionManager.doBegin()에서 session.setHibernateFlushMode(FlushMode.MANUAL)을 설정한다. 자동 flush가 비활성화되므로 트랜잭션 커밋 시 dirty checking이 일어나지 않는다. 1000건 조회 시 스냅샷 비교 비용이 사라진다.
둘째, session.setDefaultReadOnly(true)로 로드되는 엔티티의 loadedState(스냅샷)를 null로 만든다. 스냅샷 메모리를 절감한다.
셋째, Connection.setReadOnly(true)를 통해 JDBC 드라이버에 힌트를 전달한다. PostgreSQL은 실제로 쓰기를 차단하고, MySQL은 힌트만 제공한다. LazyConnectionDataSourceProxy와 AbstractRoutingDataSource를 조합하면 isCurrentTransactionReadOnly()를 기준으로 readOnly 트랜잭션을 리플리카 DB로 자동 라우팅할 수 있다.
SimpleJpaRepository는 클래스 레벨에 @Transactional(readOnly = true)를 선언하고, save()와 delete() 같은 쓰기 메서드에만 @Transactional로 오버라이드한다. 커스텀 Repository를 만들 때도 동일 원칙을 따르는 것이 권장된다.
롤백 규칙과 커밋 후 훅 — 설계가 완성되는 지점
Spring의 기본 롤백 정책: RuntimeException과 Error는 롤백, Checked Exception은 커밋. Java 설계 철학에서 Checked Exception은 “복구 가능한 예외”이므로 트랜잭션을 유지하는 것이 합리적이라는 판단이다.
이것이 조용한 데이터 오염의 원인이 된다. 비즈니스 예외를 Checked로 설계하면 rollbackFor를 누락했을 때 예외도 없이 커밋된다. 현대 Spring 애플리케이션에서 비즈니스 예외는 RuntimeException 하위로 설계하는 것이 권장된다. Spring과 JPA의 예외 계층 자체가 모두 Unchecked다.
트랜잭션이 완료된 후의 작업 — 이메일 발송, 캐시 무효화, 외부 API 호출 — 은 TransactionSynchronization으로 처리한다.
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
emailService.sendConfirmation(order); // 커밋 확정 후 실행
}
}
);
@TransactionalEventListener(phase = AFTER_COMMIT)은 이것을 선언적으로 쓰는 방식이다. 트랜잭션 내에서 이벤트를 발행하고, 커밋 후에 리스너가 실행된다. 롤백 시에는 실행되지 않으므로 이메일과 주문의 불일치가 발생하지 않는다.
정리
PlatformTransactionManager는 기술 종속을 끊는다. JPA + JDBC 혼용 환경에서는JpaTransactionManager하나만 등록하면 된다.@Transactional은 CGLIB 프록시를 통해 동작한다.private,final, Self-Invocation에서 조용히 무시된다. 함정은 설계 개선의 신호다.REQUIRED의rollbackOnly마킹과UnexpectedRollbackException,REQUIRES_NEW의 Connection 이중 점유는 Propagation을 선택하기 전에 반드시 이해해야 한다.readOnly = true는 FlushMode.MANUAL + 스냅샷 최적화 + JDBC 힌트로 구체적인 성능 개선을 만든다.- 비즈니스 예외는 Unchecked로