스프링 AOP : ProxyFactory를 이용한 동적 프록시 처리

    이 글은 인프런의 김영한님의 강의를 듣고 복습하며 정리한 글입니다.

     


    JDK 동적 프록시, CGLIB 동적 프록시

    이전 글(https://ojt90902.tistory.com/700)에서 동적 프록시를 생성하기 위해 Java의 리플렉션 기술을 활용해 동적으로 메서드를 뽑아냈다. 그리고 이 기술을 바탕으로 구현된 JDK 동적 프록시 기술로 프록시 객체를 만들어 의존관계 주입을 통해 횡단 관심사를 처리했다. 그런데 한 가지 문제점이 있었다.

     

    JDK 동적 프록시는 반드시 인터페이스가 있는 환경에서만 사용할 수 있었다. 왜냐하면 인터페이스의 클래스 로더, 클래스 메타 정보 등을 넣어주어 동적으로 메서드를 뽑아오기 때문이다. 이처럼 인터페이스 없이 구체 클래스만 있을 때는 CGLIB라는 라이브러리를 이용해 대상 객체를 상속받은 동적 프록시 객체를 만들 수 있었다. 

     

    실무에서는 인터페이스가 있는 경우도 있고, 구체 클래스만 있는 경우도 있다. 이럴 때는 CGLIB도 있고, JDK 동적 프록시도 있게 된다. 즉, 여러가지 구현해야 할 것들이 많아진다는 것이다. 스프링은 이런 번거로움을 해결해주기 위해 CGLIB, JDK 동적프록시를 추상화 시켜주는 Proxy Factory를 제공해준다. 개발자는 Proxy Factory 덕분에 좀 더 편리하게 개발할 수 있다. 

     


    스프링의 Proxy Factory

     스프링이 제공하는 프록시 팩토리는 JDK 동적 프록시, CGLIB 프록시에 의존한다. 클라이언트는 프록시 팩토리에 프록시를 요청하기 전에 어떤 기술을 사용할지 선택할 수 있다. 기본적으로 인터페이스가 있으면 JDK 동적 프록시, 인터페이스가 없으면 CGLIB 프록시가 만들어진다. 타겟 클래스 값을 True로 설정하면 인터페이스가 있어도 CGLIB로 프록시를 만들어준다.

     

    JDK 동적 프록시는 InvocationHandler로 프록시의 몸뚱아리를 추상화한다. CGLIB는 MethodInterceptor로 프록시의 몸뚱아리를 추상화한다. 프록시 팩토리는 JDK 동적 프록시와 CGLIB 프록시를 모두 가지고 있기 때문에 InvocationHandler와 MethodInterceptor의 추상화가 필요하다. 프록시 팩토리는 'Advice'라는 개념으로 두 개념을 추상화시켜 사용한다.

     

    클라이언트가 프록시 팩토리로 만든 프록시를 호출하게 되면 위의 흐름대로 문맥이 흘러가게 된다. JDK 프록시는 InvocationHandle.invoke()가 호출될 것이다. CGLIB 프록시는 MethodInterceptor.intercept()가 호출될 것이다. 둘 중 하나라도 호출이 된다면, 프록시 팩토리의 Advice.invoke()가 호출되도록 흐름이 짜진다. 따라서 개발자는 Advice.invoke()를 프록시의 몸뚱아리로 생각하고 개발하면 된다. 

     


    스프링 Proxy Factory 사용해보기

    1. Advisor 만들기 : 프록시 몸뚱아리 만들기
    2. 프록시 팩토리에서 프록시 불러오기
    3. 프록시를 스프링 빈으로 등록해주기

    스프링의 프록시 팩토리로 동적으로 프록시를 사용하는 것은 크게 두 가지 단계로 나누어진다. 또한 프록시 팩토리는 앞서 이야기한 것처럼 JDK 동적 프록시, CGLIB 동적 프록시를 모두 추상화했다. 따라서 Advisor를 만들 때 대상이 되는 객체의 인터페이스가 있든, 없든 동일한 방식으로 MethodInterceptor를 구현해주면 된다. 

     

    프록시 팩토리의 어드바이저 

    프록시 팩토리는 어드바이저를 가지고 있다. 어드바이저는 어디에 조언을 할지를 알고 있는 기능이다. 이 어드바이저는 다음과 같이 동작한다.

    성공 로직

    1. 클라이언트가 프록시의 특정 메서드를 호출한다.
    2. 포인트컷에 특정 클래스의 특정 메서드에 어드바이스를 적용해도 될지를 물어본다.
    3. 포인트컷이 True를 반환하면, Advice가 적용이 된다. 
    4. 이후 실제 인스턴스의 실제 메서드가 호출된다. 

    실패 로직

    1. 클라이언트가 프록시의 특정 메서드를 호출한다.
    2. 포인트컷에 특정 클래스의 특정 메서드에 어드바이스를 적용해도 될지를 물어본다.
    3. 포인트컷이 False를 반환하면, Advice는 적용되지 않는다.
    4. 이후 실제 인스턴스의 실제 메서드가 호출된다. 

    이렇게 될 수 있는 이유는 프록시가 어드바이저를 가지고 있기 때문이다. 부가 기능이 필요하면 어드바이저를 실행하게 되는 것이고, 부가 기능이 필요없다면 어드바이저를 실행하지 않기 때문이다. 좀 더 덧붙여 이야기하면 어드바이저와 타겟 인스턴스가 분리되어 작용하기 때문이다.

     

    프록시 팩토리에서 프록시 불러오기 + 스프링 빈으로 등록하기 

    @Bean
    public OrderRepositoryV1 orderRepositoryV1() {
    
        OrderRepositoryV1 target = new OrderRepositoryV1Impl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvisor(getAdvisor());
        OrderRepositoryV1 proxy = (OrderRepositoryV1) proxyFactory.getProxy();
        return proxy;
    }
    1. 프록시 팩토리를 만들 때, 실제 타겟을 넘겨준다
      • 이 때, 프록시 팩토리는 실제 타겟의 인스턴스 정보를 알고 있다. 따라서, 프록시 몸뚱아리는 이 정보를 몰라도 된다.
    2. 프록시 팩토리에 어드바이저를 추가해준다. 어드바이저는 포인트 컷과 어드바이스를 포함하고 있는 개념이다. 
      • 포인트컷은 어디에 어드바이스가 실행될지를 의미한다.
      • 어드바이스는 실행될 부가기능을 의미한다.
    3. 실제 타겟을 넘겨주고, 프록시 몸뚱아리를 넘겨주었기 때문에 프록시 팩토리는 필요한 모든 정보를 안다. 프록시 팩토리에게 프록시를 요청한다. 

    위의 코드는 반환된 객체가 orderRepositoryV1이라는 이름으로 스프링 빈에 등록이 된다. 그런데 반환된 객체는 프록시 팩토리에서 만들어진 프록시 객체다. 그리고 이 프록시 객체는 내부에 타겟 값으로 OrderRepositoryV1 구현 인스턴스를 가지고 있다. 

     

    프록시 팩토리를 위한 Advisor 만들기

    private Advisor getAdvisor() {
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "save*", "orderItem*");
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
    • Advisor는 주로 DefaultPointcutAdvisor를 사용한다.
    • 이 어드바이저는 Advice 1개, Pointcut 1개를 가지고 있는 어드바이저다.
    • Pointcut은 NameMatchMethodPointcut을 사용한다. 그렇지만 주로 사용하게 될 Pointcut은 AspectJExpressionPointcut이다.

     

    Advice 만들기(프록시 몸뚱아리 만들기) 

    public class LogTraceAdvice implements MethodInterceptor {
    
        private final LogTrace logTrace;
    
        public LogTraceAdvice(LogTrace logTrace) {
            this.logTrace = logTrace;
        }
    
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
    
            TraceStatus status = null;
            try {
    
                Method method = invocation.getMethod();
                String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
                status = logTrace.begin(message);
    
                // 핵심 기능
                Object result = invocation.proceed();
    
                logTrace.end(status);
                return result;
    
            } catch (Exception e) {
                logTrace.exception(status, e);
                throw e;
            }
    
        }
    }
    • Advice는 MethodInterceptor(aop.alience) 인터페이스를 구현한다.
    • 로그를 찍는 어드바이스를 구현할 것이기 때문에 LogTrace를 추가한다.
    • 프록시 팩토리에서 만들어진 프록시를 호출하게 되면, 이 클래스의 invoke가 실행된다. 이 때, 동적으로 메서드 정보가 넘어오게 된다
    • invocation.proceed()를 하면 실제 타겟에 동적으로 넘어온 메서드가 실행된다. 

     


    정리 및 참고 사항 

    프록시 팩토리의 기술 선택 방법

    • 대상 인터페이스가 있음 : JDK 동적 프록시, 인터페이스 기반 프록시. sun.com.
    • 대상 인터페이스가 없음 : CGLIB, 구체 클래스 기반 프록시. EnhancerByCGLIB
    • proxyTargetClass=true : CGLIB, 구체 클래스 기반 프록시. 인터페이스 여부와 상관없음. 

    프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고 매우 편리하게 동적 프록시를 생성할 수 있었다. 또한, 부가 기능 로직도 Advice로 추상화 되어있어 Advice만 개발자가 개발을 하면 되었다. 왜냐하면 InvocationHandler는 Advice를 호출하고, MethodInterceptor도 Advice를 호출하도록 개발되었기 때문이다. 

     

    스프링 부트는 AOP를 적용할 때 기본적으로 proxtTagertClass=true로 설정해 구체 기반 클래스를 만든다. 

     

    어드바이저, 포인트컷, 어드바이스

    • 포인트컷(어디에): 어디에 부가 기능을 적용할지를 판단하는 필터링 로직이다. True일 때 적용되는 것으로 이해하자
    • 어드바이스(조언): 프록시가 호출하는 부가기능이다.
    • 어드바이저 : 어드바이스(어디에)와 포인트컷(조언)을 가지고 있다. 즉, 어드바이저는 어디에 조언을 해야할지를 알고 있다. 

     

    여러 어드바이스를 적용하고 싶을 때

    여러 개의 프록시 의존관계 설정(체이닝) → 어드바이저 갯수만큼 프록시 생성 필요

    @Test
    void multipleProxy1() {
    
        ServiceInterface target = new ServiceImpl();
        // 프록시1 생성
        ProxyFactory proxyFactory1 = new ProxyFactory(target);
        proxyFactory1.addAdvice(new advice1());
        ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();
    
        // 프록시2 생성
        ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
        proxyFactory2.addAdvice(new advice2());
        ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();
    
        proxy2.save();
    }
    • 다음과 같이 프록시 사이의 의존관계를 추가해서 어드바이저를 여러 개 추가할 수 있다.
    • 실제 타겟은 프록시 1이 가지고, 프록시1을 프록시 2가 가진다.
    • 프록시2를 실행하면 프록시2 → 프록시1 → 실제 타겟 순으로 실행이 된다.

    런타임의 의존관계는 위와 같다. 즉, 여러 어드바이저를 적용하기 위해 어드 바이저의 갯수만큼 프록시가 만들어져야 한다. 

     

    프록시 팩토리를 이용한 어드바이저 추가 → 어드바이저가 여러개라도, 프록시는 1개

    @Test
    void multipleProxy2() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
    
        DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new advice1());
        DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new advice2());
    
        proxyFactory.addAdvisors(advisor1, advisor2);
    
        ServiceInterface proxy = (ServiceInterface)proxyFactory.getProxy();
        proxy.save();
    
    }
    • 프록시 팩토리에 타겟을 설정해준다.
    • 프록시 팩토리에 어드바이저를 addAdvisors() 메서드를 이용해서 여러개 추가해준다.
    • 프록시 팩토리는 어드바이저를 여러개 가지고 각각 부가 기능을 적용해준다. 

    어드바이저를 적용하는 시점에서는 다음과 같이 된다.

    여러 어드바이저를 추가한, 프록시 팩토리는 위와 같은 프록시를 반환해준다. 이전에는 프록시를 여러개 만들어서 추가한 것과 달리 하나의 프록시가 만들어지고, 하나의 프록시가 여러 어드바이저를 가진다. 

     

    여러 어드바이저 정리 

    하나의 타겟에 여러 AOP(어드바이저)가 동시에 적용된다고 하더라도, 스프링의 AOP는 타겟마다 하나의 프록시만 생성한다.

     


    최종 정리

    프록시 팩토리를 이용해 JDK 프록시와 CGLIB 프록시를 추상화해서 기술 구분없이 MethodInterceptor 구현을 통해 어드바이스를 만들었다. 그리고 이 어드바이스와 포인트컷을 포함한 어드바이저를 이용해서 손쉽게 동적 프록시를 생성해 횡단 관심사를 등록할 수 있었다. 

     

    그런데 두 가지 문제가 있다. 

    1. 의존관계를 설정하는 것이 너무 복잡하다.
    2.  ComponentScan 대상은 등록할 방법이 없다.

    프록시 팩토리에서 생성된 프록시를 스프링 빈으로 바꿔치기 하면서 등록을 해주었다. 이 과정에서 1번, 의존관계를 설정하는 것이 너무 복잡했다. 실제 객체를 만들고 그 객체를 프록시 팩토리에 넘겨줘서 프록시를 받아서 그것을 스프링 빈에 등록을 해주었다. 즉, 의존관계가 조금이라도 복잡해지면 실수할 가능성이 매우 높아진다. 

     

    또한, 프록시 팩토리를 이용한 프록시 객체의 스프링 빈 등록 방식은 @Configuration에서 스프링 빈을 직접 등록하는 과정에서 스프링 빈을 바꿔치기 해주는 방식이다. 따라서 @ComponentScan의 대상이 되는 스프링 빈들에는 프록시를 적용할 방법이 없다. 다음 글에서는 이것을 해결할 수 있는 Bean PostProcessor에 대해 작성한다.

    댓글

    Designed by JB FACTORY