
Spring 고급 강의를 들으면서 배웠던 핵심 기능과 부가 기능을 어떻게 분리를 하고 이를 동적으로 적용시킬 것인지에 대한 내용을 다룰 것이다. 누군가 이 글을 참고해서 공부를 하려고 한다면, 공부하기 전에 Spring Core에 대한 지식이 어느 정도 있는 상태에서 보는 걸 추천한다.
우선 Proxy의 개념을 알아야 할 필요가 있다. 어떤 객체를 호출하려고 할 때, 해당 객체에 부가적인 기능을 덧붙여서 사용하고 싶을 때가 있을 것이다. 예를 들면 로그 추적기, 보안, 트랜잭션 등이 있는데, 이러한 기능을 Proxy 객체를 통해서 적용할 수 있게 된다.
그러면 굳이 왜 Proxy라는 객체를 가져와서 쓰는 걸까? 라는 의문을 가질 수 있다. 로그 추척 기능을 추가하려는데 기존의 객체가 Controller, Service, Repository라면 우리는 핵심 로직에 부가 기능의 코드가 들어간 로직을 추가해야 할 것이다. 이렇게 됐을 때 객체 지향 원칙 중 하나인 SRP(단일 책임 원칙)에 어긋나게 된다. 하나의 클래스는 분명 하나의 책임을 가지도록 하는 게 좋기 때문에 Service라면 정말 딱 비즈니스 로직만 가질 수 있도록 하는 게 가장 바람직하고 이상적이게 되는 것이다. 그래서 만약 이 Service에 로그 추적 기능을 추가하고 싶을 때는 원본 객체에 영향을 주지 않는 Proxy라는 객체를 사용할 수 있다. 그러면 원본 객체에서 Proxy 객체를 가져오려면 어떻게 해야 할까? 일반적으로 많이 사용하는 2가지의 방법이 있다.
JDK Dynamic Proxy(JDK 동적 프록시)의 경우에는 인터페이스가 있는 구현체를 Proxy 객체로 만들고 싶을 때 사용하는 방법이다. Java reflection라이브러리에서 제공하는 메커니즘이며, 인터페이스에 정의된 메서드의 호출을 가로채 원하는 기능을 수행할 수 있게 설정할 수 있다.
package hello.proxy.config.v2_dynamicproxy.handler;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
@Slf4j
@RequiredArgsConstructor
public class LogTraceBasicHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
TraceStatus status = null;
try {
// 어떤 메서드가 실행되더라도, 동적으로 적용 가능하다.
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
status = logTrace.begin(message);
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
InvocationHandler라는 인터페이스를 구현한 CustomHandler를 사용한다. InvocationHandler는 호출 객체, 메서드 등에 대한 정보를 알 수 있게 기능을 지원한다고 보면 된다.
@Configuration
public class DynamicProxyBasicConfig {
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace){
OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(), new Class[]{OrderControllerV1.class}, new LogTraceBasicHandler(orderControllerV1, logTrace));
return proxy;
}
}
그리고 나서 위의 코드와 같이 원하는 객체를 등록할 때 Proxy.newProxyInstance를 사용하여 직접 Proxy 객체의 인스턴스를 만들 수 있고, 인자 값으로 클래스 로더와, 적용하려는 클래스들(해당 코드에선 한 개의 클래스만 적용), 아까 만들었던 Handler를 넣으면 OrderController(인터페이스)의 구현체인 OrderControllerV1Impl의 Proxy 객체가 생성이 되며, 해당 객체의 메서드가 호출될 때마다 부가 기능이 적용되는 걸 볼 수 있을 것이다.
인터페이스를 구현하지 않는 객체를 사용하고 싶을 때는 Spring에서 제공하는 CGLIB 라이브러리를 사용하면 된다. 해당 라이브러리에서는 Proxy 생성을 할 수 있게 도와주는 기능들이 제공이 되며, MethodInterceptor를 사용하면 내부적으로 intercept() 메서드를 실행하고, Proxy 객체를 만들어서 반환해주게 된다.
package hello.proxy.cglib;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
@RequiredArgsConstructor
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
log.info("Time Proxy 실행");
Long startTime = System.currentTimeMillis();
Object result = method.invoke(target, args);
Long endTime = System.currentTimeMillis();
Long resultTime = endTime - startTime;
log.info("Time Proxy 종료 = {}ms", resultTime);
return result;
}
}
위의 코드들만 봐도 쉽게 이해할 수 있을 것이다. 다만, 여기서 생기는 문제점은 만약 인터페이스를 구현하는 구현체와 구현하지 않는 객체가 둘 다 있는 경우에 생길 수 있는 문제이다. 이 때는, Spring에서 제공하는 ProxyFactory를 사용하여 유연하게 사용할 수 있게 된다.
Spring은 위의 두 가지 방법을 하나의 방법으로 처리할 수 있게 Advice라는 개념을 도입했다. 클라이언트는 추상화된 개념인 ProxyFactory에 의존하는데, 원하는 객체를 ProxyFactory의 target으로 설정하고, 그에 맞는 방법을 선택해서 프록시를 만든 후에 반환해준다.
*여기서 나오는 Advisor는 원하는 기능(Advice)과 적용을 하고자 하는 필터링(Pointcut)의 조합이라고 보면 된다.
package hello.proxy.config.v3_proxyfactory;
import hello.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import hello.proxy.app.v2.OrderControllerV2;
import hello.proxy.app.v2.OrderRepositoryV2;
import hello.proxy.app.v2.OrderServiceV2;
import hello.proxy.trace.logtrace.LogTrace;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class ProxyFactoryConfigV2 {
@Bean
public OrderControllerV2 orderControllerV2(LogTrace logTrace){
OrderControllerV2 orderControllerV2 = new OrderControllerV2(orderServiceV2(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderControllerV2);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderControllerV2 proxy = (OrderControllerV2) proxyFactory.getProxy();
log.info("ProxyFactory proxy = {}, target = {}", proxy.getClass(), orderControllerV2.getClass());
return proxy;
}
@Bean
public OrderServiceV2 orderServiceV2(LogTrace logTrace){
OrderServiceV2 orderServiceV2 = new OrderServiceV2(orderRepositoryV2(logTrace));
ProxyFactory proxyFactory = new ProxyFactory(orderServiceV2);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderServiceV2 proxy = (OrderServiceV2) proxyFactory.getProxy();
log.info("ProxyFactory proxy = {}, target = {}", proxy.getClass(), orderServiceV2.getClass());
return proxy;
}
@Bean
public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace){
OrderRepositoryV2 orderRepositoryV2 = new OrderRepositoryV2();
ProxyFactory proxyFactory = new ProxyFactory(orderRepositoryV2);
proxyFactory.addAdvisor(getAdvisor(logTrace));
OrderRepositoryV2 proxy = (OrderRepositoryV2) proxyFactory.getProxy();
log.info("ProxyFactory proxy = {}, target = {}", proxy.getClass(), orderRepositoryV2.getClass());
return proxy;
}
public Advisor getAdvisor(LogTrace logTrace){
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "save*", "order*");
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
개발자는 Advice만 만들면 되고, ProxyFactory가 내부적으로 MethodInterceptor나 InvocationHandler를 호출해주기 때문에 더욱 더 편리하게 Proxy 객체를 반환받을 수 있게 된다. 즉, JDK 동적 프록시나 CGLIB이 역할을 위임 받아서 Advice를 호출하는 구조라고 생각하면 된다.
여기서 문제점이 발생한다. 너무 많은 설정이 필요하다는 것인데, 만약 프록시 객체가 100개가 필요하다면, 100개 모두 Bean으로 등록을 해야 한다. 굉장히 번거로운 과정일 것이고, 컴포넌트 스캔을 하는 경우에는 객체 그 자체가 바로 Bean 저장소에 저장이 되기 때문에 커스텀할 수가 없게 된다. 즉, 원하는 부가 기능을 설정할 수 없는 상황이 된다.
위와 같은 문제점들 때문에 빈 후처리기라는 것을 사용한다.
애플리케이션이 실행되면서 @Bean 또는 @Component가 붙은 걸 확인하여, Bean을 생성하고 @PostConstructor과 같은 애너테이션을 사용하여 초기화 작업을 하는데, 빈 후처리기가 하는 역할은 Bean이 생성이 되고 나서 Bean 저장소로 가기 직전에 빈을 조작하고, 변경할 수 있게 해주는 것인데, 즉, Bean 원본 객체를 Proxy 객체로 바꿔치기 하여 Bean 저장소에 저장을 할 수 있게 해주는 기능이라는 것.
package hello.proxy.config.v4_postprocessor.postprocessor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
@Slf4j
@RequiredArgsConstructor
public class PackageLogTraceProxyPostProcessor implements BeanPostProcessor {
private final String basePackage;
private final 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;
}
}
인터페이스를 구현한 구현체를 만들면 되고, basePackage를 설정해서 적용하고자 하는 패키지를 설정할 수 있다.
핵심적인 코드는 아래에 적힌 것처럼, 본인이 원하는 조건문을 작성하고 조건에 충족하지 않는 경우에는 원본 객체의 Bean을 반환하고, 그게 아니라면 코드 맨 마지막처럼 프록시를 return해서 사용하면 된다.
if(!packageName.startsWith(basePackage)){
return bean;
};
빈 후처리기를 이용해서 프록시 생성을 깔끔하게 할 수 있게 되었지만, Spring에서 이미 빈 후처리기를 제공하기 때문에, 개발자들은 제공되는 빈 후처리기를 사용하면 된다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
build.gradle에 위와 같이 의존성을 추가해주고 build를 하자.
Spring에서 제공하는 자동 프록시 생성기(빈 후처리기)를 사용하면 아래와 같은 흐름대로 흘러가게 된다.
*위에서 적용한 의존성이 AspectJExpressionPointcut이라는 객체를 제공해주는데, 해당 객체는 어떻게 필터링을 할 건지 표현식으로 설정하기 위한 객체라고 생각하면 된다.
package hello.proxy.config.v5_autoproxy;
import hello.proxy.config.V1Config;
import hello.proxy.config.V2Config;
import hello.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.aop.Advisor;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import({V1Config.class, V2Config.class})
public class AutoProxyConfig {
@Bean
public Advisor advisor(LogTrace logTrace){
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* hello.proxy.app..*(..)) && !execution(* hello.proxy.app..noLog(..))");
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
코드가 그렇게 어려울 게 없기 때문에, 위와 같이 사용한다라는 것 정도만 알면 될 것 같다. 여기서 자주 보이는 Advisor는 Pointcut과 Advice가 나오는데, Pointcut은 필터링을 해주는 역할을 한다고 생각하면 되고, Advice는 부가 기능이라고 생각하면 되는데, 이에 대한 자세한 내용은 다음 포스팅에서 다루도록 하겠다.
이렇게 자동 프록시 생성을 하게 되면서 원하는 부가 기능을 쉽게 적용할 수 있게 되었는데, 우리는 이제 Spring에서 제공하는 AOP방식을 이용하여 더 간단하고 쉽게 부가 기능을 추가하는 방법을 알 필요가 있다. 실무에서 가장 많이 쓰는 방식이기 때문에, 결국엔 AOP 사용을 위한 빌드업이었다고 보면 된다.
잘못된 정보는 지적해주시면 감사하겠습니다.