<Spring> AOP 적용 시 주의 사항들

본 포스팅에서는 spring AOP를 사용할 경우 발생 할 수 있는 문제들에 대해서 알아보고 이를 어떻게 하면 해결할 수 있는지 확인해 볼 예정입니다.

Proxy 내부 호출

본 이슈의 경우 김영한님의 AOP 관련 강의를 수강하였다면 한번은 들어봤을 문제이다. 이를 이해하기 위해서는 우선 스프링 AOP가 어떻게 동작하는지 이해하고 있어야 한다. 아래 그림을 통해 이해해보자.

AOP를 특정 빈에 적용하였을 경우 생성되는 빈의 형상이다. 원래 생성하려던 빈은 Proxy 객체에 의해 한번 감싸지게 되고 해당 Proxy 객체가 AOP를 이용해 수행하려는 로직을 수행시킨 뒤, Target 객체를 호출하여 사용자가 작성한 원래 클래스의 로직을 수행해준다.

예를 들어 특정 메서드의 측정 시간을 계산하고 싶어 이에 해당하는 AOP를 메서드에 적용할 경우, 프록시 객체에서 해당 메서드의 시작 시간 측정 로직을 수행한 후에 Target 객체의 기존 로직을 수행 후 최종적으로 계산된 시간을 다시 프록시 객체에서 계산하여 로직을 수행한다.

예시를 위해 아래와 같은 Service 레이어의 구현이 있다고 가정해보자.

	@Slf4j
    private static class CallService {
        public void external() {
            log.info("external call");
            printTxInfo();
            internal();
        }

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

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

Internal 이라는 메서드에 @Transactional이 적용되어 있어서 앞서 말한 proxy 객체가 감싸져있는 형태의 CallService 빈이 컨테이너에 등록되어 있을 것이다. 반면 external 이라는 메서드는 AOP가 적용되어 있지 않기에 만약 호출을 한다면 proxy가 적용되지 않은 CallService의 원래 빈에 존재하는 메서드를 호출할 것이다. 그렇다면 external 메서드 내부에서 호출되는 internal이라는 메서드는 AOP가 적용이 될까?

@Slf4j
@SpringBootTest
public class InternalCallV1Test {

    @Autowired
    private CallService callService;

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

    @TestConfiguration
    static class TxApplyBasicConfig{
        @Bean
        CallService CallService() {
            return new CallService();
        }
    }   
}

그 결과는 아래 테스트 코드를 수행하여 알아볼 수 있다. Internal 이 불려지고 난 다음 Tx가 적용되지 않음을 확인할 수 있다. 이와 같이 빈 내부에서 AOP가 걸려있는 메서드를 호출할 경우 AOP의 기능이 적용되지 않는 문제를 프록시 내부 호출 문제라고 한다.

그러면 이러한 문제는 왜 발생하는 걸까? 스프링 빈이라는 객체와 프록시 객체가 메모리에 어떻게 저장 되는지 이해하면 알 수 있다. 앞선 그림을 살짝 바꿔보면 아래와 같이 변경할 수 있다.

그림 내의 Proxy 객체와 Target Bean는 서로 다른 객체이기에 힙 영역내에 서로 다른 메모리 주소에 저장된다. external을 호출하게 될 경우 Target Bean이 가진 레퍼런스를 통해 메서드를 호출한다. 이후 내부 로직에서 this. internal()을 호출하기에 프록시 객체가 가진 주소가 아닌 Target Bean에 등록된 internal 메서드를 호출하게 된다. 그렇게 되면 Proxy 객체에 존재하는 AOP 기능이 적용된 로직이 수행되지 않는 것이다.

그래서 이는 어떻게 해결 할 수 있을까? 주로 권장되는 방식은 메서드의 분리이다. 코드를 보면 쉽게 이해할 수 있다. 기존에 존재하던 CallService를 아래와 같이 변경할 수 있다.

	@RequiredArgsConstructor
    @Slf4j
    static class CallService {
        @Autowired
        private final InternalService internalService;

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

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

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

기존에 internal을 호출하던 external을 가지는 클래스를 별도의 빈으로 등록시켜주고 해당 빈에서 InternalService를 주입 받으면 Target 빈이 아닌 Proxy 빈의 레퍼런스가 들어오게 되어 정상적으로 AOP가 적용되는 것을 확인할 수 있다.

AOP를 빈 초기화 시점에 적용시키려면?

다음으로 살펴볼 주제는 AOP에 적용된 기능을 컨테이너 초기화 시점에 호출하려고 하면 어떻게 해야 하는지 살펴본다. 우선 Hello 라는 클래스를 빈으로 등록시키고 @PostConstruct를 통해서 초기화 시점에 AOP가 적용된 메서드를 호출을 시도해보자.

	static class Hello {
        @PostConstruct
        @Transactional
        public void initV1() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx Active = {}", txActive);
        }
    }

아래와 같이 로그를 살펴보면 false 값이 나오고 AOP가 적용되지 않은채로 initV1이라는 메서드가 호출된것을 확인할 수 있다.

결국 @PostConstruct에 의해 호출되는 시점이 프록시 객체가 완성되는 시점보다 이르기 때문에 위와 같은 결과를 보여준다. 그러면 우리가 원하는 동작은 어떻게 달성할 수 있을까? 간단하게 @EventListener라는 어노테이션을 사용해주면 된다. 그 중 우리가 사용할 컨테이너가 완전히 준비된 상태를 확인할 수 있도록 ApplicationReadyEvent.class를 통해 아래와 같이 설정해주면 된다.

		@EventListener(ApplicationReadyEvent.class)
        @Transactional
        public void initV2() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx Active2 = {}", txActive);
        }
`

간단하게 @EventListener 어노테이션을 통해서 스프링 컨테이너에 프록시 객체가 등록된 시점 직후에 원하는 메서드 로직을 호출하도록 할 수 있다.