• 포인트컷(Pointcut) : 어디에 부가 기능을 적용할지, 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직. 주로 클래스와 메서드 이름으로 필터링한다. 이름 그대로 어떤 포인트(Point)에 기능을 적용할지 하지 않을지 잘라서(cut)구분하는 것이다.
• 어드바이스(Advice) : 이전에 본 것처럼 프록시가 호출하는 부가 기능이다. 단순하게 프록시 로직이라 생각하면 된다.
• 어드바이저(Advisor) : 단순하게 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것이다. 쉽게 말하면 포인트컷1 + 어드바이스1이 다.
이렇게 포인트컷, 어드바이스, 어드바이저를 구분한 것은 역할과 책임을 명확하게 분리하는 것이다.
포인트컷은 대상 여부를 확인하는 필터 역할만 담당하고,
어드바이스는 깔끔하게 부가 기능 로직만 담당하고,
이 둘을 합치면 어드바이저가 된다. 스프링의 어드바이저는 하나의 포인트컷 + 하나의 어드바이스로 구성된다.
구조를 그림으로 간단하게 살펴보자.
이제 어드바이저를 코드로 살펴보자.
(그전에 아래의 더보기는 테스트에 사용되는 클래스들이다.)
테스트에 사용될 서비스로직인 ServiceInterface와 ServiceImpl, 그리고 Advice역할을 하는 TimeAdvice 코드는 아래와 같다.
ServiceInterface
public interface ServiceInterface {
void save();
void find();
}
ServiceImpl
@Slf4j
public class ServiceImpl implements ServiceInterface {
@Override
public void save() {
log.info("save 호출");
}
@Override
public void find() {
log.info("find 호출");
}
}
TimeAdvice
@Slf4j
public class TimeAdvice implements MethodInterceptor {
//MethodInvocation에 target 클래스 정보가 담겨있다.
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("Time Proxy 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed();//invocation.proceed()를 호출하면 알아서 target을 찾아 호출해준다.
long endTime = System.currentTimeMillis();
log.info("TimeProxy 종료 resultTime = {}", endTime-startTime);
return result;
}
}
Advisor를 테스트하는 코드는 아래와 같다.
@Test
void advisorTest1() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
// 첫번째 인자(Pointcut) : Pointcut.TRUE -> 모든 포인트를 통과시키는 포인트 컷
// 두번째 인자(Advice) : new TimeAdvice() -> 시간 추적 어드바이스
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
// advisor 추가
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
실행결과:
Time Proxy 실행
save 호출
TimeProxy 종료 resultTime = 1
Time Proxy 실행
find 호출
TimeProxy 종료 resultTime = 0
• new DefaultPointcutAdvisor : Advisor 인터페이스의 가장 일반적인 구현체. 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어주면 된다. 어드바이저는 하나의 포인트컷과 하나의 어드바이스로 구성된다.
• Pointcut.TRUE : 항상 true 를 반환하는 포인트컷이다. 이후에 직접 포인트컷을 구현해볼 것이다.
• new TimeAdvice() : 앞서 개발한 TimeAdvice 어드바이스를 제공한다.
• proxyFactory.addAdvisor(advisor) : 프록시 팩토리에 적용할 어드바이저를 지정한다. 어드바이저는 내부에 포인트컷과 어드바이스를 모두 가지고 있다. 따라서 어디에 어떤 부가 기능을 적용해야 할지 어드바이스 하나로 알 수 있다. 프록시 팩토리를 사용할 때 어드바이저는 필수이다.
• 그런데 생각해보면 이전에 분명히 proxyFactory.addAdvice(new TimeAdvice()) 이렇게 어드바이저가 아니라 어드바이스를 바로 적용했다. 이것은 단순히 편의 메서드이고 결과적으로 해당 메서드 내부에서 지금 코드와 똑같은 다음 어드바이저가 생성된다.
DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice())
proxyFactory.addAdvisor 메서드의 인자로 advisor를 넣어주면 프록시 팩토리와 어드바이저의 관계는 아래와 같다.
위의 테스트에서는 포인트컷을 언제나 true로 하여 무조건 부가기능(어드바이스, TimeAdvice) 로직을 수행하도록 하였는데, 이번에는 포인트컷을 직접 구현하여 특정 경우에는 부가기능 로직을 건너뛰도록 해보자.
save메서드에서는 어드바이스를 적용하고, find메서드에서는 어드바이스를 적용하지 않을 것이다.
스프링은 아래와 같은 포인트컷 인터페이스를 제공한다.
package org.springframework.aop;
/**
* Core Spring pointcut abstraction.
*
* <p>A pointcut is composed of a {@link ClassFilter} and a {@link MethodMatcher}.
* Both these basic terms and a Pointcut itself can be combined to build up combinations
* (e.g. through {@link org.springframework.aop.support.ComposablePointcut}).
*/
public interface Pointcut {
ClassFilter getClassFilter();
MethodMatcher getMethodMatcher();
Pointcut TRUE = TruePointcut.INSTANCE;
}
포인트컷 인터페이스는 크게 ClassFilter와 MethodMatcher 둘로 이루어진다. 이름 그대로 ClassFilter는 클래스가 맞는지, MethodMatcher는 메서드가 맞는지 확인할 때 사용된다. 둘 다 true로 반환되어야 어드바이스 로직이 적용될 수 있는 것이다.
일반적으로는 스프링이 제공하는 구현체를 사용하지만, 학습을 위해 포인트컷을 직접 구현하여 만들어 보자.
@Slf4j
public class AdvisorTest {
@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();
}
//포인트컷 직접 구현
static class MyPointcut implements Pointcut{
@Override//클래스는 언제나 true
public ClassFilter getClassFilter() {
return ClassFilter.TRUE;
}
@Override//메서드는 직접 구현한 MyMethodMatcher에 따라
public MethodMatcher getMethodMatcher() {
return new MyMethodMatcher();
}
}
//메서드필터링 조건을 matches의 메서드에 구현하면 된다.
static class MyMethodMatcher implements MethodMatcher{
private final String matchName = "save";
@Override
public boolean matches(Method method, Class<?> targetClass) {
boolean result = method.getName().equals(matchName);//"save"일 때만 true
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;
}
}
}
실행결과:
포인트 컷 호출 method = save, targetClass = class hello.proxy.common.ServiceImpl
포인트 컷의 결과 = true
Time Proxy 실행
save 호출
TimeProxy 종료 resultTime = 0
포인트 컷 호출 method = find, targetClass = class hello.proxy.common.ServiceImpl
포인트 컷의 결과 = false
find 호출
실행결과를 보면 find가 실행되었을 때는 프록시 어드바이스가 호출되지 않은 것을 볼 수 있다.
save가 호출된 경우 :
find가 호출된 경우 :
이제 스프링이 제공하는 포인트컷을 사용해보자.
NamedMatchMethodPointcut객체를 생성하여 어드바이스를 적용할 메서드 이름을 넣으면 된다.
@Test
@DisplayName("스프링이 제공하는 포인트컷")
void advisorTest3() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
// --- NameMatchMethodPointcut --
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("save");//메서드 이름이 save인 경우에만 어드바이스 적용
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
// -------------------------------
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
실행결과:
Time Proxy 실행
save 호출
TimeProxy 종료 resultTime = 0
find 호출
• NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils 를 사용한다. 예) *xxx* 허용
• JdkRegexpMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.
• TruePointcut : 항상 참을 반환한다.
• AnnotationMatchingPointcut : 애노테이션으로 매칭한다.
• AspectJExpressionPointcut : aspectJ 표현식으로 매칭한다.
가장 중요한 것은 AspectJExpressionPointcut이다. 실무에서는 AspectJExpressionPointcut가 사용하기도 편리하고 기능도 가장 많기 때문이다. (이후에 학습)
지금까지의 어드바이저는 하나의 포인트컷과 하나의 어드바이스만을 가지고 있었다.
이번에는 하나의 target에 여러 어드바이저를 사용하는 것을 예제 코드로 학습해보자.
방법1. 프록시를 여러 개 만들기.
단순히 프록시를 여러 개 만들어 클라이언트가 프록시2를 호출하면 프록시2는 프록시1을 호출하고 프록시1은 target을 호출하도록하면 될 것이다. 이와 같은 방법으로 클래스를 만들고 테스트해보자.
public class MultiAdvisorTest {
@Test
@DisplayName("여러 프록시")
void multiAdvisorTest1() {
//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 생성, target --> proxy1 입력
ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
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();
}
}
}
실행결과:
advice2 호출
advice1 호출
save 호출
하지만 위의 코드의 단점은 클라이언트가 프록시를 반복해서 생성해야 한다는 것이다. 즉 10개의 어드바이스를 적용한다면 10개의 프록시를 만들어야 한다.
하여 프록시는 하나만 만들고 어드바이저만 추가하도록해보자.
방법 2. 여러 개의 어드바이저를 하나의 프록시에.
@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
void multiAdvisorTest2() {
/*client --> proxy --> advisor2 --> advisor1 --> target*/
// 여러 advisor 생성
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);
// advisor 추가
proxyFactory.addAdvisor(advisor2);
proxyFactory.addAdvisor(advisor1);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
// 실행
proxy.save();
}
실행결과:
advice2 호출
advice1 호출
save 호출
아래의 그림과 같다.
이렇게 하나의 프록시를 사용하는 것이 당연히 성능이 더 좋다.
참고
스프링도 위의 방법처럼 AOP를 적용할 때, 최적화를 진행해서 프록시는 하나만 만들고 하나의 프록시에 여러 어드바이저를 적용한다.
정리하면 하나의 'target'에 여러 AOP가 동시에 적용되어도, 스프링의 AOP는 'target'마다 하나의 프록시만 생성한다.
직접 어플리케이션에 적용하기
이제 지금까지 학습한 스프링이 제공하는 프록시 팩토리를 V1 App에 적용해보자. 즉 로그 추적기를 주문 서비스에 적용해보자.
먼저 아래와 같이 Advice를 만든다.
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import java.lang.reflect.Method;
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(message);
Object result = invocation.proceed();
logTrace.end(status);
return result;
}catch(Exception e){
logTrace.exception(status, e);
throw e;
}
}
}
이를 사용하여 의존관계를 조립해보자.
@Slf4j
@Configuration
public class ProxyFactoryConfigV1 {
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace){
OrderControllerV1Impl orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderController);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) proxyFactory.getProxy();
log.info("proxyFactory = {}, target = {}", proxyFactory.getClass(), orderController);
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace){
OrderServiceV1Impl orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderService);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderServiceV1 proxy = (OrderServiceV1) proxyFactory.getProxy();
log.info("proxyFactory = {}, target = {}", proxyFactory.getClass(), orderService);
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1Impl orderRepository = new OrderRepositoryV1Impl();
ProxyFactory proxyFactory = new ProxyFactory(orderRepository);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderRepositoryV1 proxy = (OrderRepositoryV1) proxyFactory.getProxy();
log.info("proxyFactory = {}, target = {}", proxyFactory.getClass(), orderRepository);
return proxy;
}
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);
}
}
아래와 같은 요청을 날려보았다.
http://localhost:8080/v1/request?itemId=ppo
실행결과:
//의존관계 주입코드에서 작성한 Log가 출력됨
proxyFactory = class org.springframework.aop.framework.ProxyFactory, target = hello.proxy.app.v1.OrderRepositoryV1Impl@4f6b687e
proxyFactory = class org.springframework.aop.framework.ProxyFactory, target = hello.proxy.app.v1.OrderServiceV1Impl@73d4066e
proxyFactory = class org.springframework.aop.framework.ProxyFactory, target = hello.proxy.app.v1.OrderControllerV1Impl@22a0d4ea
OrderControllerV1.request()
|-->OrderServiceV1.orderItem()
| |-->OrderRepositoryV1.save()
| |<--OrderRepositoryV1.save() time=1003ms
|<--OrderServiceV1.orderItem() time=1004ms
OrderControllerV1.request() time=1005ms
지금까지는 인터페이스가 존재하는 어플리케이션에 적용했다면 이번에는 인터페이스가 없는 어플리케이션에 적용해보자.
사실 스프링의 프록시 팩토리를 사용하였기 때문에 의존관계 조립부분의 V1을 V2의 구체클래스들로만 바꿔주면 된다.
@Slf4j
@Configuration
public class ProxyFactoryConfigV2 {
@Bean
public OrderControllerV2 orderControllerV2(LogTrace logTrace){
OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderController);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderControllerV2 proxy = (OrderControllerV2) proxyFactory.getProxy();
log.info("proxyFactory = {}, target = {}", proxyFactory.getClass(), orderController);
return proxy;
}
@Bean
public OrderServiceV2 orderServiceV2(LogTrace logTrace){
OrderServiceV2 orderService = new OrderServiceV2(orderRepositoryV2(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderService);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderServiceV2 proxy = (OrderServiceV2) proxyFactory.getProxy();
log.info("proxyFactory = {}, target = {}", proxyFactory.getClass(), orderService);
return proxy;
}
@Bean
public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
OrderRepositoryV2 orderRepository = new OrderRepositoryV2();
ProxyFactory proxyFactory = new ProxyFactory(orderRepository);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderRepositoryV2 proxy = (OrderRepositoryV2) proxyFactory.getProxy();
log.info("proxyFactory = {}, target = {}", proxyFactory.getClass(), orderRepository);
return proxy;
}
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);
}
}
남은 문제
프록시 팩토리와 어드바이저 같은 개념 덕분에 지금까지 고민했던 문제들은 해결되었다. 프록시도 깔끔하게 적용하고 포인트컷으로 어디에 부가 기능을 적용할지도 명확하게 정의할 수 있다. 원본 코드를 전혀 손대지 않고 프록시를 통해 부가 기능도 적용할 수 있었다.
그런데 아직 해결되지 않는 문제가 있다.
문제1 - 너무 많은 설정
바로 ProxyFactoryConfigV1 , ProxyFactoryConfigV2 와 같은 설정 파일이 지나치게 많다는 점이다. 예를 들어서 애플리케이션에 스프링 빈이 100개가 있다면 여기에 프록시를 통해 부가 기능을 적용하려면 100개의 동적 프록시 생성 코드를 만들어야 한다! 무수히 많은 설정 파일 때문에 설정 지옥을 경험하게 될 것이다.
최근에는 스프링 빈을 등록하기 귀찮아서 컴포넌트 스캔까지 사용하는데, 이렇게 직접 등록하는 것도 모자라서, 프록시를 적용하는 코드까지 빈 생성 코드에 넣어야 한다.
문제2 - 컴포넌트 스캔
애플리케이션 V3처럼 컴포넌트 스캔을 사용하는 경우 지금까지 학습한 방법으로는 프록시 적용이 불가능하다.
왜냐하면 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문이다.
지금까지 학습한 프록시를 적용하려면, 실제 객체를 스프링 컨테이너에 빈으로 등록하는 것이 아니라
ProxyFactoryConfigV1에서 한 것 처럼, 부가 기능이 있는 프록시를 실제 객체 대신 스프링 컨테이너에 빈으로 등록해야 한다.
두 가지 문제를 한번에 해결하는 방법인 빈 후처리기를 다음 포스트에서 학습해보자.
김영한님의 인프런 강의와 PDF를 바탕으로 정리하였습니다.
'Spring > spring AOP' 카테고리의 다른 글
스프링 코어 2 - 스프링이 제공하는 빈 후처리기 (0) | 2022.01.11 |
---|---|
스프링 코어 2 - 빈 후처리기 (0) | 2022.01.11 |
스프링 코어 2 - 스프링이 지원하는 프록시(프록시 팩토리) (0) | 2022.01.10 |
스프링 코어2 - CGLIB (0) | 2022.01.10 |
스프링 코어2 - JDK 동적 프록시 (0) | 2022.01.09 |