Spring 빈 후처리기

조갱·2024년 6월 15일
0

스프링 강의

목록 보기
19/23

빈 후처리기

일반적인 빈 등록은 아래와 같이 진행된다. 빈 후처리기를 사용하면, 아래와 같이 진행된다.

이처럼 빈 후처리기는

  • @Bean + @Configuration
  • @ComponentScan

과 같이 컨테이너에 저장되는 모든 빈에 대해, 컨테이너에 저장되기 이전에 무언가 작업을 할 수 있다.
객체의 동작중 일부를 조작하거나, 완전히 다른 객체로 바꾸는것까지 가능하다.

빈 등록 과정을 빈 후처리기와 함께 살펴보자

    1. 생성
      스프링 빈 대상이 되는 객체를 생성한다. (@Bean, 컴포넌트 스캔 등)
    1. 전달
      생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.
    1. 후 처리 작업
      빈 후처리기는 전달된 스프링 빈 객체를 조작하거나 다른 객체로 바뀌치기 할 수 있다.
    1. 등록
      빈 후처리기에서 전달된 빈을 저장소에 등록한다.
      이 때, 빈 후처리기의 반환에 따라 빈이 그대로 들어오거나 조작되어 등록될 수 있다.

예제코드 - 일반적인 빈 등록 절차

빈 후처리기를 등록하기 이전에, 일반적인 빈 등록 절차에 대해 복습해보자.

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");}
    }
}

예제코드 - 빈 후처리기를 통한 객체 바꿔치기

빈 후처리기를 사용하기 위해서는, BeanPostProcessor인터페이스를 구현해서 빈으로 등록해야한다.

BeanPostProcessor 인터페이스

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

메소드 명에서도 알 수 있지만,
postProcessBeforeInitialization : 객체 생성 이후 @PostConstruct 초기화가 발생하기 이전 실행
postProcessAfterInitialization : 객체 생성 이후 @PostConstruct 초기화가 발생하기 이후 실행

테스트 코드

public class BeanPostProcessorTest {
    @Test
    void postProcessor() {
        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));
    }

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

BeanPostProcessorConfig 에서
BeanPostProcess 인터페이스를 구현한 AToBPostProcessor 를 빈으로 등록했다.
-> 빈 후처리기를 등록했다.

AToBPostProcessor 로직을 보면, bean이 A객체이면 B로 바꿔치기하고있다.

실행 결과

..AToBPostProcessor - beanName=beanA bean=hello.proxy.postprocessor...A@21362712
..B - hello B

LogTrace 에 적용해보기

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());
        
        //프록시 적용 대상 여부 체크
        //프록시 적용 대상이 아니면 원본을 그대로 반환
        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 와 Advisor를 받아서
특정 패키지에 속한 클래스(프록시 대상)들에는 프록시를 적용한다.

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);

        //advisor = pointcut + advice
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

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

@Bean logTraceProxyPostProcessor()
빈 후처리기를 스프링 빈으로 등록한다.
프록시를 적용할 패키지 정보(hello.proxy.app)와 어드바이저(getAdvisor(logTrace))를 넘겨준다.

Application

@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를 사용하여 필요한 패키지에만 적용하는 것이 중요하다.

또한, 스프링이 사용하는 빈 중에는, 프록시로 만들지 못하는 객체도 있다. (final class)
여기에 빈을 생성하려고 하면 오류가 발생한다.

빈 후처리기를 사용하고

이전 포스팅의 마지막에 언급한 문제점이 해결되었는지 확인해보자.

너무 많은 설정

-> 이젠 빈 후처리기 설정 하나만 등록하면 된다.

컴포넌트 스캔

-> 컴포넌트 스캔을 사용하는 V3 까지 프록시가 적용된 것을 확인할 수 있었다.

스프링이 제공하는 빈 후처리기

스프링이 제공하는 빈 후처리기를 사용하기 위해서는 반드시 의존성을 추가 해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-aop'

위 의존성을 추가하면, aspectjweaver 라는 aspectJ 관련 라이브러리가 함께 추가되며,

스프링 부트가 AnnotationAwareAspectJAutoProxyCreator 라는 자동 프록시 생성기를 포함한 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다.
스프링 부트가 등록하는 빈은 AopAutoConfiguration 를 참고하자.

자동 프록시 생성기 - AutoProxyCreator

위에서 언급한 대로, 의존성을 추가하면 AnnotationAwareAspectJAutoProxyCreator 라는 빈 후처리기가 스프링 빈에 자동으로 등록된다.

이 빈 후처리기는 스프링 빈으로 등록된 Advisor 들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다.

자동 프록시 생성기의 동작 과정은 다음과 같다.

    1. 생성
      스프링 빈 대상이 되는 객체를 생성한다. (@Bean , 컴포넌트 스캔 등)
    1. 전달:
      생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.
    1. 모든 Advisor 빈 조회:
      자동 프록시 생성기 - 빈 후처리기는 스프링 컨테이너에서 모든 Advisor 를 조회한다.
    1. 프록시 적용 대상 체크:
      3번에서 조회한 Advisor의 Pointcut을 통해, 적용 대상인지 체크한다.
    1. 프록시 생성:
      프록시 적용 대상이면 프록시를 생성하고 반환한다.
      프록시 적용 대상이 아니면, 원본 객체를 반환한다.
    1. 빈 등록:
      반환된 객체는 스프링 빈으로 등록된다.

예제코드 - NameMatchMethodPointcut 포인트컷 사용

AutoProxyConfig

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
    @Bean
    public Advisor advisor1(LogTrace logTrace) {
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "order*", "save*");
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);

        //advisor = pointcut + advice
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

빈 후처리기를 등록할 필요 없이, 위에 의존성을 추가하면 AnnotationAwareAspectJAutoProxyCreator 자동 프록시 생성기가 Advisor를 알아서 처리해준다.

Application

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
    @Bean
    public Advisor advisor1(LogTrace logTrace) {
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "order*", "save*");
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);

        //advisor = pointcut + advice
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

실행 결과

EnableWebMvcConfiguration.requestMappingHandlerAdapter()
EnableWebMvcConfiguration.requestMappingHandlerAdapter() time=63ms

애플리케이션을 실행해보면, 위와 같이 스프링에서 사용하는 빈이 로그로 출력되는 것을 확인할 수 있다.

이는, 우리가 사용한 포인트컷이 단순히 메서드 이름에 "request*", "order*", "save*" 만 포함되어 있으면 매칭 된다고 판단하기 때문이다.

결국 스프링이 내부에서 사용하는 빈에도 메서드 이름에 request 라는 단어만 들어가 있으면 프록시가 만들어지고, 어드바이스도 적용되는 것이다.

결론적으로 패키지에 메서드 이름까지 함께 지정할 수 있는 매우 정밀한 포인트컷이 필요하다.

예제코드 - AspectJExpressionPointcut 포인트컷 사용(1)

AspectJExpressionPointcut 은 이름 그대로, AspectJ 표현식을 사용한 포인트컷이다.
AspectJ 표현식은 이전 포스팅에도 언급했지만, 조건을 매우 정밀하게 설정할 수 있다.

관련해서는 뒤에서 다시 자세하게 설명한다.

AutoProxyConfig

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
    @Bean
    public Advisor advisor2(LogTrace logTrace) {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(* hello.proxy.app..*(..))");
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);
        //advisor = pointcut + advice
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

execution(* hello.proxy.app..*(..)) : AspectJ가 제공하는 포인트컷 표현식
-> * : 모든 반환 타입
-> hello.proxy.app.. : 해당 패키지와 그 하위 패키지
-> *(..) : * 모든 메서드 이름, (..) 파라미터는 상관 없음

문제점

위에 표현식을 보면, *(..)에 의해 모든 메서드가 프록시 적용 대상이 된다.
그래서 http://localhost:8080/v1/no-log 를 호출하면 로그가 찍히면 안되지만,
로그가 찍히는 문제가 생긴다.

예제코드 - AspectJExpressionPointcut 포인트컷 사용(2)

위에서 다룬 AspectJExpressionPointCut에서는 package를 기준으로 표현식을 사용했기 때문에 문제가 발생했다. 이제 package + 메소드명 을 통해 정밀한 표현식을 만들어보자.

AutoProxyConfig

@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
    @Bean
    public Advisor advisor3(LogTrace logTrace) {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(* hello.proxy.app..*(..)) && !execution(* hello.proxy.app..noLog(..))");
        LogTraceAdvice advice = new LogTraceAdvice(logTrace);
        //advisor = pointcut + advice
        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}

execution(* hello.proxy.app..*(..)) && !execution(* hello.proxy.app..noLog(..))
-> && : 두 조건을 모두 만족해야 함
-> ! : 반대
-> hello.proxy.app 패키지와 하위 패키지의 모든 메서드는 포인트컷에 매칭하되, noLog() 메서드는 제외

profile
A fast learner.

0개의 댓글