Advance I/Spring

22.10.12

kinggora 2022. 10. 12. 16:03

6. 스프링이 지원하는 프록시

프록시 팩토리와 Advice - 소개

문제점

  1. 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야할까?
  2. 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler 와 CGLIB가 제공하는 MethodInterceptor 를 각각 중복으로 만들어서 관리해야 할까?
  3. 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되었으면?

 

Q: 인터페이스 有 -> JDK 동적 프록시 적용, 인터페이스 無 -> CGLIB 적용하기

스프링은 유사한 구체 기술들을 통합해서 일관성 있게 접근하고 편리하게 사용할 수 있는 추상화된 기술을 제공한다.

스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리( ProxyFactory )라는 기능을 제공한다.

프록시 팩토리는 기본적으로 인터페이스가 있으면 JDK 동적 프록시를 사용하고, 구체 클래스만 있다면 CGLIB를 사용한다. (이 설정을 변경할 수도 있다)

 

의존 관계

클라이언트는 ProxyFactory에 접근. ProxyFactory는 동적으로 프록시를 생성하여 반환

 

사용 흐름

Q: 두 기술을 함께 사용할 때 부가 기능을 적용하기 위해 InvocationHandler와 MethodInterceptor를 모두 구현해야 하나?

스프링은 InvocationHandler 와 InvocationHandler 를 추상화한 Advice 라는 새로운 개념을 도입했다.

개발자는 Advice 만 만들면 된다. 결과적으로 InvocationHandler 나 InvocationHandler 는 Advice 를 호출하게 된다.

프록시 팩토리를 사용하면 Advice 를 호출하는 전용 InvocationHandler , MethodInterceptor 를 내부에서 사용한다.

 

스프링이 InvocationHandler , MethodInterceptor 가 모두 내부적으로 Advice를 호출하도록 세팅해두었다.

 

프록시 팩토리에 의해 동적으로 생성된 프록시의 실행 로직( Handler )에 클라이언트가 생성한 Advice 호출이 포함된다.

 

Q: 특정 조건에 따라 프록시 로직을 적용하거나 적용하지 않는 공통 기능

앞서 특정 메서드 이름의 조건에 맞을 때만 프록시 부가 기능이 적용되는 코드를 직접 만들었다. (메서드 이름 필터)

스프링은 Pointcut 이라는 개념을 도입해서 이 문제를 일관성 있게 해결한다.

프록시 팩토리와 Advice - 예제 코드

Advice: 프록시에 적용하는 부가 기능 로직

프록시 팩토리를 사용하면 InvocationHandler 와 MethodInterceptor 대신에 Advice 를 사용하면 된다.

Advice 를 만드는 방법은 여러가지가 있지만, 기본적인 방법은 다음 인터페이스를 구현하면 된다

 

MethodInterceptor - 스프링이 제공하는 코드

package org.aopalliance.intercept; 

public interface MethodInterceptor extends Interceptor { 
    Object invoke(MethodInvocation invocation) throws Throwable; 
}

 

  • Interceptor 가 Advice 를 상속한다.
  • CGLIB의 MethodInterceptor 와 패키지가 다르다. 참고로 여기서 사용하는 org.aopalliance.intercept 패키지는 스프링 AOP 모듈( spring-top ) 안에 들어있다.
  • MethodInvocation invocation 내부에는 다음 메서드를 호출하는 방법, 현재 프록시 객체 인스턴스, args , 메서드 정보 등이 포함되어 있다. 기존에 파라미터로 제공되는 부분들이 이 안으로 모두 들어갔다고 생각하면 된다.

TimeAdvice ( MethodInterceptor 구현체 )

@Slf4j
public 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={}", resultTime);
        return result;
    }
}

 

Object result = invocation.proceed() invocation.proceed() 를 호출하면 target 클래스를 호출하고 그 결과를 받는다.

그런데 기존에 보았던 코드들과 다르게 target 클래스의 정보가 보이지 않는다. target 클래스의 정보는 MethodInvocation invocation 안에 모두 포함되어 있다.

그 이유는 바로 다음 코드에 확인할 수 있는데, 프록시 팩토리를 생성할 때 target을 생성자로 주입받기 때문이다.

interfaceProxy() 테스트

인터페이스가 있는 ServiceImpl

@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();

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

 

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

* 프록시 팩토리를 통한 프록시 적용 확인 *

AopUtils.isAopProxy(proxy) : 프록시 팩토리를 통해서 프록시가 생성되면 JDK 동적 프록시나, CGLIB 모두 참

AopUtils.isJdkDynamicProxy(proxy) : 프록시 팩토리를 통해서 프록시가 생성되고, JDK 동적 프록시인 경우 참

AopUtils.isCglibProxy(proxy) : 프록시 팩토리를 통해서 프록시가 생성되고, CGLIB 동적 프록시인 경우 경우 참

concreteProxy() 테스트

구체 클래스만 있는 ConcreteService

@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();

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

 

  • 프록시 팩토리는 인터페이스 없이 구체 클래스만 있으면 CGLIB를 사용해서 프록시를 적용한다.

proxyTargetClass() 테스트

인터페이스가 있지만 CGLIB를 사용하고 싶을 때, proxyFactory.proxyTargetClass(true) 옵션 사용 

@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB, 클래스 기반 프록시 사용")
void proxyTargetClass(){
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);

    proxyFactory.setProxyTargetClass(true);

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

    proxy.save();

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

 

proxyFactory.proxyTargetClass(true) 외에는 interfaceProxy() 테스트와 동일한 코드

하지만 AopUtils 통한 검증 결과가 다르다. 

프록시 팩토리의 기술 선택 방법

  • 대상에 인터페이스가 있으면: JDK 동적 프록시, 인터페이스 기반 프록시
  • 대상에 인터페이스가 없으면: CGLIB, 구체 클래스 기반 프록시
  • proxyTargetClass=true : CGLIB, 구체 클래스 기반 프록시, 인터페이스 여부와 상관없음

정리

프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고, 매우 편리하게 동적 프록시를 생성할 수 있다.

프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게 Advice 하나로 편리하게 사용할 수 있었다. 이것은 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우 InvocationHandler 가 Advice 를 호출하도록 개발해두고, CGLIB인 경우 MethodInterceptor 가 Advice 를 호출하도록 기능을 개발해두었기 때문이다.

 

참고: 스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다.

따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.

자세한 이유는 강의 뒷 부분에서 설명한다.

포인트컷, 어드바이스, 어드바이저 - 소개

스프링 AOP를 공부했다면 다음과 같은 단어를 들어보았을 것이다. 

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

부가 기능 로직을 적용해야 하는데, 포인트컷으로 어디에 적용할지 선택하고, 어드바이스로 어떤 로직을 적용할지 선택하는 것이다. 그리고 어디에? 어떤 로직?을 모두 알고 있는 것이 어드바이저이다.

 

역할과 책임

포인트컷은 대상 여부를 확인하는 필터 역할만 담당한다.

어드바이스는 깔끔하게 부가 기능 로직만 담당한다.

스프링의 어드바이저는 하나의 포인트컷 + 하나의 어드바이스로 구성된다.

 

참고: 해당 단어들에 대한 정의는 프록시에 맞추어서 설명하지만, 이후에 AOP 부분에서 다시 한번 AOP에 맞추어 정리할 것이다. 아래 그림은 이해를 돕기 위한 것이고, 실제 구현은 약간 다를 수 있다.

 

Pointcut 필터에 의해 Advice 부가 기능을 적용하지 않더라도 target 은 호출한다.

예제 코드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();
}
  • new DefaultPointcutAdvisor : Advisor 인터페이스의 가장 일반적인 구현체이다. 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어주면 된다. ( 어드바이저 = 1 포인트컷 + 1 어드바이스 )
  • Pointcut.TRUE : 항상 true 를 반환하는 포인트컷이다. 이후에 직접 포인트컷을 구현해볼 것이다.
  • new TimeAdvice() : 앞서 개발한 TimeAdvice 어드바이스
  • proxyFactory.addAdvisor(advisor) : 프록시 팩토리에 적용할 어드바이저를 지정한다. 프록시 팩토리를 사용할 때 어드바이저는 필수이다. 그런데 이전에는 어드바이저가 아니라 어드바이스를 바로 적용했다. ( proxyFactory.addAdvice() ) 이것은 단순히 편의 메서드이고 해당 메서드 내부에서 현재 코드와 똑같은 어드바이저가 생성된다.