스프링 AOP : 프록시 내부 참조 → AOP 적용 안되는 문제

    이 포스팅은 인프런 영한님의 강의를 복습하며 작성한 글입니다. 


    AOP 프록시 내부 참조 문제

    public class AspectV1 {
        @Around("execution(* hello.aop.order..*.*(..))")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            // 프록시 객체 로직
            log.info("[LOG] {}", joinPoint.getSignature().toShortString());
            
            // Target 객체 호출
            return joinPoint.proceed();
        }
    }

    스프링은 프록시 방식의 AOP를 사용한다. 따라서 AOP를 적용하려면 항상 프록시를 통해 Target 객체를 호출해야한다. 이것은 앞에서 만들었던 어드바이스 코드를 떠올려보면 당연하다. 위의 코드를 보면 프록시 객체에서 필요한 로직을 처리하고 joinPoint.proceed()로 타겟 객체의 로직을 처리해주는 것이기 때문이다.

     

    AOP를 적용하면 스프링은 Target 대신에 프록시로 바꿔치기한 후 스프링 빈을 등록한다. 따라서 스프링은 의존관계를 주입하면 항상 프록시 객체를 주입한다. 프록시 객체가 직접 주입되기 때문에 Target 객체를 직접 호출하는 문제는 일반적으로 일어나지 않는다. 

    그렇지만 Target 객체를 호출했을 때, 다시 한번 내부 호출이 일어나게 되면 AOP가 적용되지 않는 문제가 발생한다. 위의 형태가 가장 대표적인 경우라고 볼 수 있다. AOP는 실제로 external() / internal() 메서드에 모두 적용이 되어있다. 그렇지만 이건 프록시 객체를 호출할 때, 동적으로 메서드 정보가 넘어와서 가능한 부분이다.

    동적 프록시는 메서드 정보를 실행 시점에 동적으로 받아, 프록시 객체를 통해 호출하기 때문에 AOP가 적용된다. 

     

     


    AOP 프록시 Target의 내부호출 문제 코드로 확인해보기

    이번 포스팅에서는 프록시 객체의 Target의 내부 호출이 어떨 때 일어나는지를 먼저 알아본다. 이후에는 어떻게 그런 상황을 해결할 수 있는지를 확인하고자한다. 

     

    AOP 프록시 타겟의 내부호출 기본 코드

    어드바이저 코드 작성

    @Slf4j
    @Aspect
    @Component
    public class CallLogAspect {
        @Before("execution(* hello.aop.internalcall..*(..))")
        public void doLog(JoinPoint joinPoint) {
            log.info("[aop = {}]", joinPoint.getSignature());
        }
    }
    • 어드바이저는 internalcall 패키지 + 서브 패키지 모든 메서드에 적용될 수 있도록 작성했다.
    • 단순히 AOP가 실행되었다는 로그를 찍음. 

     

    CallServiceV0 코드 작성

    @Slf4j
    @Component
    public class CallServiceV0 {
    
        /**
         * 내부에서 Internal 호출한다
         * 내부 호출은 프록시 객체 AOP 적용 안됨.
         */
    
        public void external() {
            log.info("call external");
            internal(); // 내부 메서드 호출. this.internal()
        }
    
        public void internal() {
            log.info("call internal");
        }
    
    
    }
    • CallServiceV0는 external() , internal() 메서드를 가진다.
    • external() 메서드는 내부적으로 internal() 메서드를 호출한다. 

     

    CallServiceV0 테스트 코드 작성

    @SpringBootTest
    public class CallServiceV0Test {
    
        @Autowired
        CallServiceV0 callService;
    
        @Test
        void external() {
            callService.external();
        }
    
        @Test
        void internal() {
            callService.internal();
        }
    }

    CallServiceV0 테스트 코드 작성했다. 중점적으로 봐야할 부분은 exeternal() 테스트코드다

     

    CallServiceV0 테스트 코드 → exeternal() 실행 결과 확인

    external() 실행 결과를 확인하면, external() 메서드에만 AOP가 걸린 것을 확인할 수 있다. 사실 포인트컷만 보면 internal()도 어드바이스의 적용 대상이기 때문에 internal()을 내부호출할 때도 AOP가 걸려야 할 것이라고 생각할 수 있다. 그렇지만 위 결과는 당연한 결과다. internal()은 프록시 객체를 통해서 호출한 것이 아니기 때문이다. 

     내부동작은 위와 같이 이루어졌다. 위에서 볼 수 있듯이 AOP Proxy는 external()과 internal() 모두에 걸려져 있다. 그런데 external() 함수는 프록시의 internal()을 호출한 것이 아니라, target으로 넘어간 이후 this.internal()로 내부 참조를 했다. 프록시를 거치지 않았다. 

    public class AspectV1 {
        @Around("execution(* hello.aop.order..*.*(..))")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
            // 프록시 객체 로직
            log.info("[LOG] {}", joinPoint.getSignature().toShortString());
            
            // Target 객체 호출
            return joinPoint.proceed();
        }
    }

    어드바이저 코드를 보면 이것은 더 명확하다. 어드바이저는 프록시 로직을 한 이후 joinPoint.proceed()를 한다. 위 경우에 대입해보면 joinPoint.proceed()는 target.external()을 한 상황인 것이다. 그리고 target.external()에서 this.internal()이 되었기 때문에 internal()에 대한 AOP는 적용이 되지 않은 것이다. 

     

    CallServiceV1 → 수정자로 자기자신 의존관계 주입

    위에서 Internal() 메서드에도 AOP가 걸리기를 기대했다. 실제로 internal() 메서드만 호출하면 AOP는 걸리지만, external() 메서드에서 내부적으로 호출되는 internal() 메서드에는 AOP가 걸리지 않는 것이 확인되었다. 이것은 프록시 객체를 통해서 internal()을 호출한 것이 아니기 때문이다.

    프록시의 Target 객체가 내부적으로 호출한 internal()에도 AOP가 적용되는 것을 원한다면, 내부적으로 호출되는 internal()을 this.internal()이 아닌 proxy.internal()이 될 수 있도록 해주어야 한다. CallServiceV1에서는 프록시 객체를 생성자 주입하는 방식으로 해결했다. 

    spring.main.allow-circular-references=true

    단, 생성자로 자기 자신을 주입하게 되면 SpringBoot 2.6 이후부터는 순환 문제가 발생해서 예외가 터진다. 이걸 허용하기 위해서는 properties에 위 코드를 추가해주면 된다. 

     

    CallServiceV1 코드

    @Slf4j
    @Component
    public class CallServiceV1 {
    
    
        private CallServiceV1 callServiceV1;
    
        /**
         * 자기 자신 주입
         * 순환 문제 발생 → 스프링 2.6부터 지원 X
         */
    
        @Autowired
        public void setCallServiceV1(CallServiceV1 callServiceV1) {
            this.callServiceV1 = callServiceV1;
        }
    
        public void external() {
            log.info("call external");
            callServiceV1.internal(); // 내부 메서드 호출. this.internal()
        }
    
        public void internal() {
            log.info("call internal");
        }
    
    
    }
    • 생성자 주입을 통해서는 프록시 객체를 주입할 수 없다.
      • 생성자 주입은 생성을 함과 동시에 스프링 컨테이너에서 스프링 빈을 받아와 주입하는 방식이다.
      • 프록시 객체는 스프링을 생성해서 스프링 빈에 등록하는 과정에 빈 후처리기를 통해서 만들어지기 때문이다.
    • 수정자 주입을 통해 스프링 빈이 등록된 이후, 수정자로 자기 자신(프록시 객체)를 주입 받았다. 

     

    테스트 코드 실행 결과(테스트 코드는 V0와 동일)

    실행 결과 AOP가 external(), internal()에 모두 적용되는 것이 확인되었다. 

    실제 런타임시 의존관계는 위처럼 이해할 수 있다. 프록시 객체에 external()을 요청해서 target을 호출했다. target은 external()을 호출하면서 내부적으로 internal()을 호출한다. 그런데 이 때, 프록시객체.internal()을 해주기 때문에 다시 한번 메서드 정보를 프록시 객체에 동적으로 넘겨주면서 internal()을 호출한다. 프록시 객체는 internal() 정보를 받으며 로그를 찍고, target.internal()을 호출해준다. 

     

    CallServiceV2  → 스프링 컨테이너, Provider 활용

    앞에서처럼 수정자 주입을 사용하는 방법이 있다. 그런데 수정자 주입은 앞으로 사용이 어려운 상황이 되고 있으니, 지연로딩 방식으로 이 문제를 해결할 수 있다. 지연로딩은 프록시 객체를 제공할 수 있는 객체를 이용해서 필요한 시점에 프록시 객체를 다시 제공받아 호출하는 것이다.

    • 스프링 컨테이너 : ApplicationContext
    • Provider : ObjectProvider

    이 때 사용할 수 있는 객체는 위에서 확인할 수 있다. 위 객체를 내부적으로 DI 받아, 필요 시점에 이 객체로부터 프록시 빈을 받아와서 사용하면 된다. 

     

    CallServiceV2 코드 → 스프링 컨테이너로 프록시 객체 받아오기

    @Slf4j
    @Component
    public class CallServiceV2 {
    
        private final ApplicationContext context;
        public CallServiceV2(ApplicationContext context) {
            this.context = context;
        }
    
        public void external() {
            log.info("call external");
    
    		//스프링 컨테이너 → 프록시 객체 호출
            CallServiceV2 callServiceV2 = (CallServiceV2) context.getBean("callServiceV2");
            callServiceV2.internal();
    
        }
    
        public void internal() {
            log.info("call internal");
        }
    
    
    }

     

    CallServiceV2 코드 → ObjectProvider로 프록시 객체 불러오기

    @Slf4j
    @Component
    public class CallServiceV2 {
    
        private final ObjectProvider<CallServiceV2> provider;
    
        public CallServiceV2(ObjectProvider<CallServiceV2> provider) {
            this.provider = provider;
        }
    
        public void external() {
            log.info("call external");
    
    		// ObjectProvider → 프록시 객체 호출
            CallServiceV2 callServiceV2 = provider.getObject();
            callServiceV2.internal();
    
        }
    
        public void internal() {
            log.info("call internal");
        }
    
    
    }

     

    테스트 코드 실행 결과(테스트 코드는 V0와 동일)

    실행 결과 내부호출까지 정상적으로 AOP가 적용되었다. 그림으로 표현하기 굉장히 애매한데, 프록시의 Target 객체가 내부적으로 ObjectProvider / Spring 컨테이너를 통해 다시 프록시 객체를 받아서 내부적으로 proxy.internal()을 해준 것으로 이해를 하면 편하다.

     

    CallServiceV3  → 코드의 분리 → Best Practice

    수정자 주입을 통해서 자기 자신을 호출하는 방법이나 실행 시점에 스프링 컨테이너, Object Provider로 프록시 객체를 주입받아 호출하는 것은 조금 어색한 모습이 보인다. 가장 좋은 대안은 내부 호출을 하지 않도록 구조를 변경하는 것이라고 한다. 

     

    InternalService 클래스 생성

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

     

    CallServiceV3 클래스 → InternalService 클래스 주입

    @Slf4j
    @Component
    public class CallServiceV3 {
    
        /**
         * 구조 분리
         * Internal 함수를 다른 클래스로 분리하고, 스프링 빈 등록하여 해결
         *
         */
    
        private final InternalService internalService;
    
        public CallServiceV3(InternalService internalService) {
            this.internalService = internalService;
        }
    
        public void external() {
            log.info("call external");
            internalService.internal();
        }
    
    
    }
    • CallServiceV3 클래스는 기존의 Internal() 메서드를 따로 InternalService로 분리했다. 
    • InternalService를 DI 받아, external()에서 internalService.internal()로 처리해주었다. 

     


    테스트 코드 실행(V0 테스트 코드와 동일)

    동일하게 internal() 메서드를 내부 호출했을 때도, AOP가 적용된 것이 확인된다. 

    엄밀히 말하면 내부 호출 자체가 사라진 것이다. 내부 호출은 객체와 객체로 분리되었다. 그리고 Target 객체는 프록시 객체를 내부적으로 참조하고, 이것을 호출하는 형태로 구조가 분리되었다.

    이 외에도 내부 호출을 없애고, 테스트 코드에서 external() , internal()을 하도록 하는 방법으로 메서드를 분리할 수도 있다. 

     


    정리

    내부호출에 AOP가 적용되지 않는 부분을 한번 알아보고 해결하는 방법을 정리했다. 결국 AOP가 내부적으로만 호출되는 Private한 메서드에 적용이 되지 않아서 끙끙 앓았던 문제다. 그런데 실제로 AOP는 @Transaction이나 Log처럼 public 단위의 클래스에 적용된다. 

    이렇게 내부적으로 private한 메서드에 적용이 거의 안되어, 클래스 구조를 분리할만큼의 일은 거의 없다고 한다. 그렇지만 이런 경우가 있을 수 있으니 이해를 하고 있어야 한다고 한다.

    댓글

    Designed by JB FACTORY