프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

[Spring] @Transactional 동작 원리 및 트랜잭션 미적용 원인 분석

매운할라피뇨 2026. 1. 15. 09:06
반응형

@Transactional

 

스프링 프레임워크(Spring Framework)의 선언적 트랜잭션 관리(Declarative Transaction Management)는 비즈니스 로직에 @Transactional 어노테이션을 적용하는 것만으로 복잡한 트랜잭션 처리를 추상화해 줍니다.

하지만 이 기술의 기반이 되는 AOP(Aspect Oriented Programming)와 프록시(Proxy) 패턴을 정확히 이해하지 못하면, 트랜잭션이 예상대로 동작하지 않거나 데이터 정합성이 깨지는 문제가 발생할 수 있습니다. 본 문서에서는 @Transactional의 동작 원리를 기술적으로 분석하고, 실무에서 흔히 발생하는 트랜잭션 미적용 사례와 그 해결책을 정리합니다.


1. @Transactional 동작 원리

스프링은 @Transactional이 적용된 빈(Bean)을 컨테이너에 등록할 때, 원본 객체를 감싸는 프록시 객체(Proxy Object)를 생성합니다. (기본적으로 인터페이스 유무에 따라 JDK Dynamic Proxy 또는 CGLIB Proxy가 사용됩니다.)

실제 클라이언트가 메서드를 호출할 때 일어나는 내부 프로세스는 다음과 같습니다.

  1. Caller 요청 (Intercept)
    • 외부에서 메서드를 호출하면 프록시 객체가 요청을 먼저 가로챕니다.
  2. 트랜잭션 컨텍스트 생성
    • 프록시는 TransactionManager에게 트랜잭션 생성 요청을 보냅니다.
    • TransactionManagerDataSource를 통해 DB Connection을 획득하고, setAutoCommit(false)를 설정하여 트랜잭션을 시작합니다.
    • 획득한 Connection은 TransactionSynchronizationManager(ThreadLocal)에 바인딩되어, 해당 스레드 내에서 공유됩니다.
  3. Target 메서드 실행
    • 비로소 실제 비즈니스 로직(Target Object)의 메서드가 실행됩니다. 이때 영속성 컨텍스트는 ThreadLocal에 바인딩된 Connection을 사용합니다.
  4. 트랜잭션 종료 (Commit/Rollback)
    • 메서드가 정상 종료되면 Proxy는 TransactionManager를 통해 commit을 수행합니다.
    • RuntimeException이나 Error 발생 시 rollback을 수행합니다.
    • 마지막으로 Connection을 닫고(Pool 반환), ThreadLocal 리소스를 정리합니다.

이처럼 "프록시가 요청을 가로채서 트랜잭션을 열고 닫는 과정"이 필수적이므로, 프록시를 거치지 않는 내부 호출(Self-Invocation) 시에는 트랜잭션이 적용되지 않는 것입니다.

💡 참고: JDK Dynamic Proxy vs CGLIB Proxy

  • JDK Dynamic Proxy: 인터페이스 기반으로 동작합니다. 따라서 인터페이스를 구현하지 않은 클래스에는 적용할 수 없습니다. (Java Reflection 사용)
  • CGLIB Proxy: 클래스 상속 기반으로 동작합니다. 바이트코드를 조작하여 대상 클래스를 상속받은 프록시 객체를 생성하므로, 인터페이스가 없어도 적용 가능합니다.

Spring Boot 2.0부터는 편리성(인터페이스 유무와 상관없이 동일한 방식 사용 등)을 위해 기본적으로 CGLIB를 사용하도록 설정되어 있습니다.


2. 대표적인 트랜잭션 미적용 사례 (Anti-Patterns)

1) 자기 호출 (Self-Invocation)

가장 빈번하게 발생하는 이슈입니다. 같은 클래스(Bean) 내부에서 @Transactional 메서드를 호출하는 경우, 프록시를 거치지 않고 this(인스턴스)를 통해 직접 호출되므로 트랜잭션이 적용되지 않습니다.

문제 코드:

@Service
public class OrderService {

    public void checkout(Order order) {
        // this.saveOrder(order)와 동일. 프록시가 아닌 실제 객체의 메서드를 직접 호출함.
        saveOrder(order); 
    }

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

해결책:

  • 외부 서비스 분리(권장): 트랜잭션이 필요한 메서드를 별도의 클래스(Service)로 분리하여 빈으로 주입받아 호출합니다.
  • AopContext 사용: AopContext.currentProxy()를 사용해 프록시 객체를 강제로 호출할 수 있으나, 코드가 복잡해져 권장하지 않습니다.

2) 접근 제어자 제한 (Access Modifier Limitation)

스프링의 AOP 프록시는 기본적으로 Public 메서드에만 적용됩니다. private, protected, package-private 메서드에 @Transactional을 붙여도 무시됩니다.

문제 코드:

@Transactional
protected void internalUpdate() { 
    // 트랜잭션 적용 안 됨
}

이유: JDK Dynamic Proxy와 CGLIB Proxy 모두 기술적 제약 및 스프링의 설계 원칙상 외부에서 접근 가능한 인터페이스(Public)를 대상으로 AOP를 적용하도록 설계되어 있습니다.

3) Checked Exception 발생 시 롤백 누락

스프링 트랜잭션의 기본 롤백 정책은 Unchecked Exception (RuntimeException, Error) 발생 시에만 동작합니다. Checked Exception (Exception, IOException 등)이 발생하면 트랜잭션은 커밋됩니다.

문제 코드:

@Transactional
public void process() throws IOException {
    // 파일 처리 등에서 IOException 발생 시, DB 작업이 롤백되지 않고 커밋됨
}

해결책:
Checked Exception 발생 시에도 롤백이 필요하다면 rollbackFor 옵션을 명시해야 합니다.

@Transactional(rollbackFor = Exception.class)

4) 초기화 시점 호출 (@PostConstruct)

@PostConstruct가 붙은 초기화 메서드 내부에서 @Transactional 메서드를 호출하면 트랜잭션이 적용되지 않을 수 있습니다. 빈 초기화 시점에는 아직 AOP 프록시가 완전히 적용되지 않았을 가능성이 있기 때문입니다. 이 경우 ApplicationRunner@EventListener(ApplicationReadyEvent.class)를 사용하는 것이 안전합니다.


3. 결론

@Transactional은 단순한 어노테이션이 아니라, 프록시 패턴 위에서 동작하는 정교한 메커니즘입니다.

  1. 호출 주체 확인: 반드시 외부 객체(프록시)를 통해 호출되어야 한다. (Self-invocation 주의)
  2. 접근 제어자 확인: public 메서드여야 한다.
  3. 예외 종류 확인: Checked Exception은 rollbackFor 설정을 추가해야 한다.

위 세 가지 원칙만 준수해도 실무에서 발생하는 대부분의 트랜잭션 이슈를 예방할 수 있습니다. 시스템의 데이터 무결성을 위해 어노테이션의 동작 원리를 정확히 이해하고 사용하는 습관이 중요합니다.

반응형