← all posts
DEV 2026.05.02 · 14 min read Intermediate

Spring 트랜잭션은 어떻게 비즈니스 코드를 깨끗하게 유지하는가

PlatformTransactionManager 추상화부터 Propagation 7가지, Isolation Level, readOnly 최적화, Rollback 규칙, afterCommit 훅까지 — Spring 트랜잭션 설계 철학의 일관된 원리를 추적한다.


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);
}

JpaTransactionManagerDataSourceTransactionManager는 같은 인터페이스를 구현하지만 내부 동작이 다르다. JpaTransactionManager.doBegin()은 EntityManager를 생성하고, JdbcTemplate과 Connection을 공유하기 위해 DataSource 키로도 ThreadLocal에 등록한다. JPA와 JdbcTemplate을 함께 쓰는 환경에서 DataSourceTransactionManager를 따로 추가하면 두 매니저가 각자 Connection을 쓰게 되어 같은 트랜잭션에 묶이지 않는다. JpaTransactionManager 하나로 충분하다.

프록시가 만드는 경계 — 그리고 그것이 만드는 함정

@EnableTransactionManagementBeanFactoryTransactionAttributeSourceAdvisorTransactionInterceptor를 등록한다. @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이 필요해진다면 그것은 설계 개선의 신호다.

@Transactional이 무시되는 네 가지 상황

private 메서드 (CGLIB 오버라이딩 불가), final 메서드 (오버라이딩 금지), 같은 클래스 내 this 호출 (프록시 우회), Spring 빈이 아닌 객체 (new로 직접 생성). 네 경우 모두 예외 없이 조용히 무시된다.

Propagation — 물리 트랜잭션과 논리 트랜잭션

AbstractPlatformTransactionManagergetTransaction() 호출 시 현재 스레드에 트랜잭션이 있는지 확인하고, 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은 힌트만 제공한다. LazyConnectionDataSourceProxyAbstractRoutingDataSource를 조합하면 isCurrentTransactionReadOnly()를 기준으로 readOnly 트랜잭션을 리플리카 DB로 자동 라우팅할 수 있다.

SimpleJpaRepository는 클래스 레벨에 @Transactional(readOnly = true)를 선언하고, save()delete() 같은 쓰기 메서드에만 @Transactional로 오버라이드한다. 커스텀 Repository를 만들 때도 동일 원칙을 따르는 것이 권장된다.

롤백 규칙과 커밋 후 훅 — 설계가 완성되는 지점

Spring의 기본 롤백 정책: RuntimeExceptionError는 롤백, 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에서 조용히 무시된다. 함정은 설계 개선의 신호다.
  • REQUIREDrollbackOnly 마킹과 UnexpectedRollbackException, REQUIRES_NEW의 Connection 이중 점유는 Propagation을 선택하기 전에 반드시 이해해야 한다.
  • readOnly = true는 FlushMode.MANUAL + 스냅샷 최적화 + JDBC 힌트로 구체적인 성능 개선을 만든다.
  • 비즈니스 예외는 Unchecked로