Spring Transaction AOP 주의사항

강정우·2024년 2월 23일
0

Spring-boot

목록 보기
67/73

트랜잭션 AOP 주의 사항

프록시 내부 호출

@Transactional 을 사용하면 스프링의 트랜잭션 AOP가 적용된다.
트랜잭션 AOP는 기본적으로 프록시 방식의 AOP를 사용한다

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

내부 호출이 발생할 때 문제.

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

내부 호출 발생 예제 코드

@Slf4j
@SpringBootTest
public class InternalCallV1Test {

    @Autowired
    CallService callService;

    @Test
    void printProxy() {
        log.info("callService class={}", callService.getClass());
    }

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

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

    @TestConfiguration
    static class InternalCallV1TestConfig {

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

    @Slf4j
    static class CallService {

        public void external() {
            log.info("call external");
            printTxInfo();
            this.internal();
        }

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

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
}

위 코드중 internalCall을 실행하면 당연히 우리가 생각하는 결과대로 transaction이 시작하고

잘 커밋 했다는 것 까지 확인할 수 있다.

하지만 최초에 transaction을 걸지 않은 external을 실행하면 어쩔까?

우선 언뜻 봐도 Getting Transaction이 없다. 즉, 디테일하게 @Transaction 을 걸어놔서 트랜잭션이 시작되었어야 할 intercal 메서드가 제대로 트랜잭션이 시작되지 않은 것이다.

사진으로 보면 위와 같다. 앞서 AOP로 인하여 Bean으로 등록 되는 것은 실제 객체가 아닌 Proxy 객체라고 하였다.
그런데 여기서 트랜잭션이 시작되지 않은 Proxy 객체를 호출한 것이 트랜잭션이 시작되지 않는 원인이 되었다.

문제 원인

자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다.
결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 자기 자신을 가리키므로, 실제 대상 객체( target )의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 트랜잭션을 적용할 수 없다. 결과적으로 target 에 있는 internal() 을 직접 호출하게 된 것이다.

프록시 방식의 AOP 한계

@Transactional 를 사용하는 트랜잭션 AOP는 프록시를 사용한다. 프록시를 사용하면 메서드 내부 호출에 프록시를 적용할 수 없다.

그렇다면 이 문제를 어떻게 해결할 수 있을까?

-> 가장 단순한 방법은 내부 호출을 피하기 위해 internal() 메서드를 별도의 클래스로 분리하는 것이다
즉, 한마디로 내부 호출에서 외부 호출로 바꾸면 된다는 것이다. 테스트 코드는 아래와 같다.

외부 호출

@Slf4j
@SpringBootTest
public class InternalCallV2Test {

    @Autowired
    CallService callService;

    @Test
    void printProxy() {
        log.info("callService class={}", callService.getClass());
    }

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

    @TestConfiguration
    static class InternalCallV1TestConfig {

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

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

    }

    @Slf4j
    @RequiredArgsConstructor
    static class CallService {

        private final InternalService internalService;

        public void external() {
            log.info("call external");
            printTxInfo();
            internalService.internal();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }

    static class InternalService {

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

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
        }
    }
}

보면 이제 우리가 의도한 대로 작동하는 것을 볼 수 있다. 이를 그림으로 보자면

이와 같이 생겼는데 우선 callService 는 주입 받은 internalService.internal() 을 호출한다.
다음 internalService 는 트랜잭션 프록시이다. internal() 메서드에 @Transactional 이 붙어 있으므로 트랜잭션 프록시는 트랜잭션을 적용한다.
마지막으로 트랜잭션 적용 후 실제 internalService 객체 인스턴스의 internal() 을 호출한다

참고로 여기서 호출하는 callService는 프록시 객체가 아니라 그냥 callService 객체 인스턴스이다.

protected 메서드는 트랜잭션 적용 안 됨.

스프링 부트 3.0 이전에는 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있다.
그래서 protected, private, default(package-visible) 에는 트랜잭션이 적용되지 않는다.
하지만 3.0 이후버전에는 protected, package-visible 도 @Transaction이 적용된다.

참고로 protected에 @Transactional 이 붙어 있으면 예외가 발생하지는 않고, 트랜잭션 적용만 무시된다.

초기화 시점

스프링 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있다.

@SpringBootTest
public class InitTxTest {

    @Autowired Hello hello;

    @Test
    void go() {
        //초기화 코드는 스프링이 초기화 시점에 호출한다.
    }

    @TestConfiguration
    static class InitTxTestConfig {
        @Bean
        Hello hello() {
            return new Hello();
        }
    }

    @Slf4j
    static class Hello {

        @PostConstruct
        @Transactional
        public void initV1() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("Hello init @PostConstruct tx active={}", isActive);
        }

        @EventListener(ApplicationReadyEvent.class)
        @Transactional
        public void initV2() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("Hello init ApplicationReadyEvent tx active={}", isActive);
        }
    }
}

초기화 코드가 먼저 호출되고, 그 다음에 트랜잭션 AOP가 적용되기 때문에 따라서 초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없다.

따라서 가장 확실한 대안은 initV2 메서드 처럼 EventListener 이벤트와 ApplicationReadyEvent 클래스를 사용하는 것이다.
이는 스프링이 컨테이너 다 만들고 AOP 다 만든 후에 호출하는 방법이다.

위 결과 로그를 보면 앞서 설명한 대로 우선 @PostConstructor가 동작 후 스프링이 완전히 뜨고 트랜잭션을 생성 후 갖고와서 동작하는 것을 볼 수 있다.

따라서 그냥 일반적으로 뭔갈 초기화 할 땐 @PostConstrucor를 사용하면 되고
초기화 시점에서 트랜잭션으로 안에서 뭘갈 해야할 땐 EventListener를 사용하면 된다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글