관리 메뉴

나구리의 개발공부기록

스프링이 지원하는 프록시, 프록시 팩토리(소개/예제), 포인트컷/어드바이스/어드바이저 - 소개, 예제(어드바이저/직접 만든 포인트컷/스프링이 제공하는 포인트컷/여러 어드바이저 함께 제공), 프록시 팩토리 적용 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 핵심원리 - 고급편

스프링이 지원하는 프록시, 프록시 팩토리(소개/예제), 포인트컷/어드바이스/어드바이저 - 소개, 예제(어드바이저/직접 만든 포인트컷/스프링이 제공하는 포인트컷/여러 어드바이저 함께 제공), 프록시 팩토리 적용

소소한나구리 2024. 11. 10. 19:06

출처 : 인프런 - 스프링 핵심 원리 - 고급편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 프록시 팩토리 - 소개

1) 기존 동적 프록시의 문제점을 해결

(1) 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용

  • 스프링은 유사한 구체적인 기술들이 있을 때 그것들을 통합해서 일관성 있게 접근할 수 있고 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공하는데, 동적 프록시도 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory)라는 기능을 제공함
  • 이전에는 상황에 따라서 개발자가 직접 JDK 동적 프록시를 사용하거나 CGLIB를 사용해야 했다면 프록시 팩토리 하나로 편리하게 동적 프록시를 생성할 수 있음
  • 프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고, 구체 클래스만 있다면 CGLIB를 사용하며 옵션을 통해 설정을 변경할 수도 있음
  • 즉, 클라이언트는 프록시 팩토리만 의존하면 프록시 팩토리가 알아서 환경에 따라 JDK 동적 프록시나 CGLIB를 생성해주어 매우 편리하게 동적 프록시를 사용할 수 있음

프록시 팩토리 의존관계와 사용흐름


(2) 두 기술을 함께 사용할 때 부가 기능을 적용하기 위한 방법

  • 스프링은 이 문제를 해결하기 위해 부가기능을 적용할 때 Advice라는 새로운 개념을 도입함
  • 개발자는 InvocationHandler(JDK 동적 프록시)나 MethodInterceptor(CGLIB)를 신경쓰지 않고 Advice만 만들면 됨
  • 프록시 팩토리를 사용하면 Advice를 호출하는 전용 InvocationHandler와 MethodInterceptor를 내부에서 사용하여 결과적으로 개발자가 만든 Advice를 호출하게 됨

Advice가 도입된 흐름

 

(3) 특정 조건에 맞을 때 프록시 로직을 적용하는 기능을 공통으로 제공

  • 앞서 특정 메서드 이름의 조건에 맞을 때만 프록시 부가 기능이 적용되는 코드를 필터를 통해 직접 만들었음
  • 스프링은 Pointcut이라는 개념을 도입해서 이 문제를 일관성 있게 해결함

2. 프록시 팩토리 - 예제

1) 예제1

(1) Advice 생성

  • Advice는 프록시에 적용하는 부가 기능 로직이며 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor의 개념을 추상화 한 것이므로 프록시 팩토리를 사용하면 둘 대신에 Advice를 사용하면 됨
  • Advice를 만드는 방법은 여러가지만 있지만 기본적인 방법으로는 아래의 인터페이스를 구현하면 됨

(2) 스프링이 제공하는 MethodInterceptor

  • org.aopalliance.intercept 패키지에 있는 MethodInterceptor를 구현하면 되며 CGLIB의 MethodInterceptor와 이름이 같기 때문에 패키지 이름에 주의해야함
  • MethodInterceptor -> Interceptor -> Advice 로 상속관계가 되어있기 때문에 해당 인터페이스를 구현하는 것을 Advice를 구현한 것과 같음
  • MethodInvocation 내부에는 다음 메서드를 호출하는 방법, 현재 프록시 객체 인스턴스, args, 메서드 정보 등이 포함되어있으며 기존에 파라미터로 제공되는 부분들이 이 안으로 모두 들어갔다고 생각하면 됨
package org.aopalliance.intercept;

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

 

(3) TimeAdvice - 실제 Advice 생성

  • test 하위의 common 패키지에 advice 패키지를 생성 후 작성
  • MethodInterceptor 인터페이스를 구현할 때 import 패키지 이름에 주의
  • 기존의 프록시코드와 거의 유사하지만 프록시에는 항상 target(실제 클래스의 정보)가 있어야한다고했는데 여기에서는 보이지 않음
  • invocation.proceed()를 호출하면 target클래스를 호출하고 그 결과를 받으며, 여기 안에 target 클래스의 정보가 모두 포함되어있음
  • 그 이유는 바로 다음에 확인할 수 있는데 프록시 팩토리로 프록시를 생성하는 단계에서 이미 target정보를 파라미터로 전달받기 때문임
package hello.proxy.common.advice;

import org.aopalliance.intercept.MethodInterceptor; // import 패키지에 주의

@Slf4j
public class TimeAdvice implements MethodInterceptor {

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

        // target을 직접 입력해주지 않아도 됨, invocation.proceed() 메서드에서 알아서 동작함
        Object result = invocation.proceed();

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

 

(4) ProxyFactoryTest 작성 및 실행결과

  • proxy하위에 proxyfactory 패키지를 만들어서 테스트 코드를 작성
  • new ProxyFactory(target) : 프록시 팩토리를 생성할 때 프록시의 호출 대성을 넘겨주고 이 인스턴스 정보를 기반으로 프록시를 만들어냄,
  • 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 생성하고 인터페이스 없이 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성함
  • 해당 예제에서는 target이 new ServiceImpl()의 인스턴스이기 때문에 ServiceInterface라는 인터페이스가 있으므로 JDK 동적 프록시를 생성함
  • proxyFactory.addAdvice(new TimeAdvice()) : 프록시 팩토리를 통해서 만든 프록시가 사용할 부가기능 로직을 설정함
  • proxyFactory.getProxy() : 프록시 객체를 생성하고 그 결과를 받음
  • 프록시 팩토리를 통해 프록시를 생성하면 AopUtils를 사용하여 편리하게 프록시의 여러가지 정보를 확인할 수 있으며 .getClass()처럼 인스턴스의 클래스 정보를 직접 출력해서 확인할 수도 있음
  • AopUtils.isAopProxy() : 프록시 팩토리를 통해 생성한 프록시이면 참
  • AopUtils.isJdkDynamicProxy() : 프록시 팩토리를 통해 생성한 프록시이며 JDK 동적 프록시인 경우 참
  • AopUtils.isCglibProxy() : 프록시 팩토리를 통해서 프록시가 생성되고 CGLIB 동적 프록시인 경우 참
  • 실행해보면 테스트로 적용한 Advice가 정상 동작하는 로그와 proxy.getClass에서 jdk.proxy3.$proxy13으로 jdk 동적 프록시가 적용되어 프록시가 생성된 로그를 확인할 수 있음
  • 프록시 팩토리로 생성한 프록시이기에 AopUtils를 활용한 테스트코드들도 모두 통과함
package hello.proxy.proxyfactory;

@Slf4j
public class ProxyFactoryTest {

    @Test
    @DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
    void interfaceProxy() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);   // ProxyFactory에 target을 입력하여 생성
        proxyFactory.addAdvice(new TimeAdvice());               // proxyFactory에 advice를 입력
        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();    // proxy꺼내기
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        proxy.save();
        proxy.find();
        
        // 프록시 팩토리에서 만든 프록시는 AopUtils를 사용하여 프록시의 다양한 정보를 확인할 수 있음
        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
    }
}
/*
13:56:31.592 [Test worker] INFO hello.proxy.proxyfactory.ProxyFactoryTest -- targetClass=class hello.proxy.common.service.ServiceImpl
13:56:31.594 [Test worker] INFO hello.proxy.proxyfactory.ProxyFactoryTest -- proxyClass=class jdk.proxy3.$Proxy13
13:56:31.597 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 실행
13:56:31.598 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- save 호출
13:56:31.598 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=1
13:56:31.598 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 실행
13:56:31.598 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- find 호출
13:56:31.598 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0

*/

2) 예제2

(1) ProxyFactoryTest - concreteProxy 추가

  • 구체 클래스만 있는 ConcreteService에 프록시 팩토리를 적용
  • 프록시 팩토리는 인터페이스 없이 구체 클래스만 있으면 CGLIB를 사용해서 프록시를 적용함
  • 실행 결과 로그를 보면 생성된 프록시가 SpringCGLIB로 동작하는 것을 확인할 수 있으며 테스트 코드에서도 isCglibProxy()가 true로 통과됨
@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();

    // 프록시 팩토리에서 만든 프록시는 AopUtils를 사용하여 프록시의 다양한 정보를 확인할 수 있음
    assertThat(AopUtils.isAopProxy(proxy)).isTrue();
    assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
    assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
/*
14:30:13.842 [Test worker] INFO hello.proxy.proxyfactory.ProxyFactoryTest -- targetClass=class hello.proxy.common.service.ConcreteService
14:30:13.844 [Test worker] INFO hello.proxy.proxyfactory.ProxyFactoryTest -- proxyClass=class hello.proxy.common.service.ConcreteService$$SpringCGLIB$$0
14:30:13.846 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 실행
14:30:13.846 [Test worker] INFO hello.proxy.common.service.ConcreteService -- ConcreteService 호출
14:30:13.846 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
*/

 

(2) ProxyFactoryTest - proxyTargetClass 추가

  • 인터페이스가 있어도 CGLIB를 사용해서 인터페이스가 아닌 클래스 기반으로 동적 프록시를 생성할 수 있음
  • 프록시팩토리가 제공하는 옵션 중 .setProxyTargetClass()메서드에 true를 입력하면 인터페이스가 있어도 강제로 CGLIB를 사용하여 클래스 기반의 프록시를 만들어줌
  • 실행 결과를 보면 인터페이스가 있는 인스턴스임에도 SpringCGLIB로 프록시가 생성되고 isCglibProxy()메서드도 true로 동작하여 테스트가 통과되는 것을 확인할 수 있음
  • 해당 옵션은 실무에서 종종 나오기때문에 꼭 알고 있어야함
@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고 클래스 기반 프록시를 사용")
void proxyTargetClass() {
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.setProxyTargetClass(true);     // 인터페이스가 아닌 TargetClass를 기반으로 프록시를 만들도록 설정
    proxyFactory.addAdvice(new TimeAdvice());
    ServiceInterface proxy = (ServiceInterface) 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();
}
/*
14:32:55.696 [Test worker] INFO hello.proxy.proxyfactory.ProxyFactoryTest -- targetClass=class hello.proxy.common.service.ServiceImpl
14:32:55.698 [Test worker] INFO hello.proxy.proxyfactory.ProxyFactoryTest -- proxyClass=class hello.proxy.common.service.ServiceImpl$$SpringCGLIB$$0
14:32:55.699 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 실행
14:32:55.699 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- save 호출
14:32:55.700 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=1
*/

 

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

  • 대상에 인터페이스가 있으면 JDK 동적 프록시 - 인터페이스 기반 프록시로 생성
  • 대상에 인터페이스가 없으면 CGLIB - 구체 클래스 기반 프록시로 생성
  • proxyTargetClass(true) 옵션을 사용하면 인터페이스 여부와 상관없이 CGLIB - 구체 클래스 기반 프록시로 생성

(4) 정리

  • 프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고 매우 편리하게 동적 프록시를 생성할 수 있음
  • 프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게 Advice 하나로 편리하게 사용할 수 있음

** 참고

  • 스프링부트 2.x 부터 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용하기에 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성함
  • 자세한 이유는 강의 뒷부분에서 설명

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

1) 용어 정리

(1) 포인트컷(Pointcut)

  • 어디에 부가 기능을 적용할 것인지, 적용하지 않을 것인지 판단하는 필터링 로직
  • 주로 클래스와 메서드 이름으로 필터링하며 이름 그대로 어떤 포인트(Point)에 기능을 적용할지, 하지 않을지 잘라서(cut) 구분하는 것이라고 이해하면 됨

(2) 어드바이스(Advice)

  • 프록시가 호출하는 부가 기능이며 단순히 프록시 로직이라고 생각하면 됨

(3) 어드바이저(Advisor)

  • 하나의 포인트컷과 하나의 어드바이스를 가지고 있음

(4) 역할과 책임

  • 이렇게 구분한 것은 역할과 책임을 명확하게 분리한 것임
  • 포인트컷은 대상 여부를 확인하는 필터 역할만 담당하고 어드바이스는 깔끔하게 부가 기능 로직만 담당함
  • 이 둘을 합치면 어드바이저가되며 스프링의 어드바이저는 하나의 포인트컷과 하나의 어드바이스로 구성됨

** 참고

  • 해당 단어들에 대한 정의는 문맥상 이해를 돕기 위해 프록시에 맞추어서 설명되었음
  • 이후에 AOP 부분에서 다시한번 AOP에 맞춰서 정리할 예정임
  • 아래의 전체 구조는 이해를 돕기위한 것이며 실제 구현은 다를 수 있음

전체 구조 예시


4. 포인트컷, 어드바이스, 어드바이저 - 예제

1) 예제1 - 어드바이저

  • 어드바이저는 하나의 포인트컷과 하나의 어드바이스를 가지고 있으며 프록시 팩토리를 통해 프록시를 생성할 때 어드바이저를 제공하면 어디에 어떤 기능을 제공할 지 알 수 있음

(1) AdvisorTest 생성 및 실행

  • test하위에 advisor패키지를 생성 후 작성
  • new DefaultPointcutAdvisor : Advisor 인터페이스의 가장 일반적인 구현체, 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어주면되고 어드바이저는 하나의 포인트컷과 하나의 어드바이스로 구성됨
  • Pointcut.TRUE: 항상 true를 반환하는 포인트컷
  • new TimeAdvice( ) : 앞서 개발한 TimeAdvice 어드바이스를 적용
  • proxyFactory.addAdvisor(advisor) : 프록시 팩토리에 적용할 어드바이저를 지정
  • 위에서 지정한 어드바이저는 내부에 포인트컷과 어드바이스를 모두 가지고 있어 어디에 어떤 부가기능을 적용해야할 지 어드바이저 하나로 알 수 있으므로 프록시 팩토리를 사용할 때 어드바이저는 필수임
  • 이전의 프록시 팩토리를 사용해보는 예제에서는 proxyFactory.addAdvice()로 직접 어드바이스를 바로 적용했는데, 이것은 단순히 편의 메서드이고 addAdvice()의 내부에서 지금코드와 똑같은 어드바이저가 생성되도록 코드가 작성되어있음
  • 테스트 코드를 실행해보면 save, find 메서드 모두 TimeAdvice가 적용되어 정상적으로 동작하는 것을 확인할 수 있음
package hello.proxy.advisor;

public class AdvisorTest {

    @Test
    void advisorTest1() {
        ServiceInterface target = new ServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);

        // advisor 생성 - 포인트컷은 항상 참이고, 직접 정의한 TimeAdvice()를 어드바이스로 사용
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
        proxyFactory.addAdvisor(advisor);

        ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

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

2) 예제2 - 직접 만든 포인트 컷

  • save()메서드에는 어드바이스 로직을 적용하고 find()메서드에는 어드바이스 로직을 적용하지 않도록 포인트 컷을 직접 만들어서 적용
  • 물론 어드바이스에 로직을 추가하여 메서드 이름을 보고 코드를 실행할지 말지 if문 등을 활용할 수 있지만 재사용성이 떨어지고 단일책임원칙에 위배가 되기 때문에 이런 기능에 특화된 포인트 컷을 사용하는 것이 좋음

(1) 스프링이 제공하는 Pointcut 관련 인터페이스들

  • 포인트 컷은 크게 클래스가 맞는지 확인하는 ClassFilter와 메서드가 맞는지 확인하는 MethodMatcher 둘로 이루어지며 둘 다 true로 반환해야 어드바이스를 적용할 수 있음
  • 일반적으로는 스프링이 만들어둔 구현체를 사용하며 개념 학습 차원에서 간단히 직접 구현해보는 실습을 진행
public interface Pointcut {
    ClassFilter getClassFilter();
    MethodMatcher getMethodMatcher();
}

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

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

 

(2) AdvisorTest에 advisorTest2 추가

  • 기존 로직은 똑같으며 new DefaultPointcutAdvisor()를 생성할 때 직접 만든 MyPointcut을 생성하여 사용하도록 입력

(3) MyPointcut

  • 직접 구현한 포인트컷으로 Pointcut 인터페이스를 구현
  • 실제 객체의 메서드를 기준으로 로직을 적용하면 되며 클래스 필터는 항상 True를 반환하도록하고 메서드 비교 기능은 직접 구현한 MyMethodMatcher를 사용하도록 설정

(4) MyMethodMatcher

  • MethodMatcher 인터페이스를 구현하여 메소드 비교기능을 직접 정의
  • matches() : 이 메서드에 method, targetClass 정보가 넘어오고 이 정보로 어드바이스를 적용할지 말지 판단할 수 있음
  • 해당 로직에서는 이름이 save인 경우에 true를 반환하도록 판단 로직을 적용함

** 중요하지 않은 부분

  • isRuntime()과 matches(Method method, Class<?> targetClass, Object... args) : isRuntime값이 true일 때 매개변수가 3개인 matches 메서드가 대신 호출되며 동적으로 넘어오는 매개변수를 판단 로직으로 사용할 수 있음
  • isRuntime()이 false인 경우 클래스의 정적 정보만 사용하기 때문에 스프링이 내부에서 캐싱을 통해 성능 향상이 가능하지만 true인 경우에는 매개변수가 동적으로 변경된다고 가정하기 때문에 캐싱을 하지 않도록 동작함
  • 크게 중요하지 부분은 아니므로 참고만 하고 넘어가면 됨

(5) 실행결과

  • 실행해보면 save()를 호출할 때는 어드바이스가 적용되어 TimePorxy가 실행되었지만 find()를 호출할 때는 포인트컷 결과가 result=false로 동작하여 TimeProxy가 동작하지 않음을 로그로 확인할 수 있음
@Test
@DisplayName("직접 만든 포인트 컷")
void advisorTest2() {
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);

    // 직접 구현한 MyPointcut()을 사용하여 advisor를 생성
    DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice());
    proxyFactory.addAdvisor(advisor);

    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

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

static class MyPointcut implements Pointcut {

    @Override
    public ClassFilter getClassFilter() {       // 클래스 확인로직 구현
        return ClassFilter.TRUE;    // 클래스는 항상 true
    }

    @Override
    public MethodMatcher getMethodMatcher() {   // 메서드 확인로직 구현
        return new MyMethodMatcher(); // 직접 구현한 메서드 확인 로직을 생성하여 반환
    }
}

// 메서드 이름이 save일때만 true로 반환하도록 구현
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;
    }
}
/*
16:31:01.526 [Test worker] INFO hello.proxy.advisor.AdvisorTest -- 포인트컷 호출 method=save targetClass=class hello.proxy.common.service.ServiceImpl
16:31:01.528 [Test worker] INFO hello.proxy.advisor.AdvisorTest -- 포인트컷 결과 result=true
16:31:01.530 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 실행
16:31:01.530 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- save 호출
16:31:01.530 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
16:31:01.530 [Test worker] INFO hello.proxy.advisor.AdvisorTest -- 포인트컷 호출 method=find targetClass=class hello.proxy.common.service.ServiceImpl
16:31:01.530 [Test worker] INFO hello.proxy.advisor.AdvisorTest -- 포인트컷 결과 result=false
16:31:01.530 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- find 호출
*/

 

(6) 그림으로 확인

  • save()메서드는 Pointcut에서 Advice의 적용여부를 확인하고 true가 반환되어 프록시 Advice를 적용하여 부가기능을 적용한 후 실제 인스턴스의 save()를 호출함
  • 하지만 find()메서드는 Pointcut에서 Advice의 적용여부를 확인할 때 false가 반환되어 Advice를 적용하지 않고 바로 find()를 호출하여 부가기능이 적용되지 않은 채 실제 인스턴스의 find()를 호출함

좌) 포인트컷이 적용되는 save() / 우) 포인트컷이 적용되지 않은 find()

3) 예제3 - 스프링이 제공하는 포인트 컷

  • 스프링은 우리가 필요한 포인트컷을 이미 대부분 제공함
  • 스프링이 제공하는 NameMatchMethodPointcut을 사용해서 구현해보기(가장 쉬운 포인트컷)

(1) advisorTest3 추가

  • new NameMatchMethodPointcut를 생성하여 setMappedName() 메서드에 통과할 메서드의 이름을 지정하면 포인트컷이 간단하게 완성됨
  • 출력 결과를 보면 save()메서드는 TimeProxy가 적용되었지만 find()에는 적용되지 않은것을 확인할 수 있음
@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();
}
/* 실행 결과
16:53:07.039 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 실행
16:53:07.041 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- save 호출
16:53:07.041 [Test worker] INFO hello.proxy.common.advice.TimeAdvice -- TimeProxy 종료 resultTime=0
16:53:07.042 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- find 호출
*/

 

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

  • 무수히 많은 포인트컷을 제공하는 스프링의 대표적인 포인트컷
  • NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭하며 내부에서는 PatternMatchUtils를 사용함(예시 - *xxx* 허용)
  • JdkRegexMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭
  • TruePointcut : 항상 참을 반환
  • AnnotationMatchingPointcut : 애노테이션으로 매칭
  • AspectJExpressionPointcut : aspectJ 표현식으로 매칭

(3) 가장 중요한 것은 aspectJ 표현식

  • 사실 다른것은 중요하지 않고 실무에서는 사용하기도 편리하고 기능도 가장 많은 aspectJ 표현식을 기반으로 사용하는 AspectJExpresstionPointcut을 사용함
  • AOP를 설명할 때 aspectJ 표현식과 사용방법을 다시 설명할 예정이므로 지금은 Pointcut의 동작 방식과 전체 구조를 이해하는 것에 초점을 맞춤

4) 예제4 - 여러 어드바이저와 함께 적용

  • 여러 어드바이저를 하나의 target에 적용하는 방법을 알아보는 예제
  • 즉, 하나의 target에 여러 어드바이스를 적용하는 방법을 예제로 확인해보기

(1-1) 여러 프록시를 만들어서 적용 - MultiAdvisorTest 생성

  • target을 입력받아 생성된 프록시와, 프록시를 입력받아 생성된 프록시 2개의 프록시를 생성하여 각각의 어드바이스를 적용시키는 방식으로 여러 프록시를 적용
  • 그림으로 보면 프록시를 생성할 때 이전 프록시를 입력하면서 프록시를 생성하여 마지막으로 실제 target의 로직을 호출하는 프록시는 전체 부가기능이 적용된 프록시가 되어 로직을 호출하게 됨
  • 포인트컷을 항상 true가 반횐되도록 적용하여 어드바이스가 항상 적용되도록 설정 후 실행해보면 요구사항에 맞게 여러 어드바이저를 함께 사용하여 1개의 target에 여러 어드바이스가 적용되었음
package hello.proxy.advisor;

public class MultiAdvisorTest {

    @Test
    @DisplayName("여러 프록시")
    void multiAdvisorTest1() {
        // client -> proxy2(advisor2) -> proxy1(advisor1) -> target 구조

        // 프록시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);    // target이 아닌 생성한 프록시를 입력하여 프록시 팩토리를 생성

        DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
        proxyFactory2.addAdvisor(advisor2);
        ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();

        proxy2.save();

    }

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

    @Slf4j
    static class Advice2 implements MethodInterceptor {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            log.info("advice2 호출");
            return invocation.proceed();
        }
    }
}
/*
17:53:47.793 [Test worker] INFO hello.proxy.advisor.MultiAdvisorTest$Advice2 -- advice2 호출
17:53:47.795 [Test worker] INFO hello.proxy.advisor.MultiAdvisorTest$Advice1 -- advice1 호출
17:53:47.795 [Test worker] INFO hello.proxy.common.service.ServiceImpl -- save 호출
*/

 

(1-2) 여러 프록시의 문제

  • 이 방법이 잘못된 것은 아니지만 프록시를 2번 생성해야 한다는 문제가 있음
  • 즉, 적용해야하는 어드바이저가 100개 1000개면 그에 대응하는 프록시를 똑같이 생성해야 한다는 문제가 발생함

(2-1) 하나의 프록시, 여러 어드바이저 적용

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

구조 설명, 하나의 프록시에 여러 어드바이저를 등록할 수 있음

 

(2-2) MultiAdvisorTest - multiAdvisorTest2() 추가

  • 하나의 프록시에 여러 어드바이저를 addAdvisor로 입력할 수 있으며 입력한 순서대로 로직이 동작하므로 원하는 어드바이저의 순서대로 입력해 주면 됨
  • 테스트 코드를 작성하면 똑같이 정상적으로 2개의 어드바이저가 동작하며 결과적으로 여러 프록시를 사용할 때와 비교해서 결과는 같고 성능은 더 좋음
@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
void multiAdvisorTest2() {
    // client -> proxy -> advisor2 -> advisor1 -> target

    // 어드바이저 생성
    DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
    DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());

    // 프록시 생성
    ServiceInterface target = new ServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);

    proxyFactory.addAdvisor(advisor2);
    proxyFactory.addAdvisor(advisor1);
    ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();

    // 실행
    proxy.save();
}

 

** 중요

  • 매우 간단하게 설명할 수 있는 부분을 이렇게 풀어서 설명한 이유는 스프링의 AOP를 처음 공부하거나 처음 사용하면 AOP 적용 수 만큼 프록시가 생성된다고 착각하는 사람이 많다고 하며 실무 개발자들도 이렇게 생각하는 경우가 많다고 함(이제는 김영한님 강의 덕분에 안그럴듯)
  • 스프링은 AOP를 적용할 때 최적화를 진행해서 지금처럼 프록시는 하나만 만들고 하나의 프록시에 여러 어드바이저를 적용함
  • 즉, 하나의 target에 여러 AOP가 동시에 적용되어도 스프링의 AOP는 target마다 하나의 프록시만 생성한다는 것을 꼭 기억할 것

5. 프록시 팩토리 - 적용

1) 인터페이스가 있는 v1에 적용

  • 프록시 팩토리를 사용해서 인터페이스가 있는 v1 애플리케이션에 프록시를 생성하여 적용

(1) LogTraceAdvice

  • main의 proxy.config 하위 패키지에 v3_proxyfactory.advice 패키지를 만들어서 생성
  • 실제 target정보는 invocation에 들어가있으므로 적용할 LogTrace만 생성자로 만들어주면 됨
  • method의 정보를 invocation에서 꺼내서 message를 만들 수 있으며 invocation.proceed()로 실제 비즈니스 로직을 동작시키도록 작성
package hello.proxy.config.v3_proxyfactory.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(); // invocation에서 method 정보를 꺼낼 수 있음
            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) ProxyFactoryConfigV1 - 빈 등록 설정

  • getAdvisor(): 여러 계층에서 어드바이저를 편하게 입력받을 수 있도록 메서드를 생성
  • 포인트 컷은 NameMatchMethodPointcut에는 심플 매칭 기능이 있어서 *을 매칭할 수 있는데, no-log메서드는 어드바이스가 동작하지 않도록 request*, order*, save*로 setMappedNames()를 설정하면 request, order, save로 시작하는 메서드에는 포인트컷이 true를 반환함
  • 어드바이저에는 앞서 생성한 포인트컷과 LogTraceAdvice를 가지고 있으며 반환값은 new DefaultPointcutAdvisor()로 어드바이저를 생성하여 반환
  • 프록시 팩토리에 각각의 target과 advisor를 등록하여 프록시를 생성하여 스프링빈으로 등록함
package hello.proxy.config.v3_proxyfactory;

@Slf4j
@Configuration
public class ProxyFactoryConfigV1 {

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
        ProxyFactory factory = new ProxyFactory(orderController);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderControllerV1 proxy = (OrderControllerV1) factory.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 factory = new ProxyFactory(orderService);
        factory.addAdvisor(getAdvisor(logTrace));
        OrderServiceV1 proxy = (OrderServiceV1) factory.getProxy();
        log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderService.getClass());
        return proxy;
    }

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

    // 여러곳에서 advisor를 입력해야해서 메서드로 추출
    private Advisor getAdvisor(LogTrace logTrace) {
        // pointcut
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "order*", "save*"); // 가변인자로 적용할 포인트컷 이름을 입력할 수 있음

        // advice
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);
        return new DefaultPointcutAdvisor(pointcut, advice);    // advisor 반환
    }

}

 

(3) ProxyApplication 수정 및 실행

  • @Import로 ProxyFactoryConfigV1을 등록하면 요구사항대로 Logtrace가 정상 동작하며, 로그를 확인해보면 JDK 동적 프록시가 생성되어 동작하는 것을 확인할 수 있음
@Import(ProxyFactoryConfigV1.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app.v3") //주의
public class ProxyApplication {
    // 기존 코드 생략
}

jdk 동적 프록시로 동작된 로그

2) 구체 클래스만 있는 v2에 적용

  • 인터페이스가 없고 구체 클래스만 있는 v2 애플리케이션에 LogTrace기능을 프록시 팩토리를 통해서 적용
  • 어드바이스는 전에 만든 LogTraceAdvice를 사용

(1) ProxyFactoryConfigV2

  • ProxyFactory가 알아서 동적 프록시를 생성해주기 때문에 코드의 로직은 전부 동일함
  • 각 계층의 target만 V2의 Controller, Service, Repository로만 변경해주면 됨
package hello.proxy.config.v3_proxyfactory;

@Slf4j
@Configuration
public class ProxyFactoryConfigV2 {

    @Bean
    public OrderControllerV2 OrderControllerV2(LogTrace logTrace) {
        // ProxyFacotry를 생성하는 로직은 동일, OrderConrollerV2로만 변경
    }

    @Bean
    public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
        // ProxyFacotry를 생성하는 로직은 동일, OrderServiceV2 변경

    }

    @Bean
    public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
        // ProxyFacotry를 생성하는 로직은 동일, OrderRepositoryV2 변경
    }

    // getAdvisor()메서드 로직 동일

}

 

(2) ProxyApplication 수정 및 실행

  • @Import로 ProxyFactoryConfigV2를 입력 후 애플리케이션을 실행해보면 정상적으로 모두 동작하고 CGLIB로 프록시가 동작하는 로그를 확인할 수 있음
@Import(ProxyFactoryConfigV2.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app.v3") //주의
public class ProxyApplication {
    // 기존 코드 생략
}

CGLIB로 프록시가 동작하는 로그

 

3) 남은 문제

  • 프록시 팩토리와 어드바이저 같은 개념 덕분에 지금까지 고민했던 문제들을 해결되었음
  • 프록시도 깔끔하게 적용하고 포인트컷으로 부가 기능을 적용할지도 명확하게 정의할 수 있었으며 원본 코드를 전혀 손대지 않고 프록시를 통해 부가 기능도 적용할 수 있었음

(1) 문제1 - 너무 많은 설정

  • 그러나 설정 파일이 지나치게 너무 많으며 설정파일의 로직도 보면 중복 코드가 상당히 많음
  • 애플리케이션에 스프링 빈이 100개가 있으면 여기에 프록시를 통해 부가 기능을 적용하려고 할 때 100개의 동적 프록시 생성 코드를 만들어야 하기에 설정 지옥을 경험하게 될 수 있음
  • 최근에는 스프링 빈을 등록하지 않고 컴포넌트 스캔을 사용하는 경우가 많은데 직접 스프링빈을 등록하는것에 더해 프록시를 적용하는 코드까지 빈 생성 코드에 넣어야하는 번거로움이 있음

(2) 문제2 - 컴포넌트 스캔

  • 애플리케이션 V3처럼 컴포넌트 스캔을 사용하는 경우 지금까지 학습한 방법으로는 프록시 적용이 불가능함
  • 지금까지 학습한 방법으로 프록시를 적용하려면 실제 객체가 아니라 부가 기능이 있는 프록시를 실제 객체 대신 스프링 컨테이너에 빈으로 등록해야 함
  • 그러나 컴포넌트 스캔을 사용하면 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 전부 등록해 버린 상태이기 때문에 적용이 불가능함

** 이 두가지 문제를 한번에 해결하는 방법이 바로 다음에 설명할 빈 후처리기임