프록시 팩토리 - 어드바이저와 적용

바그다드·2023년 9월 2일
0

지난 포스팅에서 프록시 팩토리에 대해서 알아보았다. 프록시 팩토리는 JDK 동적 프록시와 CGLIB 상관 없이 동적으로 프록시를 만들어 준다. 이번 포스팅에서는 AOP에서 중요한 개념인 포인트컷, 어드바이스, 어드바이저에 대해서 알아보고 이 개념을 기반으로 프록시 팩토리를 로그 추적기에 적용해보도록 하자.

포인트컷 + 어드바이스 = 어드바이저

스프링에 대한 이론을 공부할 때 스프링 AOP에 대해서 알게 되었는데, 그때는 프록시 같은 개념에 대해서도 알지 못하던 때라 아무리 봐도 이해가 잘 되질 않았다. 하지만 중요한 개념이므로 확실하게 짚고 넘어가자.

  • 포인트컷 Pointcut
    부가 기능을 어디에 적용할지, 또는 부가 기능을 적용할지 안할지 판단하는 필터링 로직이다.
    주고 클래스와 메서드 이름으로 필터링한다.
    즉, 어떤 지점(Point)에 기능을 적용할지 안할지 잘라서(cut) 구분하는 것이다.
  • 어드바이스 Advice
    타겟에 적용할 프록시 로직.
    프록시가 호출하는 부가 기능을 말한다.
  • 어드바이저 Advisor
    하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것을 말한다.
    그냥 1 포인트컷 + 1 어드바이스 = 어드바이저 라고 생각하자.

어떤 부가 기능(어드바이스)을 어디에(포인트컷) 적용할지 알고 있는 것이 어드바이저이다.

  • 이렇게 구분한 이유는 역할과 책임을 명확하게 분리한 것이다.
    • 포인트컷은 필터 역할을 담당
    • 어드바이스는 부가 기능 로직만 담당
    • 1포인트컷 + 1어드바이스 = 어드바이저

코드로 확인해보자.

어드바이저 예제

    @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();
        proxy.find();
    }
  • 지난 포스팅에서 봤던 로직과 유사하다.
  • DefaultPointcutAdvisor(포인트컷, 어드바이스)
    Advisor의 가장 일반적인 구현체로 포인트컷과 어드바이스를 파라미터로 주입받는다.
  • Pointcut.TRUE
    포인트컷은 어디에 어드바이스를 적용할지 안할지 결정하는 일종의 필터라고 했다.
    Pointcut.TRUE는 항상 참을 반환해 타겟의 모든 메서드에 프록시를 적용한다.
  • proxyFactory.addAdvisor(어드바이저)
    프록시 팩토리에 어드바이저를 지정한다.
    • 앞선 포스팅에서는 proxyFactory.addAdvice(new TimeAdvice())로 어드바이스를 직접 적용했는데,
      이것은 편의 메서드로 결국 이 안에서 new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice())와 같은 로직이 수행된다.

  • 테스트코드의 결과를 확인해보면

    타겟의 각 메서드에 어드바이스가 적용된 것을 확인할 수 있다.

포인트컷 직접 만들어보기

위의 예제에서는 모든 메서드에 어드바이스가 적용되도록 설정하였다.
이번에는 포인트컷을 직접 만들어 save메서드에만 어드바이스가 적용되도록 해보자.

포인트컷 생성

  • 테스트 코드로 작성하자.
	static class MyPointcut implements Pointcut {
        @Override
        public ClassFilter getClassFilter() {
            return ClassFilter.TRUE;
        }

        // 포인트 컷을 따로 만들려면 MethodMatcher를 직접 생성해줘야 함
        // 메서드를 비교하는 기능
        @Override
        public MethodMatcher getMethodMatcher() {
            return new MyMethodMatcher();
        }
    }
    
    // MethodMatcher 직접 생성
    static class MyMethodMatcher implements MethodMatcher{

        private String matchName = "save";

        @Override
        public boolean matches(Method method, Class<?> targetClass) {
            boolean result = method.getName().equals(matchName);
            log.info("포인트컷 호출 method={} targetClass={}", method.getName(), targetClass);
            log.info("포인트컷 결과 result={}",result);
            return result;
        }

        @Override
        public boolean isRuntime() {
            return false;
        }

        @Override
        public boolean matches(Method method, Class<?> targetClass, Object... args) {
            return false;
        }
    }

Pointcut

  • 포인트컷을 만들려면 Pointcut 인터페이스를 구현해줘야 한다.
  • 클래스 필터는 항상 true를 반환, 메서드 비교 기능은 따로 MyMethodMatcher를 만들어 사용한다.

MethodMatcher

  • 메서드 매처를 만들려면 MethodMatcher 인터페이스를 구현해줘야 한다.
  • matches(Method method, Class<?> targetClass)
    method, targetClass 정보가 파라미터로 넘어와, 어드바이스를 적용할지 말지 판단한다.
  • method.getName().equals(matchName); // matchName = "save";
    메서드 이름이 save인 경우에 true를 반환한다.
  • MethodMatcher를 보면 matches가 두개가 있는 것을 확인할 수 있는데,
    isRuntime이 true인 경우 아래에 있는 matches메서드가 실행된다.
    isRuntime이 false인 경우 위에 있느 matches가 실행되어 클래스의 정적정보만 사용하기 때문에 스프링에서 내부 캐싱을 통한 성능 향상이 가능한 반면, 아래에 있는 matches의 경우 Object... args처럼 파라미터가 동적으로 넘어온다고 가정하여 캐싱을 하지 않는다고 한다.
    이건 중요한 부분은 아니므로 참고만 하자!!

테스트코드 생성

    @Test
    @DisplayName("직접 만든 포인트컷")
    void advisorTest2() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice());
        proxyFactory.addAdvisor(advisor);
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
        proxy.find();
    }
  • 결과를 확인해보면

    직접 생성한 포인트컷에서 설정한대로 save의 경우 어드바이스가 적용되고, find의 경우 적용되지 않은 것을 확인할 수 있다.

  • save() 호출시
  1. 클라이언트가 save를 호출
  2. 포인트컷으로 어드바이스 적용 여부 확인 -> 포인트컷이 true를 반환
  3. advice 로직 수행
  4. 타겟의 save 호출
  • find() 호출시
  1. 클라이언트가 save를 호출
  2. 포인트컷으로 어드바이스 적용 여부 확인 -> 포인트컷이 false를 반환
  3. 타겟의 find호출

이상으로 직접 포인트컷을 생성해서 적용까지 해봤다.
하지만 항상 그렇듯 스프링에서 이미 대부분의 포인트컷을 제공한다.

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

    @Test
    @DisplayName("스프링이 제공하는 포인트컷")
    void advisorTest3() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("save");
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
        proxyFactory.addAdvisor(advisor);
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
        proxy.find();
    }
  • new NameMatchMethodPointcut()
    메서드의 이름을 이용해 매칭을 하는 포인트컷
  • pointcut.setMappedNames("save")
    호출한 메서드 이름이 save와 일치할 때 true를 반환
  • 테스트 결과를 보면

    save 메서드에만 어드바이스가 적용된 것을 확인할 수 있디.

포인트컷 종류

스프링에서는 많은 수의 포인트컷을 제공하는데, 그 중 일부만 알아보면

  1. NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils를 사용한다.
    • 예) xxx 허용
  2. JdkRegexpMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.
  3. TruePointcut : 항상 참을 반환한다.
  4. AnnotationMatchingPointcut : 애노테이션으로 매칭한다.
  5. AspectJExpressionPointcut : aspectJ 표현식으로 매칭한다.

실무에서는 AspectJExpressionPointcut를 가장 많이 사용한다고 한다.

이것으로 포인트컷에 대해서 알아보았다.
앞에서 하나의 어드바이저는 하나의 포인트컷, 하나의 어드바이스를 가지고 있다고 하였다.
그런데 하나의 target에 여러개의 어드바이저를 적용하려면 어떻게 해야할까?

다중 프록시

여러 어드바이저를 적용하는 방법은 여러개의 프록시를 만드는 방법도 있을 것이다. 그리고 그렇게 한다고 했을 때 객체관의 관계는 아래와 같다.

이 경우 어드바이스의 개수만큼 프록시를 생성해야 한다.

스프링에서는 이 문제에 대해 하나의 프록시에 여러 어드바이저를 적용할 수 있게 해준다.

    @Test
    @DisplayName("하나의 프록시, 여러 어드바이저")
    void multiAdvisorTest2() {
        // client -> proxy2(advisor2) -> proxy1(advisor1) -> target

        DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
        DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());

        // 프록시1 생성
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory1 = new ProxyFactory(target);
        // 어드바이저 추가
        // 어드바이스는 추가된 순서대로 실행
        proxyFactory1.addAdvisor(advisor2);
        proxyFactory1.addAdvisor(advisor1);
        ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy();

        // 실행
        proxy.save();
    }
  • addAdvisor()메서드를 이용해 여러 개의 어드바이스를 적용할 수 있다.
    어드바이저는 등록하는 순서대로 호출된다는 것을 기억해두자.

스프링은 AOP를 적용할 때 최적화를 진행하기 때문에 하나의 타겟에 하나의 프록시만 생성된다. 하나의 타겟에 여러 어드바이스가 적용되어도, 하나의 타겟에는 하나의 프록시만 생성된다는 것을 기억해두자!!!

프록시 팩토리 적용

그럼 이제 로그 추적기에 프록시 팩토리를 직접 적용해보자.

1. 어드바이스 생성

  • MethodInterceptor를 구현하고, invoke메서드를 오버라이딩 해주자.
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;
        }
    }
}
  • 이것으로 프록시 로직 적용을 위한 준비는 끝났다.
    이제 프록시 팩토리를 활용해 프록시를 적용해보자.

2. 의존성 주입

인터페이스가 있을 경우

  • 먼저 인터페이스가 있는 경우이다.
    OrderControllerV1와 같이 V1으로 끝나는 객체 이름은 인터페이스, Impl로 끝나는 객체 이름은 구현체이다.
@Slf4j
@Configuration
public class ProxyFactoryConfigV1 {

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
        ProxyFactory proxyFactory = new ProxyFactory(orderController);
        proxyFactory.addAdvisor(getAdvisor(logTrace));
        OrderControllerV1 proxy = (OrderControllerV1) proxyFactory.getProxy();
        log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
        return proxy;
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
        ProxyFactory proxyFactory = new ProxyFactory(orderService);
        proxyFactory.addAdvisor(getAdvisor(logTrace));
        OrderServiceV1 proxy = (OrderServiceV1) proxyFactory.getProxy();
        log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderService.getClass());
        return proxy;
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();

        ProxyFactory proxyFactory = new ProxyFactory(orderRepository);
        proxyFactory.addAdvisor(getAdvisor(logTrace));
        OrderRepositoryV1 proxy = (OrderRepositoryV1) proxyFactory.getProxy();
        log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderRepository.getClass());
        return proxy;
    }

    private Advisor getAdvisor(LogTrace logTrace) {
        // pointcut
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "save*", "order*");

        // advice
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);

        // advisor
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}
  • NameMatchMethodPointcut을 사용해서 메서드에 request, save, order가 포함되어 있으면 포인트컷은 true를 반환한다.
  • Advisor를 생성하는 로직은 별도의 메서드로 추출하였다.

구현체만 있을 경우

  • target을 생성하는 부분만 제외하면 모든 로직은 동일하므로 컨트롤러 부분만 확인해보자.
    @Bean
    public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
        OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));
        ProxyFactory proxyFactory = new ProxyFactory(orderController);
        proxyFactory.addAdvisor(getAdvisor(logTrace));
        OrderControllerV2 proxy = (OrderControllerV2) proxyFactory.getProxy();
        log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
        return proxy;
    }

첫번째는 JDK 동적 프록시로, 두번째는 CGLIB로 동적 프록시를 생성해준다.
다만, 인터페이스에 구현체를 넣어 프록시 팩토리에 넘기느냐, 구현체를 프록시 팩토리에 넘기느냐 하는 차이만 있을뿐, 프록시 팩토리를 사용하는 방식은 완벽하게 똑같다. 이처럼 프록시 팩토리를 활용하면, 동적 프록시를 생성하는 기술을 신경쓰지 않고, 단지 프록시 팩토리를 활용해 동적으로 프록시를 생성할 수 있게 되었다.

정리

이것으로 AOP에서 중요한 개념인 포인트컷, 어드바이스, 어드바이저에 대해서 알아보았다.
포인트컷은 2가지 역할을 한다.

  1. 프록시를 생성할 대상인지 아닌지
  2. 메서드가 어드바이스를 적용할 메서드인지 아닌지.

이를 기반으로 어드바이스를 적용하는데,
어드바이스는 적용하고자 하는 부가기능(여기서는 로그 추적기)을 말한다.

어드바이저는 하나의 포인트컷 + 하나의 어드바이스를 가지고 있는 것을 말하는데, 어드바이저를 가지고 있다면 어떤 포인트컷과 어떤 어드바이스를 적용해야할지 알 수 있다. 따라서 프록시 팩토리에는 어드바이저를 지정해주는 것만으로 원하는 어드바이저를 적용할 수 있다.

여기서 기억해둬야 할 것은, 하나의 타겟에는 하나의 프록시만 생성한다는 것이다. 타겟에 여러 개의 어드바이저를 적용한다고 하더라도 스프링에서는 타겟에 하나의 프록시만을 생성하고, 하나의 프록시에 여러 어드바이스를 적용하도록 지원한다.

프록시 팩토리를 사용한 결과 개발자는 동적 프록시 구현 기술을 신경쓰지 않고 프록시를 생성할 수 있게 되었다.

하지만 프록시 팩토리에서도 단점이 있다.

  1. 설정 로직이 복잡해진다.
    각 타겟에 프록시를 생성할 때마다 프록시 팩토리를 생성해줘야 하고, 그 때마다 어드바이저를 생성해서 주입하는 등의 로직을 작성해야 한다.
    타겟이 많아질수록 설정 로직도 그만큼 많아지게 되는 것이다.

  2. 컴포넌트 스캔
    @Component를 이용해 빈으로 등록하는 객체의 경우 어플리케이션이 구축되는 과정에서 컨테이너에 빈으로 등록된 상태이기 때문에 앞서 확인한 방법으로는 프록시 적용이 불가능하다.

이런 문제를 해결하려면 어떻게 해야할까?
빈 후처리기를 사용하면 된다.

다음 포스팅에서는 빈 후처리기에 대해 알아보자.

출처 : 김영한 - 스프링 핵심 원리 고급편

profile
꾸준히 하자!

0개의 댓글