BackEnd/Spring

[Spring] 빈 후처리기 - 1

샤아이인 2022. 8. 15.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

1. 빈 후처리기 - 소개

 

@Bean 이나 컴포넌트 스캔으로 스프링 빈을 등록하면, 스프링은 대상 객체를 생성하고 스프링 컨테이너 내부의 빈 저장소에 등록한다.

그리고 이후에는 스프링 컨테이너를 통해 등록한 스프링 빈을 조회해서 사용하면 된다.

 

▶ 빈 후처리기 - BeanPostProcessor

스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶다면 빈 후처리기를 사용하면 된다.

출처 - 인프런 김영한 고급편

 

1) 생성: 스프링 빈 대상이 되는 객체를 생성한다. (@Bean , 컴포넌트 스캔 모두 포함)
2) 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.
3) 후 처리 작업: 빈 후처리기는 전달된 스프링 빈 객체를 조작하거나 다른 객체로 바뀌치기 할 수 있다.

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

 

만약 다른 빈으로 바꿔버린다면 다음과 같을 것 이다.

출처 - 인프런 김영한 고급편

 

2. 빈 후처리기 - 예제 코드1

우선 일반적으로 빈을 등록하는 과정을 잠시 살펴보자.

public class BasicTest {

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

        A beanA = applicationContext.getBean("beanA", A.class);
        beanA.helloA();

        assertThatThrownBy(() -> applicationContext.getBean(B.class)).isInstanceOf(NoSuchBeanDefinitionException.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");
        }
    }
}

applicationContext 즉 Spring 컨테이너를 BasicConfig 기반으로 생성하게 되었다.

또한 "beanA"라는 이름으로 A 객체를 등록시켰다.

 

등록후, A객체를 다시 찾아와서 helloA()를 호출하게 된다.

또한 객체등록이 되어있지 않은 B객체 같은 경우 스프링 컨테이너에서 찾을수가 없다.

따라서 예외가 발생한다.

 

3. 빈 후처리기 - 예제 코드2

이번에는 빈 후처리기를 통해서 A 객체를 B 객체로 바꿔치기 해보자.

 

우선 BeanPostProcessor interface는 다음과 같다.

▶ BeanPostProcessor interface

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

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

 

- postProcessBeforeInitialization

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

 

- postProcessAfterInitialization :

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

 

우선 다음 테스트 코드를 살펴보자. 문제가 있는 코드다.

class BeanPostProcessorTest {

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

        A beanA = applicationContext.getBean("beanA", A.class);
        beanA.helloA();

        assertThatThrownBy(() -> applicationContext.getBean(B.class)).isInstanceOf(NoSuchBeanDefinitionException.class);
    }

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

        @Bean
        public AtoBPostProcessor helloPostProcessor() {
            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();
            }
            return bean;
        }
    }
}

인터페이스인 BeanPostProcessor 를 구현하고, 스프링 빈으로 등록하면 스프링 컨테이너가 빈 후처리기로 인식하고 동작한다.

AtoBPostProcessor 를 등록했기 때문에 Spring Container에는 B 객체가 등록된다.

 

파라미터로 넘어오는 빈(bean)객체가 A의 인스턴스이면 새로운 B 객체를 생성해서 반환한다.

여기서 A 대신에 반환된 값인 B 가 스프링 컨테이너에 등록된다.

 

따라서 다음 코드 부분에서

A beanA = applicationContext.getBean("beanA", A.class);

 

다음과 같은 오류가 발생한다.

org.springframework.beans.factory.BeanNotOfRequiredTypeException: Bean named 'beanA' is expected to be of type 'hello.proxy.postprocessor.BeanPostProcessorTest$A' but was actually of type 'hello.proxy.postprocessor.BeanPostProcessorTest$B'

우리는 A.class를 원한다 명시하였지만, 실제로 등록된 type은 B.class라는 의이이다.

따라서 알맞게 수정해보자.

 

수정된 테스트 코드는 다음과 같다.

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

    // beanA 이름으로 B 객체가 빈으로 등록된다.
    B beanB = applicationContext.getBean("beanA", B.class);
    beanB.helloB();

    // A는 빈으로 등록되지 않는다.
    assertThatThrownBy(() -> applicationContext.getBean(A.class)).isInstanceOf(NoSuchBeanDefinitionException.class);
}

실행 결과는 다음과 같다.

PostProcessor 메서드 내부로 들어와 우선 bean의 정보를 출력할때만 하더라도 beanA 에 대한 정보를 출력한다.

이후 B 객체가 대신 등록되기 때문에 helloB()를 출력시 "hello B"가 출력되게 된다.

"beanA"라는 스프링 빈 이름에 A 객체 대신에 B 객체가 등록된 것을 확인할 수 있다. A는 스프링 빈으로 등록조차 되지 않는다.

 

일반적으로 스프링 컨테이너가 등록하는, 특히 컴포넌트 스캔의 대상이 되는 빈들은 중간에 조작할 방법이 없는데, 빈 후처리기를 사용하면 개발자가 등록하는 모든 빈을 중간에 조작할 수 있다.

이 말은 빈 객체를 프록시로 교체하는 것도 가능하다는 뜻이다.

 

▶ 참고 - @PostConstruct 의 비밀

@PostConstruct는 에노테이션만 추가하면 빈이 생성된 이후 에노테이션이 있는 초기화 메서드를 간편하게 실행시켜준다.

그런데 생각해보면 빈의 초기화 라는 것이 단순히 @PostConstruct 애노테이션이 붙은 초기화 메서드를 한번 호출만 하면 된다.

 
쉽게 이야기해서 생성된 빈을 한번 조작하는 것이다.
 
스프링은 CommonAnnotationBeanPostProcessor 라는 빈 후처리기를 자동으로 등록하는데,
여기에서 @PostConstruct 애노테이션이 붙은 메서드를 호출한다.
 
따라서 스프링 스스로도 스프링 내부의 기능을 확장하기 위해 빈 후처리기를 사용한다.
 
 

4. 빈 후처리기 - 적용

이번 시간에는 빈 후처리기를 사용해서 실제 객체 대신 프록시를 스프링 빈으로 등록해보자.

 

이렇게 하면 수동으로 등록하는 빈은 물론이고, 컴포넌트 스캔을 사용하는 빈까지 모두 프록시를 적용할 수 있다.

더 나아가서 설정 파일에 있는 수 많은 프록시 생성 코드도 한번에 제거할 수 있다.

 

출처 - 인프런 김영한 고급편

우선 프록시를 생성할 후처리기를 만들어보자.

▶ PackageLogTraceProxyPostProcessor

@Slf4j
public class PackageLogTraceProxyPostProcessor implements BeanPostProcessor {

    private final String basePackage;
    private final Advisor advisor;

    public PackageLogTraceProxyPostProcessor(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());

        // Proxy 적용 대상 여부 체크
        String packageName = bean.getClass().getPackageName();
        if(!packageName.startsWith(basePackage)) {
            return bean;
        }

        // Proxy 대상이면 프록시를 만들어서 반환
        ProxyFactory proxyFactory = new ProxyFactory(bean);
        proxyFactory.addAdvisor(advisor);

        Object proxy = proxyFactory.getProxy();
        log.info("create proxy: target={} proxy={}", bean.getClass(), proxy.getClass());
        return proxy;
    }
}

PackageLogTraceProxyPostProcessor 는 원본 객체를 프록시 객체로 변환하는 역할을 한다.

이때 프록시 팩토리를 사용하는데, 프록시 팩토리는 advisor 가 필요하기 때문에 이 부분은 외부에서 주입 받는다.

 

또한 모든 Bean들을 Proxy로 만들 필요는 없다.

이를 위해 특정 패키지 이하의 빈들만 Proxy 적용이 되도록 만들었다.

// Proxy 적용 대상 여부 체크
String packageName = bean.getClass().getPackageName();
if(!packageName.startsWith(basePackage)) {
    return bean;
}

여기서는 hello.proxy.app 과 관련된 부분에만 적용하면 된다. 다른 패키지의 객체들은 원본 객체를 그대로 반환한다.

Proxy 적용 대상 객체들은 프록시가 대신 등록되도록 후처리 된다.

 

다음으로는 config 파일을 만들어보자.

▶ BeanPostProcessorConfig

@Slf4j
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class BeanPostProcessorConfig {

    @Bean
    public PackageLogTraceProxyPostProcessor logTraceProxyPostProcessor(LogTrace logTrace) {
        return new PackageLogTraceProxyPostProcessor("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);
    }
}

 

1) @Import({AppV1Config.class, AppV2Config.class})

V3는 컴포넌트 스캔으로 자동으로 스프링 빈으로 등록되지만, V1, V2 애플리케이션은 수동으로 스프링 빈으로 등록해야 동작한다.

 

2) logTraceProxyPostProcessor()

특정 패키지를 기준으로 프록시를 생성하는 빈 후처리기를 스프링 빈으로 등록한다.

빈 후처리기는 스프링 빈으로만 등록하면 자동으로 동작한다.

여기에 프록시를 적용할 패키지 정보( hello.proxy.app )와 어드바이저( getAdvisor(logTrace) )를 넘겨준다.

 

프록시를 생성하는 반복되는 공통 코드가 설정 파일에서 모두 사라졌다. 순수한 빈 등록만 고민하면 된다.

프록시를 생성하고 프록시를 스프링 빈으로 등록하는 것은 빈 후처리기가 모두 처리해준다

 

다음 과 같이 @Import 문을 설정해주면 성공적으로 실행이 된다.

@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();
    }
}

 

실행시 출력되는 로그를 확인해보자.

#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

여기서는 basePackage 를 사용해서 v1~v3 애플리케이션 관련 빈들만 프록시 적용 대상이 되도록 했다.

 

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

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

v3: 구체 클래스만 있으므로 CGLIB 프록시가 적용된다. 컴포넌트 스캔된 빈에도 후처리가 잘 적용되었다!


빈 후처리기 덕분에 프록시를 생성하는 부분을 하나로 집중할 수 있다.

그리고 컴포넌트 스캔처럼 스프링이 직접 대상을 빈으로 등록하는 경우에도 중간에 빈 등록 과정을 가로채서 원본 대신에 프록시를 스프링 빈으로 등록할 수 있다.

 

하지만 개발자의 욕심은 끝이 없다.
스프링은 프록시를 생성하기 위한 빈 후처리기를 이미 만들어서 제공한다.

 

중요!

우리는 프록시의 적용 대상 여부를 간단히 패키지를 기준으로 설정했다.

하지만 잘 생각해보면 포인트컷을 사용하면 더 깔끔할 것 같다.

 

포인트컷은 이미 클래스, 메서드 단위의 필터 기능을 가지고 있기 때문에, 프록시 적용 대상 여부를 정밀하게 설정할 수 있다.

참고로 어드바이저는 포인트컷을 가지고 있다. 따라서 어드바이저를 통해 포인트컷을 확인할 수 있다.

뒤에서 학습하겠지만 스프링 AOP는 포인트컷을 사용해서 프록시 적용 대상 여부를 체크한다.

 

결과적으로 포인트컷은 다음 두 곳에 사용된다.

 

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

댓글