본문 바로가기
Spring/spring AOP

스프링 코어 2 - 빈 후처리기

by 킹차니 2022. 1. 11.

빈 후처리기

 

일반적으로 스프링 빈을 등록하면 스프링은 대상 객체를 생성하고 스프링 컨테이너 내부의 빈 저장소에 등록한다. 그 이후에는 스프링 컨테이너를 통해 등록된 스프링 빈을 조회해서 사용하면 된다.

 

 

빈 후처리기 - BeanPostProcessor

이때 빈 후처리기를 사용하면 빈으로 등록하고자하는 객체를 빈 저장소에 등록하기 전에 조작할 수 있다.

BeanPostProcessor는 이름 그대로 빈을 생성한 후에 무언가를 처리하는 용도로 사용한다.

객체를 조작하거나, 완전히 다른 객체로 바꿔치기하는 것도 가능하다.

 

그림으로 보면 아래와 같다.

1. 생성 : 스프링 빈 대상이 되는 객체를 생성한다. (@Bean, ComponentScan 모두 포함)

2. 전달 : 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.

3. 후 처리 작업 :  빈 후처리기는 전달받은 빈 객체를 조작하거나 다른 객체로 바꿔치기 할 수 있다.

4. 등록 : 빈 후처리기는 빈을 반환한다. 전달된 빈을 그대로 반환하면 해당 빈이 등록되고, 바꿔치기하면 다른 객체가 등록된다.

 

이들을 학습하기 전에 일반적인 스프링 빈 등록을 살펴보자.

 

아래에는 A, B 클래스가 있고,  BasicConfig 클래스에서 A는 "beanA"라는 이름으로 빈 객체 등록을 하였지만 B는 하지 않았다. 하여 아래의 BasicTest 테스트는 성공할 것이다.

@Slf4j
public class BasicTest {

    @Test
    void basicConfig() {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BasicConfig.class);

        //A는 빈으로 등록된다.
        A a = applicationContext.getBean("beanA", A.class);
        a.helloA();

        //B는 빈으로 등록되지 않는다.
        Assertions.assertThrows(NoSuchBeanDefinitionException.class, () -> applicationContext.getBean(B.class));
    }

    @Slf4j
    @Configuration
    static class BasicConfig {
        @Bean(name = "beanA")
        public A a() {
            return new A();
        }
    }

    @Slf4j
    static class A{
        public void helloA() {
            log.info("hello A");
        }
    }

    @Slf4j
    static class B{
        public void helloB() {
            log.info("hello B");
        }
    }
}

 

하지만 이번에는 빈 후처리기를 사용하여 A객체를 B객체로 바꿔치기 해볼 것이다.

아래의 그림처럼 말이다.

 

이는 스프링이 제공하는 BeanPostProcessor 인터페이스를 사용하여 가능하다.

public interface BeanPostProcessor {
      Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException
      Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException
}

빈 후처리기를 사용하기 위해서는 위의 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.

postProcessBeforeInitialization : 객체 생성 이후에 @PostConstruct같은 초기화가 발생하기 전에 호출되는 포스트 프로세서이다.

postProcessAfterInitialization : 객체 생성 이후에 @PostConstruct같은 초기화가 발생한 이후에 호출되는 포스트 프로세서이다.

 

@Slf4j
public class BeanPostProcessorTest {

    @Test
    void basicConfig() {
        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);

        //A는 빈으로 등록되지 않고 B가 등록된다. 하여 여기서 NoSuchBeanDefinitionException가 발생한다.
        A a = applicationContext.getBean("beanA", A.class);
        a.helloA();

        //B는 빈으로 등록된다. 하여 해당 테스트는 실패한다.
        Assertions.assertThrows(NoSuchBeanDefinitionException.class, () -> applicationContext.getBean(B.class));
    }

    @Slf4j
    @Configuration
    static class BeanPostProcessorConfig {
        @Bean(name = "beanA")
        public A a() {
            return new A();
        }

        @Bean /* 빈 후처리기를 빈으로 등록 */
        public AToBPostProcessor aToBPostProcessor() {
            return new AToBPostProcessor();
        }
    }

    @Slf4j
    static class A{
        public void helloA() {log.info("hello A");}
    }

    @Slf4j
    static class B{
        public void helloB() {log.info("hello B");}
    }

    @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();//A객체는 B객체를 빈으로 등록
            return bean;
        }
    }
}

마지막에 AToBPostProcessor를 보면 A객체를 빈으로 등록하려 하면 B를 빈으로 등록한다.

위의 테스트는 이름은 "beanA"지만 타입은 B인 객체가 빈으로 등록되어 테스트에 실패할 것이다.

 

 

하여 테스트 코드를 아래와 같이 수정하면 통과할 것이다.

@Test
void basicConfig() {
    ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);

    B b = applicationContext.getBean("beanA", B.class);
    b.helloB();

    Assertions.assertThrows(NoSuchBeanDefinitionException.class, () -> applicationContext.getBean(A.class));
}

 

 

AToBPostProcessor

빈 후처리기. 인터페이스인 BeanPostProcessor 를 구현하고, 스프링 빈으로 등록하면 스프링 컨테이너가 빈 후처리기로 인식하고 동작한다. 이 빈 후처리기는 A객체를 새로운 B객체로 바꿔치기 한다. 파라미터로 넘어오는 빈(bean)객체가 A의 인스턴스이면 새로운 B 객체를 생성해서 반환한다. 여기서 A 대신에 반환된 값인 B 가 스프링 컨테이너에 등록된다.

 

빈 후처리기는 빈을 조작하고 변경할 수 있는 후킹 포인트이다. 이것은 빈 객체를 조작하거나 심지어 다른 객체로 바꾸어 버릴 수 있을 정도로 막강하다. 여기서 조작이라는 것은 해당 객체의 특정 메서드를 호출하는 것을 뜻한다.
일반적으로 스프링 컨테이너가 등록하는, 특히 컴포넌트 스캔의 대상이 되는 빈들은 중간에 조작할 방법이 없는데, 빈 후처리기를 사용하면 개발자가 등록하는 모든 빈을 중간에 조작할 수 있다. 이 말은 빈 객체를 프록시로 교체하는 것도 가능하다는 뜻이다.

 

참고 @PostConstruct

@PostConstruct 는 스프링 빈 생성 이후에 빈을 초기화 하는 역할을 한다. 그런데 생각해보면 빈의 초기화라는 것이 단순히 @PostConstruct 애노테이션이 붙은 초기화 메서드를 한번 호출만 하면 된다. 쉽게 이야기해서 생성된 빈을 한번 조작하는 것이다. 따라서 빈을 조작하는 행위를 하는 적절한 빈 후처리기가 있으면 될 것 같다. 스프링은 CommonAnnotationBeanPostProcessor 라는 빈 후처리기를 자동으로 등록하는데, 여기에서 @PostConstruct 애노테이션이 붙은 메서드를 호출한다. 따라서 스프링 스스로도 스프링 내부의 기능을 확장하기 위해 빈 후처리기를 사용한다.

 

 


 

 

빈 후처리기 - 적용

 이제 빈 후처리기를 V1, V2, V3 App에 적용해보자. (빈 후처리기는 컴포넌트 스캔이든 @Bean으로 등록된 빈이든 모든 빈에 적용가능하다!)

빈 후처리기를 사용하여 실제 객체 대신 프록시 객체를 빈으로 등록하는 것이다. 이렇게하면 수동 등록 빈이든 컴포넌트 스캔 빈이든 상관없이 프록시를 적용하여 부가 기능을 추가할 수 있고, 설정 파일에 존재하는 수 많은 프록시 생성 코드도 필요없어진다.

 

먼저 아래와같이 BeanPostProcessor를 구현하여 빈 후처리기를 구현한다.

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
public class PackageLogTracePostProcessor implements BeanPostProcessor {

    private final String basePackage;//지정된 패키지에만 빈 후처리기 적용
    private final Advisor 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;
    }
}

 

위에서 만든 빈 후처리기는 아래에서 빈으로 등록하였다.

@Slf4j
@Import({AppV1Config.class, AppV2Config.class})
@Configuration
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);
    }
}

• PackageLogTraceProxyPostProcessor 는 원본 객체를 프록시 객체로 변환하는 역할을 한다. 이때 프록시 팩토리를 사용하는데, 프록시 팩토리는 advisor 가 필요하기 때문에 이 부분은 외부에서 주입 받도록 했다.     

 모든 스프링 빈들에 프록시를 적용할 필요는 없다. 여기서는 특정 패키지와 그 하위에 위치한 스프링 빈들만 프록시를 적용한다. 여기서는 hello.proxy.app 과 관련된 부분에만 적용하면 된다. 다른 패키지의 객체들은 원본 객체를 그대로 반환한다.       

 

 프록시 적용 대상의 반환 값을 보면 원본 객체 대신에 프록시 객체를 반환한다. 따라서 스프링 컨테이너에 원본 객체 대신에 프록시 객체가 스프링 빈으로 등록된다. 원본 객체는 스프링 빈으로 등록되지 않는다.

 

 

 

마지막으로 메인 클래스에서는 아래와 같이 BeanProcessConfig.class를 import해준다.

@Import(BeanPostProcessorConfig.class)
//@Import(ProxyFactoryConfigV2.class)
//@Import(ProxyFactoryConfigV1.class)
//@Import(DynamicProxyFilterConfig.class)
//@Import(DynamicProxyBasicConfig.class)
//@Import(ConcreteProxyConfig.class)
//@Import(InterfaceProxyConfig.class)
//@Import({AppV1Config.class, AppV2Config.class})
//@Import(AppV1Config.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();
   }
}

• @Import({AppV1Config.class, AppV2Config.class}) : V3는 컴포넌트 스캔으로 자동으로 스프링 빈으로 등록되지만, V1, V2 애플리케이션은 수동으로 스프링 빈으로 등록해야 동작한다. ProxyApplication 에서 등록해도 되지만 편의상 여기에 등록하자.

• @Bean logTraceProxyPostProcessor() : 특정 패키지를 기준으로 프록시를 생성하는 빈 후처리기를 스프링 빈으로 등록한다. 빈 후처리기는 스프링 빈으로만 등록하면 자동으로 동작한다. 여기에 프록시를 적용할 패키지 정보( hello.proxy.app )와 어드바이저(getAdvisor(logTrace))를 넘겨준다.


 이제 프록시를 생성하는 코드가 설정 파일에는 필요 없다. 순수한 빈 등록만 고민하면 된다. 프록시를 생성하고 프록시를 스프링 빈으로 등록하는 것은 빈 후처리기가 모두 처리해준다.

 

이를 실행해보면 아래와 같은 로그가 출력된다.( PackageLogTracePostProcessor 에 로그 코드가 존재함 )

#v1 애플리케이션 프록시 생성 - JDK 동적 프록시
create proxy: target=v1.OrderRepositoryV1Impl proxy=class com.sun.proxy. $Proxy50
create proxy: target=v1.OrderServiceV1Impl proxy=class com.sun.proxy.$Proxy51 create proxy: target=v1.OrderControllerV1Impl proxy=class com.sun.proxy. $Proxy52

#v2 애플리케이션 프록시 생성 - CGLIB
create proxy: target=v2.OrderRepositoryV2 proxy=v2.OrderRepositoryV2$ $EnhancerBySpringCGLIB$$x4
create proxy: target=v2.OrderServiceV2 proxy=v2.OrderServiceV2$ $EnhancerBySpringCGLIB$$x5
create proxy: target=v2.OrderControllerV2 proxy=v2.OrderControllerV2$ $EnhancerBySpringCGLIB$$x6

#v3 애플리케이션 프록시 생성 - CGLIB
create proxy: target=v3.OrderRepositoryV3 proxy=3.OrderRepositoryV3$ $EnhancerBySpringCGLIB$$x1
create proxy: target=v3.orderServiceV3 proxy=3.OrderServiceV3$ $EnhancerBySpringCGLIB$$x2
create proxy: target=v3.orderControllerV3 proxy=3.orderControllerV3$ $EnhancerBySpringCGLIB$$x3

• v1: 인터페이스가 있으므로 JDK 동적 프록시가 적용된다.

• v2: 구체 클래스만 있으므로 CGLIB 프록시가 적용된다.

v3: 구체 클래스만 있으므로 CGLIB 프록시가 적용된다.

 

 


 

정리

 

빈 후처리기를 사용하여 컴포넌트 스캔, 수동 빈 등록에 상관없이 프록시 객체를 적용할 수 있었고, ProxyFactoryConfigV1, ProxyFactoryConfigV2에서 지나치게 많았던 설정 파일을 줄일 수 있게 되었다. 또한 애플리케이션에 수 많은 스프링 빈이 추가되어도 프록시와 관련된 코드는 전혀 변경되지 않는다. 

 

하지만 스프링은 프록시를 생성하기 위한 빈 후처리기를 이미 만들어서 제공한다.

 

중요

프록시의 적용 대상 여부를 여기서는 간단히 패키지를 기준으로 설정했다. 그런데 잘 생각해보면 포인트컷을 사용하면 더 깔끔할 것 같다. 포인트컷은 이미 클래스, 메서드 단위의 필터 기능을 가지고 있기 때문에, 프록시 적용 대상 여부를 정밀하게 설정할 수 있다.
참고로 어드바이저는 포인트컷을 가지고 있다. 따라서 어드바이저를 통해 포인트컷을 확인할 수 있다.
스프링 AOP는 포인트컷을 사용해서 프록시 적용 대상 여부를 체크한다.

결과적으로 포인트컷은 다음 두 곳에 사용된다.
1. 프록시 적용 대상 여부를 체크해서 꼭 필요한 곳에만 프록시를 적용한다. (빈 후처리기 - 자동 프록시 생성)
2. 프록시의 어떤 메서드가 호출 되었을 때 어드바이스를 적용할 지 판단한다. (프록시 내부)

 

 

 

 

 

 

김영한님의 인프런 강의와 PDF를 바탕으로 정리하였습니다.