프록시 팩토리와 Advisor

땡글이·2023년 1월 24일
0

스프링 AOP

목록 보기
2/5

동적 프록시를 구현하는 기술에는 JDK 동적 프록시, CGLIB가 있다. 이 기술들을 추상화시켜 일관성 있게 접근하고 더욱 편리하게 사용할 수 있는 기술이 "프록시 팩토리(Proxy Factory)" 이다.

JDK 동적 프록시와 CGLIB에 대해서는 해당 포스팅을 확인해주세요.

스프링에서는 JDK 동적 프록시에서 제공하는 InvocationHandler 와 CGLIB에서 제공하는 MethodInterceptor 를 각각 중복으로 만들지 않게 하기 위해, Advice 라는 개념을 도입했다. 개발자는 InvocationHandlerMethodInterceptor를 만들지 않고, Advice 만을 만들면 된다.


프록시 팩토리와 Advice 사용

    @Test
    @DisplayName("인터페이스가 있으면, JDK 동적 프록시 사용")
    void interfaceProxy() {
        ServiceInterface target = new ServiceImpl();

        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvice(new TimeAdvice());
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        proxy.save();

        // ProxyFactory 로 만들어진 proxy 인지 확인 가능
        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
    }
targetClass=class hello.proxy.common.service.ServiceImpl
proxyClass=class com.sun.proxy.$Proxy13
TimeProxy 실행
save 호출
TimeProxy 종료 resultTime=0
    @Test
    @DisplayName("구체클래스만 있으면, CGLIB 사용")
    void concreteProxy() {
        ConcreteService target = new ConcreteService();

        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvice(new TimeAdvice());
        ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        proxy.call();

        // ProxyFactory 로 만들어진 proxy 인지 확인 가능
        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
        assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
    }
targetClass=class hello.proxy.common.service.ConcreteService
proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerBySpringCGLIB$$569d3987
TimeProxy 실행
ConcreteService 호출
TimeProxy 종료 resultTime=27

ProxyFactory에서 실제 객체를 참조하도록 하고, proxyFactory에 Advice를 달아주어 프록시 실행 로직을 달아주었다.
그리고 각각의 테스트코드 별 실행결과를 보면 알 수 있듯이, 인터페이스가 있으면 JDK 동적 프록시를 사용해 프록시를 생성하고, 인터페이스가 없다면 CGLIB 를 사용해 프록시를 생성한다.

  • new ProxyFactory(target) : 인자로 들어온 인스턴스 정보를 기반으로 프록시를 만들어낸다. 인터페이스가 있으면 JDK 동적 프록시, 없으면 CGLIB로 프록시를 생성한다.
  • proxyFactory.addAdvice(new TimeAdvice()) : 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. 프록시가 제공하는 부가 기능 로직을 어드바이스(Advice)라고 한다.
  • proxyFactory.getProxy() : 프록시 팩토리를 통해 만들어진 프록시를 반환받는다.

proxyFactory.setProxyTargetClass(true); 를 통해, 항상 CGLIB 기반으로 프록시가 생성되도록 설정을 바꿀 수도 있다.

Advice 만들기

프록시 팩토리에서 사용되는 Advice를 만드려면, MethodInterceptor를 구현해 내부 로직을 작성하면 된다.

CGLIB 에서의 MethodInterceptor의 패키지는 org.springframework.cglib.proxy 이고, 프록시 팩토리에서 Advice 를 구현할 때 사용되는 MethodInterceptor 의 패키지는 org.aopalliance.intercept 이다. 엄연히 다른 것이니 헷갈리지 말자!

@Slf4j
public class TimeAdvice implements MethodInterceptor {
    
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();

//        Object result = method.invoke(target, args);
        Object result = invocation.proceed();   // target 을 찾아서 알아서 실행해줌.

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;

        log.info("TimeProxy 종료 resultTime={}", resultTime);

        return result;
    }
}

앞의 코드에서 ProxyFactoryAdvice로 사용된 TimeAdvice 클래스를 알아보자. 그런데 CGLIB나, JDK 동적 프록시와는 달리 Advice에서는 target 즉, 실제 객체를 참조하고 있지 않다. 그래도 정상 작동할 수 있는 이유는 프록시 팩토리에서 실제 객체를 참조하고 있기 때문이다.

  • invocation.proceed() : target 객체를 호출하고 그 결과를 받는다.

어드바이저 (Advisor)

포인트컷 (Pointcut)

포인트컷은 어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다. 주로 클래스와 메서드 이름으로 필터링한다. 이름 그대로 어떤 포인트(Point)에 기능을 적용할지 안할지 잘라서(cut) 구분하는 것이다.

어드바이스 (Advice)

앞에서 프록시 팩토리에서 사용된 TimeAdvice 를 보면 알 수 있듯이, 프록시가 호출하는 부가기능을 의미한다. 단순하게 프록시 로직이라고 생각하면 된다.


어드바이저는 하나의 포인트컷과 하나의 어드바이스를 묶어서 일컫는 단어다. 즉, "어드바이스 = 포인트컷1 + 어드바이스1" 이다.

Advisor=Pointcut+AdviceAdvisor = Pointcut + Advice

이렇게 어드바이저를 두 개로 분리한 것은 역할과 책임을 명확하게 분리한 것이다.

  • 포인트컷은 대상 여불르 확인하는 필터 역할만 담당
  • 어드바이스는 깔끔하게 부가 기능 로직만 담당
  • 둘을 합친 것이 어드바이저

아래 그림으로 보면, 어드바이저의 구조와 프록시 팩토리에서 어떻게 어드바이저를 이용하는지 알 수 있다.

포인트컷에서 Advice 적용 여부를 확인했을 때, 적용 대상이 아니라면 당연히 프록시 객체에 부가기능(Advice)을 적용하지 않는다.

프록시 팩토리 + 어드바이저(Advisor)

    @Test
    void advisorTest1() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);

        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
        proxyFactory.addAdvisor(advisor);

        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
    }

new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice()) 가 의미하는 바는 Pointcut에서는 모든 것들이 적용대상이 되게끔 Pointcut.True 를 인자로 넣어줬고, Advice로는 이전에 만들어둔 TimeAdvice를 인자로 넣어줬다.

스프링이 제공하는 포인트컷 사용

스프링에서는 다양한 포인트컷을 제공한다.

  • NamedMatchPointcut : 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils 사용함.
  • JdkRegexMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.
  • TruePointcut : 항상 참을 반환한다.
  • AnnotationMatchingPointcut : 애노테이션으로 매칭한다.
  • AspectJExpressionPointcut : aspectJ 표현식으로 매칭한다.
    • 실무에서는 AspectJExpressionPointcut 을 가장 많이 사용한다.

AspectJ 표현식에 대해선 따로 글을 남기겠다.

NamedMatchPointcut을 사용하는 코드를 보도록 하자.

    @Test
    @DisplayName("스프링이 제공하는 포인트컷")
    void advisorTest3() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);

        // 스프링이 제공하는 포인트컷
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedName("save");

        DefaultPointcutAdvisor advisor
                = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
        proxyFactory.addAdvisor(advisor);

        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
        proxy.find();
    }

pointcut.setMappedName("save") : save 라는 이름의 메서드만 프록시 로직이 적용되도록 포인트컷을 구현했다. 즉, 메서드 이름이 save 일 경우에만 true를 반환한다.

여러 어드바이저(Advisor) 적용

하나의 target에 여러 어드바이저를 적용하려면, 방법은 총 2가지이다. 하나는 "프록시를 여러 개 생성하는 것" 이고, 다른 하나는 "하나의 프록시에 여러 어드바이저를 적용하는 것" 이다.

프록시를 여러 개 생성

    @Test
    @DisplayName("여러 프록시")
    void multiAdvisorTest1() {
        // client > proxy2(advisor2) > proxy1(advisor1) > target


        // 프록시 1 생성
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory1 = new ProxyFactory(target);
        DefaultPointcutAdvisor advisor1
                = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
        proxyFactory1.addAdvisor(advisor1);
        ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();


        // 프록시 2 생성
        ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
        DefaultPointcutAdvisor advisor2
                = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
        proxyFactory2.addAdvisor(advisor2);
        ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();


        // 실행
        proxy2.save();
    }

    @Slf4j
    static class Advice1 implements MethodInterceptor {

        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info("advice1 호출");
            return invocation.proceed();
        }
    }

    @Slf4j
    static class Advice2 implements MethodInterceptor {

        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info("advice2 호출");
            return invocation.proceed();
        }
    }

여러 어드바이저를 적용하기 위해, 여러 어드바이스 클래스를 만들었다. 편의상 포인트컷은 항상 True를 반환하도록 설정해주었다. 이 방법은 런타임 시에 다음과 같이 동작한다.

여러 프록시를 생성하는 방식이 잘못된 방식은 아니지만, 문제가 하나 있다.

  • 만약 적용해야 하는 어드바이저가 10개라면, 10개의 프록시를 생성해줘야 한다는 점이다.

하나의 프록시, 여러 개의 어드바이저

    @Test
    @DisplayName("하나의 프록시, 여러 어드바이저")
    void multiAdvisorTest2() {
        // client > proxy > advisor2 > advisor1 > target
        DefaultPointcutAdvisor advisor1
                = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
        DefaultPointcutAdvisor advisor2
                = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());

        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory1 = new ProxyFactory(target);

        // 먼저 실행될 어드바이스부터 넣는다.
        proxyFactory1.addAdvisor(advisor2);
        proxyFactory1.addAdvisor(advisor1);
        ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();

        // 실행
        proxy1.save();
    }
advice2 호출
advice1 호출
save 호출

스프링에서는 "여러 프록시 생성"에서의 문제를 해결하기 위해 하나의 프록시에 여러 어드바이저를 적용할 수 있게 만들어두었다. 즉, 프록시 팩토리와 어드바이저 간에 1:N 관계가 가능하다.

위의 코드를 보면, proxyFactory.addAdvisor()를 2번 호출하는 것을 볼 수 있다. 어드바이저의 순서는 먼저 추가한 어드바이저부터 실행되도록 프록시 팩토리 내부에 구현되어 있다. 즉, 등록하는 순서대로 어드바이저가 호출된다. 이 방식은 런타임 시, 다음과 같은 의존관계를 가진다.

이전 포스팅과 이번 포스팅에서는 스프링 AOP 에 대해 보다 자세하게 공부하기 위함이었다. 많은 개발자들이 스프링 AOP를 사용할 때, AOP 적용 수 만큼 프록시가 생성된다고 착각한다. 하지만, 스프링 AOP에서는 최적화를 진행해서 위의 방식처럼 프록시는 하나만 들고, 하나의 프록시에 여러 어드바이저를 적용한다.
정리하면, 하나의 target에 여러 AOP가 적용되어도, 스프링의 AOP는 target 마다 하나의 프록시만 생성한다.

프록시 팩토리 방식의 문제점

	...
    
    @Bean
    public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
        OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));

        ProxyFactory factory = new ProxyFactory(orderController);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderControllerV2 proxy = (OrderControllerV2) factory.getProxy();
        log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
        return proxy;
    }
    
    ...

위처럼 실제 빈을 등록해주기 전, 프록시 객체를 등록해주기 위해 동적 프록시 생성 코드를 작성해 빈으로 등록해줘야 한다. 프록시 클래스를 여러 개 만들지 않고, 하나의 파일에 동적 프록시 생성 코드를 관리할 수 있게됐지만 여전히 100개의 클래스에 프록시를 적용하려면 동적 프록시 생성 코드를 100개 작성해야 한다.
또한, 컴포넌트 스캔 방식으로 스프링 컨테이너에 자동으로 빈 등록이 되는 클래스를 해당 방식으로는 바꿀 수 없다. 그래서 실무에서는 빈 후처리기 방식이나 @Aspect 어노테이션을 이용해 동적으로 프록시를 생성한다.

Reference

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard
https://maeryo88.tistory.com/200

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글