S E P H ' S

[Spring] 10. Spring AOP - 동적 프록시 (ProxyFactory) 본문

Programing & Coding/Spring

[Spring] 10. Spring AOP - 동적 프록시 (ProxyFactory)

yoseph0310 2023. 6. 28. 13:58

JDK 동적 프록시 & CGLIB 동적 프록시

이전 포스트의 내용을 한번 정리하고 프록시 팩토리에 대해서 시작해보자. 동적 프록시를 생성하기 위해 Java의 리플렉션을 활용하여 동적으로 메소드를 뽑아냈다. 그리고 이를 바탕으로 구현된 JDK 동적 프록시 기술로 프록시 객체를 만들어 의존관계 주입을 통해서 처리했다.

 

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

 

실무에서는 인터페이스가 있는 경우, 구체 클래스만 있는 경우 등 다양하다. 이럴 때는 CGLIB, JDK 동적 프록시를 위해 여러가지 구현해야 할 것들이 많아진다. 스프링은 이렇게 번거로운 과정을 해결하기 위해 CGLIB, JDK 동적 프록시를 추상화하여 제공하는 프록시 팩토리(Proxy Factory)가 존재한다.


Spring의 Proxy Factory

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

 

JDK 동적 프록시는 InvocationHandler로 프록시의 몸통을 추상화한다. CGLIB는 MethodInterceptor로 프록시 몸통을 추상화한다. 프록시 팩토리는 JDK 동적 프록시와 CGLIB 프록시를 모두 가지고 있어 InvocationHandler와 MethodInterceptor의 추상화가 필요하다. 프록시 팩토리는 Advice라는 개념으로 두 개념을 추상화하여 사용한다.

클라이언트가 프록시 팩토리로 만든 프록시를 호출하게 되면 위 흐름대로 흘러간다. JDK 프록시는 InvocationHandler.invoke()가 호출되고 CGLIB 프록시는 MethodInterceptor.intercept()가 호출된다. 둘 중 하나라도 호출되면 프록시 팩토리의 Advice.invoke()가 호출되도록 흐름이 되어있다. 따라서 Advice.invoke()를 프록시 몸통이라고 생각하면 된다.


Spring ProxyFactory 사용해보기

  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, 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(messsage);
            
            // 핵심 기능
            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를 적용할 때 기본적으로 proxyTargetClass=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);
    proxyFactory1.addAdvice(new advice2());
    ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();
    
    proxy2.save();
}
  • 위와 같이 프록시 사이의 의존관계를 추가하여 어드바이저를 여러개 추가할 수있다.
  • 실제 타겟은 프록시 1이, 프록시 1을 프록시 2가 가진다.
  • 프록시 2를 실행하면 프록시 2 -> 프록시 1 -> 실제 타겟 순으로 실행된다.

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

 

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

 

@Test
void mutipleProxy2() {
    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에 대해 알아볼 것이다.