Spring

[Spring] 동적 프록시의 추상화, ProxyFactory

이덩우 2024. 3. 6. 00:49

1. ProxyFactory

스프링은 항상 비슷한 기능들을 묶어 *추상화*해 관리한다.

인터페이스 기반의 프록시 기술인 `JDK 동적 프록시`, 클래스 기반의 프록시 기술인 `CGLIB`를 생각해보자.

모든 경우에 항상 인터페이스 기반의 프록시만 필요하거나, 클래스 기반의 프록시만 필요할 수 없다. 따라서 스프링은 이 문제를 해결하기 위해 `ProxyFactory`라는 추상화된 개념을 도입한다.

사용 흐름

이제 동적 프록시를 얻으려면 `ProxyFactory에 접근하면된다.

다만, 남은 문제가 있다. 

 

JDK 동적 프록시, CGLIB 중 어떤 기술을 사용할 지 모르니 `InvocationHandler`와 `MethodInterceptor`를 둘 다 만들어놔야하지 않는가? 이는 중복이다. 스프링은 이 문제를 어떻게 해결할까?

 


2. 어드바이스(Advice)

스프링은 위 문제를 해결하기위해 `Advice`라는 새로운 개념을 도입했다. 

개발자는 `InvocationHandler`와 `MethodInterceptor`를 신경쓰지 않고 `Advice`만 만들면 된다!

다시 한번 추상화된 개념을 도입한 것이다.

 

프록시 팩토리가 각 핸들러나 인터셉터를 만들어주고, 단순히 우리가 만들 `Advice`로 요청을 위임하는 형태이다.

Advice 도입

 

어드바이스는 JDK 동적 프록시의 핸들러 or CGLIB의 인터셉터와 같이 *부가 기능 로직을 수행한다.* 단어의 맥락을 기억하면 쉽다! 어드바이스, 즉 조언이나 충고의 의미이다. 

어드바이스를 만드는 방법은 여러가지가 있지만, 기본적인 방법은 아래 인터페이스를 구현하면 된다.

package org.aopalliance.intercept;
 
public interface MethodInterceptor extends Interceptor {
     Object invoke(MethodInvocation invocation) throws Throwable;
}

어라, CGLIB 동적 프록시를 위해 `MethodInterceptor`를 구현했었는데 왜 같은 인터페이스를 구현할까?

사실 이름만 같은 인터페이스고 패키지가 다르다. 현재 보고있는 `MethodInterceptor`는 AOP 패키지에 존재하는 인터페이스이다.

 

예제를 통해 실제로 어드바이스를 만들어보자!

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

        Object result = invocation.proceed();

        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료, resultTime={}", resultTime);
        return result;
    }
}

유심히 볼 점은 *이제 더 이상 target을 필드로 가지고있지 않다는 점이다.

프록시 팩토리를 생성하는 시점에 target에 대한 정보를 미리 넣어주기 때문에, 더 이상 필드에 target을 가질 필요가 없다.

 

또한, `InvocationHandler`, `MethodInterceptor(CGLIB)`는 오버라이딩할 메서드의 인자로 proxy, method, args 등이 넘어왔는데 이제 단순히 `MethodInvocation` 한 가지만 넘어온다. 

 

실제 target을 호출하는 부분도 `invocation.proceed()`로 대체된다. 많은 부분이 추상화된 것이다!

이제 실제로 프록시 팩토리를 통해 동적 프록시를 생성해보자.

public class ProxyFactoryTest {
    @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());
        // jdk 동적 프록시 적용
        proxy.save();

        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
        // 직접 만든 프록시는 안된다. 프록시 팩토리를 통해서 만든 것만 확인 가능
    }
}
  • 프록시 팩토리를 생성하는 시점에 target을 주입한다.
  • 어드바이스를 추가한다. -> 부가 기능을 추가하는 개념
  • `getProxy()`를 통해 프록시 객체를 얻는다.
  • 사용한다.
참고로 테스트 예제처럼 프록시 팩토리를 통해서 만든 프록시 객체만 `AopUtils`를 활용해 프록시 객체인지, 어떤 프록시 기술을 사용해 만들어졌는지 확인할 수 있다.
`ProxyFactory.setTargetClass(true)`옵션을 사용하면 인터페이스가 있어도, 강제로 CGLIB를 기반으로 프록시를 생성한다.
스프링 부트는 AOP를 적용할 때 기본적으로 위 옵션을 `true`로 설정한다. 자세한 이유는 뒷 강의에서!

 


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

- 소개

자, 이제 스프링 AOP를 한 번쯤 접해봤다면 들어봤을 어드바이스, 어드바이저, 포인트컷에대해 알아보자. 

  • 어드바이스(Advice)
    • 바로 위에서 알아본 내용이다. 어떤 부가 기능을 수행할 것인지를 의미한다. 실제 프록시에서 수행할 부가 로직이다.
    • 역할 : 부가 기능 로직
  • 포인트컷(Pointcut)
    • 어디에 부가 기능(어드바이스)를 적용할 건지 판단하는 필터링 로직이다. 주로 클래스 이름과 메서드 이름으로 필터링한다.
    • 역할 : 필터
  • 어드바이저(Advisor)
    • 하나의 포인트컷 + 하나의 어드바이스를 가지고 있는 것이다.
    • 역할 : 둘을 합친 것

왜 이렇게 구분해놨을까? 어드바이스에 사실 필터링 로직을 함께 섞는 것도 가능은 하지만, 이는 역할이 제대로 분리되지 않은 것이다. 

*역할과 책임을 명확하게 분리하기 위해 도입된 개념들이다!*

실제 관계도를 보면 다음과 같다.

이해를 위한 구조도

 

사실 프록시 팩토리를 사용할 때는 *어드바이저가 필수이다.*

응? 이전에 프록시 팩토리를 사용할 때 어드바이스만 사용해도 됐는데..?

`addAdvice()`는 내부적으로 DefaultPointcutAdvisor를 넣어서 항상 True를 반환하는 포인트컷이 내장된 어드바이저가 주입된다.

 

- 포인트컷

포인트컷은 필터 역할을 한다고 말했다. 

메서드명으로 필터 역할을 하는 예제 포인트컷을 직접 만들어보자.

사실 스프링은 이미 수많은 포인트컷을 제공한다. 직접 만들일은 드물지만, 학습차원에서 만들어보자.
static class MyPointcut implements Pointcut {
    @Override
    public ClassFilter getClassFilter() {
        return ClassFilter.TRUE;
    }

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

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

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

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

포인트컷은 `Pointcut`인터페이스를 구현해 만들 수 있다.

내부적으로 두 가지의 메서드를 확인하는데, *클래스 필터를 거쳤을 때 참이어야하고 메서드 필터를 거쳤을 때 참이어야 어드바이스를 적용한다.

`isRuntime()`이 false인 경우가 첫번째 `matches`메서드를 호출하게된다. 위 아래 메서드가 파라미터 종류의 차이인 것을 알 수 있는데, 스프링은 정적인 데이터로 캐싱이 가능하기 때문에 `isRuntime()`메서드의 반환값은 true로 설정하고 사용한다.

`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는 어드바이스 적용 O
//find는 어드바이서 적용 X

어드바이저의 구현체로는 가장 일반적인 `DefaultPointcutAdvisor`를 사용했다. 파라미터로 직접 만든 포인트컷과 어드바이스를 넣어 생성했다.

이후 *프록시 팩토리에 어드바이저를 주입한다.*

 

이제 스프링이 제공하는 포인트컷을 알아보자. 스프링은 우리가 필요한 포인트컷을 이미 대부분 제공한다.

정말 다양한 포인트컷이 있지만, 지금은 학습차원에서 `NameMatchMethodPointcut`을 사용해보자.

실제로는 사용하기도 편하고 기능도 가장 많은 `AspectJExpressionPointcut`을 사용한다.
지금은 동작방식과 전체 구조에 집중해자.
public class AdvisorTest {
    @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();
    }
}

정말 단순하게 `setMappedName()`메서드를 활용해 필터링할 메서드명을 지정해줄 수 있다.

 

- 여러 어드바이저를 함께 적용

여러 어드바이저를 하나의 target에 적용하려면 어떻게 해야할까?

프록시 팩토리를 여러 개 생성해 프록시를 여러 개 생성하면 되지 않을까? 

이것도 맞는 말이다. 아래와 같은 구조도가 완성될 것이다.

여러 프록시를 사용

 

하지만 적용하고 싶은 어드바이저가 10개, 그 이상이라면 과연 효율적인 방식일까?

스프링은 이 문제를 해결하기 위해 하나의 프록시로 여러 어드바이저를 적용할 수 있게 만들어두었다.

하나의 프록시로 사용

 

예제 코드로 살펴보자!

public class MultiAdvisorTest {
    @Test
    @DisplayName("여러 프록시")
    void multiAdvisorTest1() {
        // 프록시 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();
    }

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

        // 프록시 1 생성
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvisor(advisor2);
        proxyFactory.addAdvisor(advisor1);
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

        proxy.save();
    }

    static class Advice1 implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info("advice 1 호출");
            return invocation.proceed();
        }
    }

    static class Advice2 implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info("advice 2 호출");
            return invocation.proceed();
        }
    }
}

첫 번째 테스트의 경우 처음 설명한 여러 프록시를 생성하는 방식이다. 

두 번째 테스트를 보면 하나의 프록시 팩토리를 생성하고 여러개의 어드바이저를 주입하는 모습이다.

이 때 순서는 중요하다. *먼저 등록한 어드바이저가 먼저 실행된다.*

 

영한님의 말에 따르면 많은 개발자들이 스프링 AOP를 처음 공부하거나 사용하면 어드바이저를 적용한 수 만큼 프록시가 생성된다고 착각한다고한다. 하지만 스프링은 성능 최적화를 위해 프록시는 하나만 만들고 하나의 프록시에 여러 어드바이저를 적용하는 형태가 가능하게 만들었다. 

*하나의 target에 여러 AOP가 동시에 적용되어도 하나의 프록시만 생성된다는 것을 기억하자.*

 


4. 적용

실제 애플리케이션에 적용해보자. Config 파일만 수정하면 된다.

@Configuration
public class ProxyFactoryConfigV1 {

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

    @Bean
    public OrderServiceV2 orderServiceV1(LogTrace logTrace) {
        OrderServiceV2 target = new OrderServiceV2Impl(orderRepositoryV1(logTrace));
        ProxyFactory factory = new ProxyFactory(target);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderServiceV2 proxy = (OrderServiceV2) factory.getProxy();
        log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), target.getClass());
        return proxy;
    }

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

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

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

        LogTraceAdvice advice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}
  • 하나의 어드바이스로 모든 곳에서 사용하는 모습
  • 현재는 인터페이스 기반의 동적 프록시를 사용하는 예제이지만, 구체 클래스도 똑같은 모양으로 변경하면 된다.
  • 이 경우 JDK 동적 프록시가 생성될 것이다.

로그 출력 결과

 


5. 남은 문제

프록시 팩토리를 도입하면서 인터페이스 기반, 클래스 기반이 섞여있을 때 어떻게 프록시를 생성할지 고민하는 문제를 해결했다.

하지만 아직 해결되지 않은 문제가 있다.

  • 너무 많은 설정 파일
    • 스프링 빈이 많으면 많을수록 수동 빈 등록 방식에서 모든 빈 코드에 프록시 생성 코드를 만들어야한다.
  • 컴포넌트 스캔
    • 컴포넌트 스캔을 통해 스프링 빈을 자동으로 등록하는 상황에서는 현재 방법으로는 프록시 적용이 불가능하다.
      왜냐면 *컴포넌트 스캔은 실제 객체를 스프링 컨테이너에 빈으로 등록해버리기 때문이다.*

위 문제들을 해결하는 *빈 후처리기*에 대해 다음 포스팅에서 알아보자!

 

 

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