프록시 팩토리와 어드바이저를 활용해 프록시 객체를 동적으로 생성할 수 있었다. 하지만, 문제가 있다. 첫 번째는 설정 파일에 동적 프록시 생성 코드를 만들어야 한다는 점, 두 번째로는 컴포넌트 스캔으로 자동으로 스프링 DI 컨테이너에 등록되는 객체들을 앞서 살펴본 동적 프록시 기술을 통해 프록시 객체를 등록할 수 없다는 것이다.
프록시 팩토리와 어드바이저에 대한 포스팅은 해당 포스팅을 참고해주시기 바랍니다.
빈 후처리기는 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전 조작하고 싶을 때 사용되는 기술이다. 빈 후처리기는 객체를 조작할 수도 있고, 완전히 다른 객체로 바꿔치기도 가능하다.
직접 코드를 통해 살펴보자.
@Test
void basicConfig() {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);
// beanA 이름으로 B 객체가 빈으로 등록됨
B b = applicationContext.getBean("beanA", B.class);
b.helloB();
// A 는 빈으로 등록되지 않는다.
Assertions.assertThrows(NoSuchBeanDefinitionException.class
, () -> applicationContext.getBean(A.class));
}
@Configuration
static class BeanPostProcessorConfig {
@Bean(name = "beanA")
public A a() {
return new A();
}
@Bean
public AToBPostProcessor helloPostProcessor() {
return new AToBPostProcessor();
}
}
static class A {
public void helloA() {
log.info("hello A");
}
}
static class B {
public void helloB() {
log.info("hello B");
}
}
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;
}
}
...
16:16:10.294 [Test worker] INFO hello.proxy.postprocessor.BeanPostProcessorTest - beanName=beanA bean=hello.proxy.postprocessor.BeanPostProcessorTest$A@2ca47471
16:16:10.345 [Test worker] INFO hello.proxy.postprocessor.BeanPostProcessorTest - hello B
직접 테스트코드로 결과를 확인해보니, beanA 라는 이름의 빈에 A 객체가 아닌, B 객체가 저장되어 있는 것을 확인했다.
AToBPostProcessor
클래스BeanPostProcessor
를 구현하고, 스프링 빈으로 등록하면 스프링 컨테이너가 빈 후처리기로 인식하고 동작한다. 주의할 점은 스프링 컨테이너에 등록되는 모든 스프링 빈들이 해당 후처리기를 거치니까 적절히 분기처리를 해줘야한다는 점이다.
@PostConstruct
어노테이션은 스프링 빈 생성 이후에 빈을 초기화하는 역할을 한다. 즉, 생성된 빈을 한 번 조작하는 것이다. 따라서 빈을 조작하는 빈 후처리기가 필요한데, 스프링에서는CommonAnnotationBeanPostProcessor
라는 빈 후처리기를 자동으로 등록하고, 여기에서@PostConstruct
어노테이션이 붙은 메서드를 호출하는 것이다. 따라서 스프링 스스로도 스프링 내부의 기능을 확장하기 위해 빈 후처리기를 사용한다.
프록시 팩토리에서는 직접 프록시 객체를 생성하는 코드를 작성하고, 빈으로 등록해줘야 했지만, 빈 후처리기를 활용해 빈으로 등록될 객체를 모두 프록시 객체로 바꿀 수 있게 되었다.
위의 예제에서는 객체의 클래스 타입으로 프록시 대상 객체를 선정했지만, 패키지 이름으로도 프록시 대상 여부를 적용할 수 있다. 다음의 코드를 보자. scanBasePackage 를 통해, 스프링 부트가 기본으로 등록하는 수많은 빈들이 빈 후처리기를 거치지 않도록 해줄 수 있다. 이렇게 패키지를 설정해줌으로써, 애플리케이션 관련 빈들만 프록시 대상이 되도록 했다.
@Import(BeanPostProcessorConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app")
public class ProxyApplication {
public static void main(String[] args) {
SpringApplication.run(ProxyApplication.class, args);
}
@Bean
public LogTrace logTrace() {
return new ThreadLocalLogTrace();
}
}
@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 advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
@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);
// 프록시 적용대상 여부 체크
// 프록시 적용 대상이 아니면 원본을 그대로 진행
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;
}
}
위의 예제 코드에서는 패지지 이름, 객체 타입으로 프록시 적용 대상 여부를 체크했지만 포인트컷(Pointcut) 을 이용하면 조금 더 깔끔할 것이다. 포인트컷은 클래스, 메서드 단위의 필터 기능을 가지고 있기 때문에, 프록시 적용 대상 여부를 정밀하게 설정할 수 있다.
참고로, 어드바이저는 포인트컷을 포함하고 있기에 어드바이저를 통해 적용 대상 여부를 판단할 수 있다.
스프링 AOP도 포인트컷을 사용해서 프록시 적용 대상 여부를 체크한다.
포인트컷은 다음 두 곳에서 사용된다.
- 자동 프록시 생성 : 프록시 적용 대상 여부를 체크해서 꼭 필요한 곳에만 프록시를 적용한다
- 프록시 내부 : 프록시의 어떤 메서드가 호출되었을 때 어드바이스를 적용할 지 판단한다.
스프링이 제공하는 빈 후처리기를 사용하려면 build.gradle 파일에 다음을 꼭 추가해주어야 한다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
이 라이브러리를 추가해주면 aspectjweaver
라는 aspectJ
관련 라이브러를 등록하고, 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다.
스프링부트는 자동으로 AnnotationAwareAspectJAutoProxyCreator
라는 빈 후처리기가 스프링 빈에 자동으로 등록된다. 이름 그대로 자동으로 프록시를 생성해주는 빈 후처리기이다.
이 빈 후처리기는 스프링 빈으로 등록된 Advisor
들을 자동으로 찾아서, 프록시가 필요한 곳에 자동으로 프록시를 적용해준다.
Advisor
안에는 Pointcut
과 Advice
가 이미 모두 포함되어 있다. 따라서 Advisor
만 알고 있으면 그 안에 있는 Pointcut
으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다. 그리고 Advice
로 부가 기능을 적용하면 된다.
AnnotationAwareAspectJAutoProxyCreator
는@AspectJ
와 관련된 AOP 기능도 자동으로 찾아서 처리해준다.Advisor
는 물론이고,@Aspect
도 자동으로 인식해서 프록시를 만들고 AOP를 적용해준다.@Aspect
에 대한 자세한 내용은 다음 포스팅에서 다룬다.
자동 프록시 생성기(빈 후처리기)는 스프링 컨테이너에서 모든 Advisor를 조회한다. 그리고, 조회한 Advisor 에 포함된 Pointcut을 이용해서 프록시를 적용할 대상인지 아닌지 판단한다.
프록시 적용 대상여부를 체크할 때, 객체의 클래스 정보, 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 대칭해본다. 조건이 하나라도 만족하면 프록시 적용 대상이 된다.
만약 만족하는 어드바이저가 여러 개라면 하나의 프록시에 여러 개의 어드바이저를 포함시켜 스프링 컨테이너에 등록한다.
@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 advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
import org.aopalliance.intercept.MethodInterceptor;
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;
}
}
}
위처럼 자동 프록시를 등록해서 사용할 수 있다. 하지만, 위처럼 Pointcut
을 메서드 이름으로 매칭하게 된다면 스프링부트가 자동으로 등록하는 빈들의 메서드와 매칭되어 어드바이스를 적용하게 된다. 그러므로, 더 정밀한 포인트컷을 사용해야 한다. 실무에서는 매우 정밀한 AspectJExpressionPointcut
포인트컷을 사용한다.
@Bean
public Advisor advisor2(LogTrace logTrace) {
// pointcut
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* hello.proxy.app..*(..))");
// advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
pointcut.setExpression("execution(* hello.proxy.app..*(..))");
의미
*
: 모든 반환 타입hello.proxy.app..
: hello.proxy.app
패키지와 하위 패키지로 패키지 범위를 지정*(..)
: *
는 모든 메서드 이름을, (..)
는 파라미터는 상관 없다는 것을 의미합니다. 정리하자면, hello.proxy.app 패키지와 그 하위 패키지의 모든 메서드는 포인트컷의 매칭 대상이 된다.
만약, 프록시를 적용하고 싶지 않은 대상이 있다면 다음과 같이 작성하면 된다.
@Bean
public Advisor advisor3(LogTrace logTrace) {
// pointcut
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* hello.proxy.app..*(..)) && !execution(* hello.proxy.app..noLog(..))");
// advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
&&
: 모두 만족!
: 반대