본문 바로가기
Java/Spring

프록시 팩토리를 통한 AOP

by oneny 2023. 10. 16.

프록시 팩토리

스프리에서 프록시 팩토리는 AOP(Aspect-Oriented Programming)의 핵심 개념 중 하나이다. AOP는 애플리케이션의 핵심 비즈니스 로직 외에도 보안, 로깅, 트랜잭션 관리 등과 같은 다양한 부가적인 관심사를 모듈화하고 분리하는데 사용되는 기술로 이를 통해 코드 중복을 줄이고 코드의 재사용성을 향상시킬 수 있다.

프록시 팩토리는 주로 AOP를 구현하기 위해 사용된다. 스프링은 빈(Bean)을 프록시 객체로 래핑하고, 프록시 객체를 통해 부가적인 관심사를 처리하는 방식으로 AOP를 제공한다. 따라서 클라이언트 코드에 영향을 주지 않으면서 부가적인 동작을 수행할 수 있다.

 

프록시가 나온 계기

런타임 환경에서 프록시 객체를 동적으로 생성하는 방법에 다음 두 가지 방법이 있다.

  • JDK 동적 프록시
  • CGLIB 라이브러리

인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용한다. 스프링은 유사한 구체적인 기술들이 있을 때, 그것들을 통합해서 일관성 있게 접근할 수 있고, 더욱 편리하게 사용할 수 있는 추상호된 기술을 제공한다. 따라서 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory)라는 기능을 제공한다. 따라서 위 그림처럼 프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고, 구체 클래스만 있다면 CGLIB를 사용하기 때문에 팩토리 하나로 편리하게 동적 프록시를 생성할 수 있다.

 

Advice

 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 하는 문제를 Advice라는 개념을 도입하여 해결했다. 즉, 개발자는 InvocationHandler나 MethodInterceptor를 신경쓰지 않고, Advice만 만들면 된다. 그러면 위 그림처럼 결과적으로 Advice를 호출하는 전용 InvocationHandler, MethodInterceptor를 내부에서 사용한다.

또한, 특정 메서드의 이름의 조건에 맞을 때만 프록시 부가 기능이 적용되는 코드는 스프링에서 Pointcut이라는 개념을 도입해서 해결할 수 있다.

 

Advice 구현

Advice를 만드는 방법에는 여러가지가 있지만, 기본적인 방법은 org.aopalliance.intercept.MethodInterceptor를 구현하면 된다. MethodInterceptor는 Interceptor를 상속하고, Interceptor는 Advice 인터페이스를 상속한다.

  • MethodInvocation invocation: 내부에는 다음 메서드를 호출하는 방법, 현재 프록시 객체 인스턴스, args, 메서드 정보 등이 포함되어 있다. 기존에 파라미터로 제공되는 부분들이 이 안으로 모두 들어갔다고 생각하면 된다. invocation.proceed()를 호출하면 target 클래스를 호출하고 그 결과를 받는다.

 

@Slf4j
public class TimeAdviceTest {

    @Test
    @DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
    public void interfaceProxy() {
        Service target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvice(new TimeAdvice());
        Service proxy = (Service) proxyFactory.getProxy();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        proxy.save();

        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
    }

    @Test
    @DisplayName("구체 클래스만 있으면 CGLIB 사용")
    public 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.save();

        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
        assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
    }

    @Test
    @DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고, 클래스 기반 프록시 사용")
    public void proxyTargetClass() {
        Service target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(true);
        proxyFactory.addAdvice(new TimeAdvice());
        Service proxy = (Service) proxyFactory.getProxy();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        proxy.save();

        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
        assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
    }

    @Slf4j
    static class ConcreteService {

        public void save() {
            log.info("ConcreteService - save 호출");
        }
    }

    @Slf4j
    static class ServiceImpl implements Service {

        @Override
        public void save() {
            log.info("ServiceImpl - save 호출");
        }
    }


    @Slf4j
    static class TimeAdvice implements MethodInterceptor {

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

            Object result = invocation.proceed();
            long endTime = System.currentTimeMillis();
            long resultTime = endTime - startTime;
            log.info("TimeProxy 종료 resultTime={}ms", resultTime);
            return result;
        }
    }
}

위 코드는 인터페이스가 있어 JDK 동적 프록시로 프록시 생성하는 경우, 구체클래스만 있어 CGLIB로 프록시 생성하는 경우, 인터페이스가 있지만 CGLIB로 프록시 생성하는 경우를 테스트한 코드이다.

  • new ProxyFactory(target): 프록시 팩토리를 생성할 떄, 생성자에 프록시의 호출 대상을 함께 넘겨준다. 프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 만들어 낸다. 만약 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성한다.
  • proxyFactory.addAdvice(new TimeAdvice()): 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor의 개념과 유사하다.

 

 

인터페이스가 있어 JDK 동적 프록시 사용한 경우 결과

결과를 보면 프록시 객체가 ServiceImple 객체가 아닌 프록시 객체인 것을 확인할 수 있다. 이는 위에서 살펴본대로 proxy 객체의 save() 메서드를 호출하면 addAdvice를 통해 추가한 Advice가 호출되어 TimeProxy 실행 메시지와 TimeProxy 종료 메시지가 출력된 것을 확인할 수 있다.

또한, 해당 프록시 객체는 인터페이스가 있기 때문에 테스트를 통해 JDK 동적 프록시를 사용한 것을 확인할 수 있다.

 

 

인터페이스가 있어 JDK 동적 프록시 사용한 경우 결과

인터페이스가 없는 ConcreteService를 target으로 한 경우에는 뒤에 SpringCGLIB가 붙어 있고, 당연히 CGLIB를 했기 때문에 테스트를 통과한 것을 확인할 수 있다.

 

인터페이스가 있어 JDK 동적 프록시 사용한 경우 결과

프록시 팩토리는 proxyTargetClass라는 옵션을 제공하는데, 이 옵션에 true 값을 넣으면 인터페이스가 있어도 강제로 CGLIB를 사용한다. 따라서 인터페이스가 아닌 클래스 기반의 프록시가 만들어진 것을 확인할 수 있다.

 

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

  • 포인트컷(Pointcut): 어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다. 주로 클래스와 메서드 이름으로 필터링한다. 이름 그대로 어떤 포인트(Point)에 기능을 적용할지 하지 않을지 잘라서(cut) 구분하는 것이다.
  • 어드바이스(Advice): 이전에 본 것처럼 프록시가 호출하는 부가 기능이다. 단순하게 프록시 로직이라 생각하면 된다. 
  • 어드바이저(Advisor): 단순하게 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것이다. 쉽게 포인트컷1 + 어드바이스1이다. 즉, 어드바이저는 어디(Pointcut)에 Advice를 해야할지를 알고 있다.

 

addAdvice 메서드 다시 살펴보기

DefaultPointcutAdvisor는 Advisor 인터페이스의 가장 일반적인 구현체이다. 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어주면 되는데 위에서 addAdvice() 메서드를 사용한 것도 결국 내부 로직을 살펴보면 addAdvisor를 사용하는 것을 확인할 수 있고, Pointcut.TRUE로 항상 true를 반환하는 포인트컷을 넣은 것을 확인할 수 있다.

proxyFactory.addAdvisor(advisor)는 프록시 팩토리에 적용할 어드바이저를 지정한다. 어드바이저는 내부에 포인트컷과 어드바이스를 모두 가지고 있어 어디에 어떤 부가 기능을 적용해야 할지 어드바이저 하나로 알 수 있다. 프록시 팩토리를 사용할 때 어드바이저는 필수이다.

따라서 proxyFactory.addAdvice(new TimeAdvice())를 실행하면 proxyFactory.addAdvisor(Pointcut.TRUE, new TimeAdvice()처럼 내부적으로 동작한다고 보면 된다.

 

Pointcut 관련 인터페이스

public interface Pointcut {
    ClassFilter getClassFilter();
    MethodMatcher getMethodMatcher();
}

public interface ClassFilter {
	boolean matches(Class<?> clazz);
}

public interface MethodMatcher {
	boolean matches(Method method, Class<?> targetClass);
}

포인트컷은 크게 ClassFilter와 MethodMatcher 둘로 이루어진다. 이름 그대로 하나는 클래스가 맞는지, 하나는 메서드가 맞는지 확인할 때 사용한다. 둘 다 true로 반환해야 어드바이스를 적용할 수 있다.


Pointcut 관련 테스트

@Slf4j
public class AdvisorTest {

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

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

    @Test
    @DisplayName("스프링이 제공하는 포인트컷")
    void advisorTest3() {
       ServiceImpl 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);
       Service proxy = (Service)proxyFactory.getProxy();

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

    static class MyPointCut implements Pointcut {
       @Override
       public ClassFilter getClassFilter() {
          return ClassFilter.TRUE;
       }

       @Override
       public MethodMatcher getMethodMatcher() {
          return new MyMethodMatcher();
       }
    }

    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;
       }
    }

    @Slf4j
    static class ServiceImpl implements Service {

       @Override
       public void save() {
          log.info("ServiceImpl - save 호출");
       }

       @Override
       public void find() {
          log.info("ServiceImpl - find 호출");
       }
    }

    @Test
    @DisplayName("하나의 프록시, 여러 어드바이저")
    void multiAdvisorTest() {
       DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
       DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());

       Service target = new ServiceImpl();
       ProxyFactory proxyFactory = new ProxyFactory(target);
       proxyFactory.addAdvisor(advisor1);
       proxyFactory.addAdvisor(advisor2);
       Service proxy = (Service)proxyFactory.getProxy();

       proxy.save();
    }

    @Slf4j
    static class TimeAdvice implements MethodInterceptor {

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

          Object result = invocation.proceed();
          long endTime = System.currentTimeMillis();
          long resultTime = endTime - startTime;
          log.info("TimeProxy 종료 resultTime={}ms", resultTime);
          return result;
       }
    }

    @Slf4j
    static class Advice2 implements MethodInterceptor {
       @Override
       public Object invoke(MethodInvocation invocation) throws Throwable {
          log.info("advice2 호출");
          return invocation.proceed();
       }
    }
}
  • MyMethodMatcher: MethodMatcher 인터페이스를 구현한 클래스이다.
    • matches(): 이 메서드에 method, targetClass 정보가 넘어온다. 이 정보로 어드바이스를 적용할지 적용하지 않을지 판단할 수 있다.
    • isRuntime(), matches(... args): isRuntime()이 값이 참이면 matches(... args) 메서드가 대신 호출된다. 동적으로 넘어오는 매개변수를 판단 로직으로 사용할 수 있다.
      즉, isRuntime()이 false인 경우 클래스의 정적 정보만 사용하기 때문에 스프링 내부에서 캐싱을 통해 성능 향상이 가능하지만, isRuntime()이 true인 경우 매개변수가 동적으로 변경된다고 가정하기 때문에 캐싱하지 않는다.
  • MyPointcut: Pointcut을 직접 구현한 포인트컷이다. 현재 메서드 기준으로 로직을 적용하면 된다. 클래스 필터는 항상 true를 반환하도록 했고, 메서드 비교 기능은 MyMethodMatcher를 사용한다.
  • new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice()): 어드바이저에 직접 구현한 포인트컷을 사용한다.

 

직접 만든 포인트컷 결과

matches() 메서드 내에서 메서드 이름이 "save"인 경우에만 true를 반환하도록 로직을 작성했다. 따라서 save() 메서드를 호출했을 때에만 TimeProxy가 실행되고, find() 메서드를 호출한 경우에는 TimeProxy가 적용되지 않은 것을 확인할 수 있다.

 

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

NameMatchMethodPointcut을 생성하고 setMappedName(...)으로 메서드 이름을 지정하면 포인트컷이 완성되어 위 같은 결과가 출력되는 것을 확인할 수 있다. 내부에서는 PatternMatchUtils를 사용하기 때문에 *xxx*와 같은 표현식이 가능하다. 그 외에 추가로 지원하는 포인트컷은 다음과 같다.

  • JdkRegexMethodPointcut: JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.
  • TruePointcut: 항상 참을 반환한다.
  • AnnotationMatchingPointcut: 어노테이션으로 매칭한다.
  • AspectJExpressionPointcut: aspectJ 표현식으로 매칭한다.

 

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

하나의 ProxyFactory에 여러 어드바이저를 적용할 수 있다. 등록한 순서대로 호출되는 것을 확인할 수 있다. 즉, 하나의 target에 여러 AOP가 동시에 적용되어도 각각의 프록시를 생성하는 것이 아닌 하나의 프록시만 생성하여 여러 어드바이저를 적용한다는 점을 주의해야 한다.

 

프록시 팩토리만 사용했을 때의 문제점

어드바이저, 어드바이스, 포인트컷 이라는 개념 덕분에 어떤 부가 기능을 어디에 적용할 지 관심사를 분리할 수 있었다. 하지만 이를 Configuration 클래스에 빈으로 등록하려고 한다면 만약 100개가 있을 때 프록시를 통해 부가 기능을 적용하려면 100개의 동적 프록시 생성 코드를 만들어야 하기 때문에 너무 많은 설정 지옥이 될 가능성이 매우 높아진다.

그리고 컴포넌트 스캔을 사용하는 경우에는 위 방법대로는 프록시 적용이 불가능하다. 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문에 컴포넌트 스캔으로는 프록시 적용을 할 수 있다는 문제가 발생한다.

따라서 위 두 가지 문제를 한 번에 해결하는 방법으로 빈 후처리기가 있다.

 

AOP 용어 정

  • 타겟(Target): 부가기능을 부여할 대상을 말한다. 즉, 어드바이스를 받는 객체로 포인트컷으로 결정된다. 타겟은 핵심기능을 담은 클래스일 수도 있지만 경우에 따라 다른 부가기능을 제공하는 프록시 오브젝트일 수도 있다.
  • 어드바이스: 타깃에게 제공할 부가기능을 담은 객체이다. 어드바이스는 오브젝트로 정의하지만 메서드 레벨에서 정의할 수도 있다.
  • 조인 포인트: 어드바이스가 적용될 수 있는 위치를 말한다. 조인 포인트는 추상적인 개념으로 AOP를 적용할 수 있는 모든 지점이라 생각하면 된다. 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메서드 실행 지점으로 제한된다.
  • 포인트컷: 어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈을 말한다. 스프링 AOP의 조인 포인트는 메서드의 실행이므로 스프링의 포인트컷은 메서드를 선정하는 기능을 갖고 있다.
  • 어드바이저: 포인트컷과 어드바이스를 하나씩 갖고 있는 오브젝트이다. 어드바이저는 어떤 부가기능(어드바이스)을 어디에(포인트컷) 전달할 것인가를 알고 있는 AOP의 가장 기본이 되는 모듈이다.
  • AOP 프록시: AOP 기능을 구현하기 위해 만든 프록시 객체를 말한다. 스프링에서 AOP 프록시는 JDK 동적 프록시 또는 CGLIB 프록시이다.

 

출처

스프링 핵심 원리 - 고급편

토비의 스프링 3.1 Vol 1 스프링의 이해와 원리