AOP 1편 [프록시 팩토리, 빈 후처리기]
인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야할까?에 대한 해답을 스프링이 제공해 준다. 이 두 동적 프록시를 통합해줄 ProxyFactory라는 기술을 이용하자.
1. 프록시 팩토리
위 사진과 같은 역할을 하기 위해 어떤 것이 필요할까? 단연 InvocationHandler와 MethodInterceptor의 공통 된 로직을 작성해야 할 것이다. 이 공통된 로직을 advice라 하고 이 advice를 적용할지 말지 결정하는 녀석을 point cut이라 한다. 이 point cut 과 advice의 조합을 advisor라고 부른다. 즉 전체 흐름은 아래와 같을 것이다.
백문이불여일타! 코드를 작성해보자.
1.1 advice 코드
advice는 MethodInterceptor를 구현한다. MethodInterceptor? CGLIB? 맞다 이름이 똑같다. 하지만 라이브러리가 다르다. CGLIB를 위한 MethodIntercpetor는 springframework의 cglib 라이브러리에 존재하고 advice를 위한 MethodInterceptor는 aopaliance라이브러리 밑에 있다.
@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;
}
}
어휴 이제 지긋지긋하다. 부가 기능을 작성하고 invocation.proceed로 핵심 기능을 불러낸다. MethodInvocation은 target클래스의 메서드를 호출한다. 어? 근데 기존에는 invocation.proceed 대신에
methodProxy.invoke(target, args);
method.invoke(target, args);
이런 식으로 target과 args들이 필요했는데 이젠 그냥 invocation.proceed()밖에 없다? 그 이유는 프록시 팩토리를 생성하는 단계에서 target을 파라미터로 전달 받기에 필요 없는 것이다. 그럼 프록시 팩토리를 생성하는 과정을 보자.
1.2 프록시 팩토리 생성
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface)proxyFactory.getProxy();
proxy.save();
첫 줄 부터 차례로 보자면,
ServiceImpl에 implements service 있는거 보고 동적 프록시 적용 한다.
ProxyFactory를 만들면서 target을 넘겨 받는다.
advice를 추가한다.
getProxy를 통해 proxy 객체를 생성한다.
proxy를 통해 로직을 실행한다.
구체 클래스일 경우 망설이지 말고 인터페이스 = new 구체 클래스; 이거를 그냥 구체 클래스 = new 구체 클래스 이렇게 하면 된다. 그담에 ProxyFactory 만들고 target 집어 넣고 addvice 추가하고,.. 똑같다.
만약 혹시 인터페이스 있는 구체 클래스에도 CGLIB를 적용하고 싶다면?!
proxyFactory.setProxyTargetClass(true);
위 코드를 사용하면 된다.
2. 포인트 컷
이제 하나의 어드바이스를 적용할지 말지를 결정해줄 포인트 컷에 대해 다루어 보자. 이제 proxyFactory에 어드바이저를 적용할 것인데 어드바이저는 어드바이스+포인트 컷이다. 그러하니 proxyFactory.addAdvisor(포인트컷, 어드바이스)와 같은 형식으로 진행 할 것이다.
그럼 일단 advisor를 구현한 코드를 살펴보자.
@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를 만들어 낸다. 이때 pointcut과 advice를 등록하고 Pointcut.True는 직관적으로도 모든 조건에 true를 리턴할, 즉 모든 프록시 객체에 timeAdvice를 적용하게끔 한다.
그럼 이제 본격적으로 pointcut을 만들어 보자.
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;
}
}
Pointcut의 인터페이스를 구현한 MyPointCut은 ClassFilter와 MethodMatcher를 구현해야 한다. ClassFilter는 클래스가 맞는지 해당 클래스의 메서드가 맞는지 확인할 때 사용한다. 둘다 true를 반환해야만 한다.
MethodMatcher는 위와 같이 3가지를 구현해야 하는데 기능은 두가지이다. static으로 매칭할 경우는 matches고 런타임에 동적으로 확인할 경우는 isRuntime을 true로 하고 matches를 구현하면 된다.
아무튼 중요한 것은 메서드 이름이 matchName과 같아야지만이 포인트 컷을 호출한 다는 것이다. 이전 코드를 보면 proxy.save와 proxy.find가 존재하는데 이번에는 우리가 만든 포인트컷을 적용하면 proxy.find만 동작할 것이다.
2.1 스프링이 제공하는 포인트 컷
위 코드 처럼 포인트 컷을 만들기 조금.. 귀찮을 수 있다. 그리고 기본적으로 AOP를 주로 사용하는 파트는 여러 메서드에 공통 로직을 적용하느냐 마느냐 이다 보니 메서드 이름을 가지고 판단하는 경우가 대부분이다. 그렇기에 스프링은 NameMatchMethodPointcut을 제공한다.
위 코드를 NameMatchMethodPointcut을 이용하면 아래 코드와 같을 것이다.
...target, proxyFactory 생성...
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedName("save");
...proxyFactory에 advisor 적용...
proxy.save();
proxy.find();
물론 다른 포인트 컷도 제공한다. 메서드 이름만 가지고 하기에는 불편할 것이 반드시 존재하기에!
NameMatchMethodPointcut
|
메서드 이름을 기반으로. 내부에 PatternMatchUtils가 존재 |
JdkRegexpMethodPointcut |
JDK 정규 표현식을 기반으로 포인트컷을 매칭한다
|
TruePointcut
|
항상 참 |
AnnotationMatchingPointcut
|
어노테이션으로 매칭 |
AspectJExpressionPointcut
|
aspectJ 표현식으로 매칭 |
실무에서는 마지막에 존재하는 AspectJ를 이용해서 사용한다고 한다. 이에 대해서는 다음 글에서 다룰 것이당.
2.2 여러 어드바이저 함께 적용
그렇다면 여러 어드바이저를 적용하려면 어떻게 해야할까? 음.. interceptor나 filter chain 처럼 여러개를 만들어서 순서를 등록할까? 아니다! 여러 어드바이저를 단 하나의 프록시 팩토리에 등록할 수 있다! 아래 코드 처럼.
void multiAdvisorTest2(){
//client -> proxy -> advisor2 -> advisor1 -> target
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());//모든 포인트 다 통과
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
//프록시 1 생성
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
proxyFactory1.addAdvisor(advisor2);
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy1 = (ServiceInterface)proxyFactory1.getProxy();
//실행
proxy1.save();
}
advisor 적용 순서는 addAdvisor를 먼저 적용한 순서이다. 즉, 이런 덕분에 프록시를 여러개 만들지 않아도 되니 성능이 더 좋을 수 밖에 없다.
3. 어드바이저 적용
우리의 요구사항인 로그 찍기를 잊지 않았죠? 이를 적용하기 위해서는 aopalliance의 MethodInterceptor를 구현해서 @Bean 객체에 프록시를 리턴하도록 하면 된다.
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;
}
}
}
당연히 다른 MethodInterceptor나 InvocationHandler와는 달리 target이 존재하지 않는다. 이미 알고 있으니깐!!
그럼 이제 Config 파일에 proxy를 리턴하게 하면 된다.
@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 OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace){
OrderRepositoryV1Impl 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;
}
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);
}
}
와 너무 좋다! 인터페이스의 유무와 상관 없이 어드바이저 덕분에 프록시를 일괄적으로 적용할 수 있다. 하지만 남은 문제가 있다. 그럼 모든 @Bean만들어서 위 Config 파일을 만들어야해? 라는 것. 컴포넌트 스캔 대상일 경우는 어떻게 적용할 것인데? 이러한 두 문제를 한방에 해결해 주는 것이 바로 빈 후처리기 이다.
3. 빈 후처리기
빈 후처리기는 정말정말 무지막지하다. 스프링 컨테이너에 등록되기 직전에 해당 빈을 수정할 수도 있고 아예 완전히 다른 객체를 올려버릴 수 있다.
빈 후처리기 과정을 아래 사진과 함께 보자.
1. 스프링 빈을 생성하고 2. 빈 후처리기에 전달한다. 3. 후처리 작업 이후. 4 스프링 컨테이너에 등록한다. 심지어 이름은 바뀌지 않고 빈 객체만 바뀌는 것이다. 마치 위 그림에서 빈 저장소에 빈 이름은 beanA, 빈 객체는 B 객체를 넣을 수 있다.
빈 후처리기는 BeanPostProcessor 인터페이스를 구현해야 한다. BeanPostProcessor는 아래와 같다.
public interface BeanPostProcessor {
Object postProcessBeforeInitialization(Object bean, String beanName) throws
BeansException
Object postProcessAfterInitialization(Object bean, String beanName) throws
BeansException
}
그럼 이제 코드를 보자.
@Slf4j
@Configuration
static class BeanPostProcessorConfig {
@Bean(name="beanA")
public A a(){
return new A();
}
@Bean
public AToBPostProcessor helloPostProcessor(){
return new AToBPostProcessor();
}
}
@Slf4j
static class AToBPostProcessor implements BeanPostProcessor{
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
log.info("beanName={} bean={}", beanName, bean);
if(bean instanceof A){
return new B();
}
return bean;
}
}
A, B 클래스가 존재한다고 하자. @Configuration에서 A를 등록하고 빈 후처리기도 등록한다. BeanPostProcessor(빈 후처리기)를 구현한 AToBPostProcessor를 보면 postProcessAfterInitialization을 보면 현재 인스턴스 bean이 A일 경우 B를 반환 아니면 bean을 반환하게 했다. 물론 beanName을 보아도 빈 이름으로도 찾아낼수 있는 것을 알 수 있다.
이것에 대해 아래와 같은 테스트 코드를 돌리면?
@Test
void basicConfig(){
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);
//beanA 이름으로 B 객체가 빈으로 등록된다.
B beanB = applicationContext.getBean("beanA", B.class);
beanB.helloB();
//A는 빈으로 등록되지 않는다.
Assertions.assertThrows(NoSuchBeanDefinitionException.class, ()->applicationContext.getBean(A.class));
}
성공하는 것을 볼 수 있다. beanA라는 이름으로 B가 등록되고 A 타입의 빈은 존재하지 않는 것을 알 수 있다.
이것을 보면 ! 하고 떠오르는 생각이 존재할 것이다. 빈 객체 대신 프록시를 올리면 되겠군! 그렇다면 컴포넌트 스캔인 빈 객체에도 적용할 수 있겠군!
본격적으로 이를 만들어 내기 전에 @PostConstruct 에 대해서 언급하자면 이 녀석도 결국 빈에 대한 조작을 가한다. 그러니깐 후처리기라는 것이다. 스프링은 CommonAnnotationBeanPostProcessor을 등록하여 빈을 컨테이너에 등록하기 이전에 @PostConstruct이 붙은 메서드를 호출하여 빈 후처리를 동작한다.
4. 빈 후처리기 적용하기
그럼 먼저 빈 후처리기를 만들자.
@Slf4j
public class PackageLogTracePostProcessor implements BeanPostProcessor {
private final String basePackage;
private final Advisor advisor;
public PackageLogTracePostProcessor(String basePackage, Advisor advisor) {
this.basePackage = basePackage;
this.advisor = advisor;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
log.info("param beanName={} bean={}",beanName,bean.getClass());
//프록시 적용 대상 여부 체크
//프록시 적용 대상이 아니면 원본을 그대로 진행
String packageName = bean.getClass().getPackageName();
if(!packageName.startsWith(basePackage)){
return bean;
}
//프록시 대상이면 프록시를 만들어서 반환
ProxyFactory proxyFactory = new ProxyFactory(bean);
proxyFactory.addAdvisor(advisor);
Object proxy = proxyFactory.getProxy();
log.info("create proxy: target={} proxy={}",bean.getClass(), proxy.getClass());
return proxy;
}
}
BeanPostProcessor를 구현하였고 basePackage와 advisor를 주입 받아 빈 후처리기 로직을 실행한다. 프록시 적용 대상인지를 확인해서 적용 대상이 아니면 기존의 bean을 리턴하고 적용 대상이면 proxy를 만들고 addAdvisor를 하여 proxy를 리턴한다.
물론 이 빈 후처리기를 등록! 해야한다. 등록하는 과정은 다음과 같다.
@Slf4j
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class BeanPostProcessorConfig {
@Bean
public PackageLogTracePostProcessor logTracePostProcessor(LogTrace logTrace){
return new PackageLogTracePostProcessor("hello.proxy.app", getAdvisor(logTrace));
}
private Advisor getAdvisor(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*","order*","save*");
//advice
LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
return advisor;
}
}
포인트 컷과 어드바이스를 만들어서 어드바이저와 basePackage(적용 대상 패키지)를 넘긴다.
이로써 이전 글의 3번째 요구상황이었던 componenent scan일 경우에도 적용 되게 된다. 하지만 한가지 더 짚고 넘어가야할 부분이 있다. 현재 basePackage를 통해 적용 대상 클래스인지 확인을 했는데, 이것 마저도 포인트 컷이 해내야 하는 역할인게 더 맞아 보인다. 즉, 포인트 컷에서는 이 객체가 proxy를 적용할 객체인가? 와 해당 메서드에 대해 부가 기능을 적용해야 하는가? 를 결정한다.
5. 스프링이 제공하는 빈 후처리기
이젠 진짜 빈 후처리기 만든 것도 귀찮아진 개발자들을 위한 것이다. 이젠 스프링이 제공한다. 이를 사용하려면 아래와 같은 라이브러리를 추가해야한다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
이 빈 후처리기 이름은 AutoProxyCreator라 고 부른다. 요 녀석의 동작과정은 아래와 같다.
1. 생성: 스프링이 스프링 빈 대상이 되는 객체를 생성한다. ( @Bean , 컴포넌트 스캔 모두 포함)
2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.
3. 모든 Advisor 빈 조회: 자동 프록시 생성기 - 빈 후처리기는 스프링 컨테이너에서 모든 Advisor 를 조회한다.
4. 프록시 적용 대상 체크: 앞서 조회한 Advisor 에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다. 이때 객체의 클래스 정보는 물론이고, 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 매칭해본다. 그래서 조건이 하나라도 만족하면 프록시 적용 대상이 된다. 예를 들어서 10개의 메서드 중에 하나만 포인트컷 조건에 만족해도 프록시 적용 대상이 된다.
5. 프록시 생성: 프록시 적용 대상이면 프록시를 생성하고 반환해서 프록시를 스프링 빈으로 등록한다. 만약 프록시 적용 대상이 아니라면 원본 객체를 반환해서 원본 객체를 스프링 빈으로 등록한다.
6. 빈 등록: 반환된 객체는 스프링 빈으로 등록된다.
이때 4번 과정에서 Advisor 뿐만 아니라 @Aspect도 자동으로 인식해서 프록시를 만들고 AOP를 적용한다. @Aspect는 다음 글에서~
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
@Bean
public Advisor advisor1(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*","order*","save*");
//advice
LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
return advisor;
}
}
위 코드 처럼 pointcut과 advice를 만들어 등록하기만 하면 된다. 아니? 응응 왜냐하면 빈 후처리기 자체는 스프링이 만들어 주다 보니 우리가 조작할 길이 없으니 포인트 컷에 프록시를 생성할 bean인지를 결정할 권한을 넘긴다. 진짜.. 너무 편리하군!
하지만 위 방식의 NameMatch 방식 같은 경우 어떠한 bean이든 request나 order로 시작하면 프록시로 만들어 버린다. 의도치 않게 스프링이 올라가면서 등록되는 bean들 중에 우연히 겹칠 수도 있다. 즉, 포인트컷 로직이 너무나 허술하다. 조금 더 세밀하게 정하자.
그래서 AspectJExpressionPointcut라는 것을 이용하여 조금 세밀한 포인트 컷을 만들 수 있다.
@Bean
public Advisor advisor2(LogTrace logTrace){
//pointcut
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* hello.proxy.app..*(..))");
//app과 app 하위의 모든 페키지(파라미터에 대해 상관 없다) -> 패키지 기준
//advice
LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
return advisor;
}
이처럼 excution이라는 함수 내부에 위와 같은 표현식은 app과 app 하위 모든 패키지에 적용하고 *(..) 부분은 모든 메서드(*)와 모든 인자((...))에 대해 적용하겠다는 의미이다. 물론 아래와 같이 조건식을 넣을 수 도 있다.
pointcut.setExpression("execution(* hello.proxy.app..*(..)) && !execution(* hello.proxy.app..noLog(..))");
마무리..
휴 이제 AOP 1편의 마지막 글만 남겨두고 있다. 바로 aspect에 대하여! 이야기를 펼쳐 나갈 것이다. @Aspect에 대한 내용과 1편이 마무리 되면 AOP에 대해 좀 더 자세하게 주의해야 할 점이 무엇인지 위주로 AOP 2편을 다룰 것이다. 화이팅!