Backend/Spring

스프링의 트랜잭션 AOP(@Transactional)의 특징과 주의사항

olsohee 2024. 1. 16. 17:33

트랜잭션 AOP 특징

지난 글(https://olsohee.tistory.com/82)에서는 스프링이 트랜잭션을 제공하는 방식에 대해 알아봤다. 이번에는 스프링이 제공하는 트랜잭션 중 선언적 트랜잭션 관리 방식인 @Transactional에 대해 더 자세히 알아보자.

  • @Transactional을 사용하면 스프링의 트랜잭션 AOP가 적용된다.
  • @Transactional 어노테이션이 특정 클래스나 메소드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다. 즉 실제 객체(BasicService)가 아닌 프록시 객체(BasicService$$CGLIB)가 스프링 빈에 등록된다.
  • 이때 프록시 객체는 원본 객체를 상속받고, 내부에 실제 객체(BasicService)를 참조한다. 
  • 애플리케이션 로직에서 @Transactional이 붙은 클래스를 @Autowired로 주입받고자 하면, 이때 프록시 객체가 스프링 빈으로 등록되어 있기 때문에 실제 객체가 아닌 프록시 객체가 주입된다. (프록시 객체는 원본 객체를 상속받고 있기 때문에 BasicService 타입의 변수에 BasicService 대신 BasicService$$CGLIB를 주입받을 수 있다. 즉, 다형성을 활용할 수 있다.)
  • 실제 객체를 호출하면 의존관계가 주입된 프록시 객체가 먼저 요청을 받아서 트랜잭션을 시작하고, 실제 객체의 메소드를 호출한다.

트랜잭션 AOP 주의사항

내부 호출시 AOP가 적용되지 않는 문제

앞서 살펴본 것처럼 @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고 실제 객체를 호출해준다. 따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체을 호출해야 한다. 이렇게 해야 먼저 프록시에서 트랜잭션이 시작되고 이후에 대상 객체가 호출되기 때문이다. 만약 프록시를 거치지 않고 대상 객체를 직접 호출하면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다.

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항상 실제 객체 대신에 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 이렇게 되면 @Transactional이 있어도 트랜잭션이 적용되지 않는다.

 

내부 호출시 @Transactional이 적용되지 않는 이유를 예제를 통해 살펴보자.

static class CallService {
    
    public void external() {
        internal(); // 메소드 내부 호출
        ...
    }

    @Transactional
    public void internal() { 
        ...
    }
}

 

@SpringBootTest
public class InternalCallTest {

    @Autowired
    CallService callService;
    
    @TestConfiguration
    static class InternalCallConfig {
    
        @Bean
        CallService callService() {
            return new CallService();
        }
    }
    
    @Test
    void internalCall() {
        callService.internal();
    }

    @Test
    void externalCall() {
        callService.external();
    }
}
  • CallService의 internal() 메소드에는 트랜잭션이 적용되고, external()에는 트랜잭션이 적용되지 않는다.
  • @Transactional가 하나라도 있으면 프록시 객체가 생성되고 빈으로 등록된다. 따라서 테스트 코드에서 CallService를 주입받을 때 주입되는 객체는 프록시 객체이다.
  • internalCall() 테스트의 실행 흐름은 다음과 같다.
    1. 테스트 코드는 callService.internal() 메소드를 호출한다.
    2. 이때 프록시 객체가 호출된다.
    3. internal() 메소드에 @Transactional이 붙어 있으므로 프록시 객체는 트랜잭션을 적용한다.
    4. 트랜잭션 적용 후에, 프록시 객체는 실제 객체의 internal() 메소드를 호출한다.

  • externalCall() 테스트의 실행 흐름은 다음과 같다.
    1. 테스트 코드는 callService.external() 메소드를 호출한다.
    2. 이때 프록시 객체가 호출된다.
    3. external() 메소드에는 @Transactional이 없다. 따라서 프록시 객체는 트랜잭션을 적용하지 않는다.
    4. 트랜잭션을 적용하지 않고, 프록시 객체는 실제 객체의 external() 메소드를 호출한다.
    5. external() 메소드는 내부에서 internal() 메소드를 호출한다. 따라서 이때 트랜잭션이 적용되어야 할 internal() 메소드가 트랜잭션이 적용되지 않은 채 호출되는 문제가 발생한다.

이렇게 프록시를 사용하는 트랜잭션 AOP는 내부 호출시에 프록시를 적용할 수 없다는 한계가 있다. 이를 해결하기 위해서는 internal() 메소드를 별도의 클래스로 분리하면 된다. 그러면 다음과 같은 흐름으로 동작한다.

  1. 테스트 코드는 callService.external() 메소드를 호출한다.
  2. callService에는 @Transactional이 없기 때문에 실제 객체의 external() 메소드가 호출된다.
  3. callService는 internalService.internal() 메소드를 호출한다.
  4. 이때 internalService의 internal() 메소드에 @Transactional이 붙어있기 때문에 internalService의 프록시 객체의 internal() 메소드가 호출된다.
  5. 프록시 객체는 internal()에 @Transactionl이 붙어 있으므로 트랜잭션을 적용한다.
  6. 트랜잭션을 적용한 후에, 실제 객체인 internalService의 internal() 메소드를 호출한다.
  7. external() 메소드에는 @Transactional이 없다. 따라서 프록시 객체는 트랜잭션을 적용하지 않는다.

초기화 시점에 따라 AOP가 적용되지 않는 문제

초기화 코드(ex, @PostConstruct)와 @Transactionl을 함께 사용하면 트랜잭션이 적용되지 않는다. 초기화 코드가 먼저 호출되고그 다음에 트랜잭션 AOP가 적용되기 때문이다따라서 초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없다.

@PostConstruct
@Transactional
public void init() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    log.info("tx active={}", isActive); // false
}
초기화 메소드에도 트랜잭션을 적용하려면 ApplicationReadyEvent를 사용하면 된다. @EventListener(value = ApplicationReadyEvent.class)는 트랜잭션 AOP를 포함한 스프링 컨테이너가 완전히 생성된 후에 해당 어노테이션이 붙은 메소드가 호출한다.
@EventListener(value = ApplicationReadyEvent.class)
@Transactional
public void init() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    log.info("tx active={}", isActive); // true
}

 

예외와 트랜잭션 

만약 예외가 발생했는데 내부에서 예외를 처리하지 못하고 트랜잭션 범위(트랜잭션 AOP) 밖으로 예외가 던져지면 어떻게 될까? 스프링이 제공하는 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋 또는 롤백한다.

  • RuntimeException, Error와 같은 언체크 예외는 롤백한다.
  • Exception과 같은 체크 예외를 커밋한다.

rollbackFor 옵션을 사용하면 추가로 어떤 예외가 발생했을 때 롤백할지 지정할 수 있다. 예를 들어 다음과 같이 지정하면 Exception이 발생해도 롤백한다. (이때 자식 타입도 롤백된다.)

@Transactional(rollbackFor = Exception.class)

 

반대로 noRollbackFor은 어떤 예외가 발생했을 때 롤백하면 안되는지 지정할 수 있다.


Reference

  • 인프런, 스프링 DB 2편 - 데이터 접근 핵심 원리,김영한