[Spring] 트랜잭션 AOP 주의 사항

프록시 방식의 AOP 한계

@Transactional 어노테이션을 사용하는 트랜잭션 AOP는 프록시를 사용하는데

프록시를 사용하면 메서드 내부 호출에 프록시를 적용할 수 없는 한계를 갖습니다.

 

어떠한 한계가 생기는지 자세히 알아보기 위해 먼저 스프링 컨테이너에서 트랜잭션 프록시가 등록되는 것을 그림으로 봅시다.

스프링 컨테이너에 트랜잭션 프록시 등록

@Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 있으면 트랜잭션 AOP는 위 그림처럼 프록시를 만들어서 스프링 컨테이너에 등록합니다.

이 때, 실제 TxService 객체 대신 프록시인 TxService$$CGLIB 를 스프링 빈에 등록합니다.

그리고 프록시는 내부에 실제 TxService 를 참조합니다.

 

여기서 핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점입니다.

클라이언트인 TxTest 는 스프링 컨테이너에 @Autowired TxService txService 로 의존관계 주입을 요청하면

스프링 컨테이너에는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어 있기 때문에 프록시를 주입합니다.

 

프록시와 실제 객체의 관계를 그림으로 보면 다음과 같은 상속 관계를 갖고 있습니다.

 

프록시는 실제 객체 (TxService)를 상속해서 만들어지기 때문에 다형성을 활용할 수 있습니다.

따라서 TxService 대신프록시인 TxService$$CGLIB 를 주입할 수 있습니다.

 

그런데 이 그림만 봐서는 도대체 어디서 뭐가 문제가 되는지 알 수 없습니다.

이제 어디서 문제가 생기는 지 봅시다.

 

문제 발생

@Transactional 을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고 실제 객체를 호출해줍니다.

따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 합니다.

 

그런데 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 된다면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는 큰 문제가 발생하게 됩니다.

위 그림을 보면 CallService 클래스에서 external() 메서드에는 트랜잭션이 적용되지 않았고, internal() 메서드에는 트랜잭션이 적용되어 있습니다.

클라이언트가 CallService를 사용하기 위해 CallService를 빈 등록하고 이 객체를 의존성 주입하여 사용할 때

CallService의 internal()에 트랜잭션이 적용되어 있기 때문에 스프링은 실제 객체 대신에 프록시 객체를 주입합니다.

 

각각 메서드를 따로 사용한다면 문제가 없지만

만약 트랜잭션이 적용되지 않은 external() 메서드 내부에서 internal() 메서드를 호출할 경우 문제가 발생합니다.

@Slf4j
@SpringBootTest
public class InternalCallTest {

    @Autowired
    CallService callService;

    @Test
    void internalCall() {
        callService.internal();
    }

    @Test
    void externalCall() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallTestConfig {

        @Bean
        CallService callService() {
            return new CallService();
        }
    }

    static class CallService {

        /**
         *  트랜잭션이 적용되지 않은 external이 트랜잭션이 적용된 internal을 호출
         *  external()메서드 내부에서 호출한 internal()메서드에도 트랜잭션이 적용이 되지 않았다.
         */
        public void external() {
            log.info("call external");
            internal();
        }

        @Transactional
        public void internal() {
            log.info("call internal");
        }
    }
}

위 테스트 코드를 실행했을 시 나오는 결과를 보면

 

internal()만 호출하였을 경우 아래처럼 정상적으로 트랜잭션이 적용된 것을 볼 수 있습니다.

하지만, external() 내부에서 internal을 호출했을 경우

트랜잭션이 적용된 internal()에도 트랜잭션이 적용되지 않은 것을 볼 수 있습니다.

 

문제 원인

  1. 클라이언트인 테스트 코드는 callService.external() 을 호출. (CallService = 트랜잭션 프록시)
  2. CallService의 트랜잭션 프록시 CallService$$CGLIB가 호출.
  3. external() 메서드에는 @Transactional이 없기 때문에 트랜잭션 프록시는 트랜잭션을 적용하지 않는다.
  4. 트랜잭션 적용하지 않고, 실제 callService 객체 인스턴스의 external() 을 호출
  5. external() 은 내부에서 internal() 메서드를 호출. 그런데 여기서 문제가 발생

자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킵니다.

결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 자기 자신을 가리키므로, 실제 대상 객체( target )의 인스턴스를 뜻하게 됩니다.

그래서 결과적으로 이러한 내부 호출은 프록시를 거치지 않기 때문에 트랜잭션을 적용할 수 없습니다.

결과적으로 target 에 있는 internal() 을 직접 호출하게 된 것입니다.

 

해결 방법

메서드 내부 호출 때문에 트랜잭션 프록시가 적용되지 않는 문제를 해결하기 위해

내부호출이 아니도록 internal() 메서드를 별도의 클래스로 분리하는 방법을 사용할 수 있습니다.

@Slf4j
@SpringBootTest
public class InternalCallTest {

    @Autowired
    CallService callService;

    @Test
    void externalCall() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallTestConfig {

        @Bean
        CallService callService() {
            return new CallService(internalService());
        }

        @Bean
        InternalService internalService() {
            return new InternalService();
        }
    }

    @Slf4j
    @RequiredArgsConstructor
    static class CallService {

        private final InternalService internalService;

        /**
         *  트랜잭션이 적용되지 않은 external이 트랜잭션이 적용된 internal을 호출
         *  external()메서드 내부에서 호출한 internal()메서드에도 트랜잭션이 적용이 되지 않았다.
         *  프록시를 거치지 않고
         */
        public void external() {
            log.info("call external");
            internalService.internal();
        }
    }

    static class InternalService {
        @Transactional
        public void internal() {
            log.info("call internal");
        }
    }
}

internal() 메서드를 별도의 클래스로 분리했을 시 호출되는 흐름은 다음과 같습니다.

  1. 클라이언트인 테스트 코드는 CallService.external() 을 호출. ( 여기서 CallService는 실제 CallService객체 인스턴스 )
  2. CallService는 주입 받은 InternalService.internal() 을 호출.
  3. InternalService의 internal() 메서드에 @Transactional 이 붙어 있으므로 InternalService는 트랜잭션 프록시임으로, 트랜잭션을 적용.
  4. 트랜잭션 적용 후 실제 internalService 객체 인스턴스의 internal() 을 호출.

 

public 메서드만 트랜잭션 적용된다.

스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있습니다.

@Transactional
public class Test{
	 public method1();
	 method2(); // 트랜잭션 적용 X
	 protected method3(); // 트랜잭션 적용 X
	 private method4(); // 트랜잭션 적용 X
}

public이 아닌곳에 @Transactional 이 붙어 있으면 예외가 발생하지는 않고, 트랜잭션 적용만 무시됩니다.

 

# 스프링 부트 3.0 부터는 protected , package-visible (default 접근제한자)에도 트랜잭션이 적용된다고 합니다.


초기화 코드와 트랜잭션을 함께 사용시 주의점

초기화 코드(예: @PostConstruct )와 @Transactional 을 함께 사용하면 트랜잭션이 적용되지 않습니다.

그 이유는 초기화 코드가 먼저 호출되고, 그 다음에 트랜잭션 AOP가 적용되기 때문입니다.

따라서 초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없습니다.

 

하지만 초기화 시점에 트랜잭션을 사용해야한다면 @EventListener(ApplicationReadyEvent.class) 사용하여 해결 할 수 있습니다.

// 트랜잭션 적용 X
@PostConstruct 
@Transactional
public void initV1() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    log.info("Hello init @PostConstruct tx active = {}", isActive);
}

 

// 트랜잭션 적용 O
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    log.info("Hello init ApplicationReadyEvent tx active = {}", isActive);
}