11 min read

<Spring> AOP 발전과정 살펴보기 2

앞선 포스팅에서 우리는 JDK 동적 프록시와 CGLIB을 사용하여 프록시 객체를 스프링 빈으로 등록하는 법을 알아보았다.해당 방식은 사용자가 직접 JDK 동적프록시를 사용할지 CGLIB을 사용할지 수동으로 결정해야하는 문제점이 있다.스프링에서는 이를 해결하기 위해 ProxyFactory라는 객체를 제공해준다.

ProxyFactory란

팩토리라는 이름처럼 해당 객체는 특정 클래스에 대한 프록시 객체를 생성해주는 기능을 수행한다. 프록시 팩토리는 앞서 소개된 JDK 동적 프록시와 CGLIB 두 가지 방식을 통해 프록시 객체를 생성해준다.즉, 주어진 타입이 인터페이스인 경우는 JDK 동적프록시를 구체 클래스 타입인 경우는 CGLIB을 사용하여 자동으로 프록시 객체를 만들어준다.즉,프록시 팩토리는 아래와 같은 의존관계를 가진다.

위와 같은 의존관계를 가진다면, 프록시 팩토리에서는 CGLIB과 JDK 동적프록시가 가지는 MethodInterceptorInvocationHandler를 추상화한 객체가 필요하지 않을까? 이를 해결하기 위해 스프링에서는 Advice라는 개념을 도입한다.

Advice, Pointcut, Advisor

드디어 본격적으로 AOP에서 사용되는 용어들이 등장한다.우선 Advice에 대해 먼저 이해해보자.언급된 것처럼 CGLIB과 JDK 동적 프록시는 모두 인프라 로직이 구현되어 있는 객체가 필요하다.즉, MethodInterceptorInvocationHandler이 공통적으로 호출해서 사용할 인프라 로직이 구현된 객체가 프록시 팩토리에게 필요하다.이러한 역할을 수행하는 객체가 바로 Advice이다. Advice를 사용할 경우 요청 흐름은 아래와 같다.

다음으로 Pointcut에 대해서 알아보자. 우리는 지금까지 클래스 단위로 프록시 객체를 생성하였다.즉, 클래스 내부에 메서드가 n개 존재할 경우 별다른 필터 로직을 추가하지 않을 경우 모든 메서드가 호출 될 때마다 인프라 로직이 적용된다.만약 요구사항에 의해 한 클래스 내에서 프록시 적용 대상이 되는 메서드를 달리 설정하려면 어떻게 해야할까? 이러한 요구사항을 만족시키기 위해 Pointcut이라는 개념이 등장한다. Pointcut에 의해 인프라 로직이 적용될 범위를 조절할 수 있다.

마지막으로 Advisor의 경우 하나의 포인트컷과 하나의 어드바이스가 합쳐진 객체라고 생각하면 된다.

위 세 가지 개념들은 아래와 같이 간략하게 정리할 수 있다.

  • Advice : 인프라 로직 구현
  • Pointcut : 프록시 적용 범위 조절
  • Advisor : Advice + Pointcut

프록시 팩토리 사용하기

프록시 팩토리를 사용하기 위해 선수적으로 필요한 개념들에 대해 알아보았으니 프록시 팩토리를 이용해서 프록시 객체를 생성해보자.인프라 로직을 구현하는 Advice를 먼저 구현해보자.간단하게 해당 메서드에 대한 로깅을 남겨주는 Advice를 구현한다.

@Slf4j
public class LogAdvice implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        Method method = invocation.getMethod();
        String methodString  = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
        log.info("Invoked Method : {}", methodString);
        return invocation.proceed();
    }
}

한 가지 주의할 점은 해당 코드에서 구현하는 MethodInterceptor은 CGLIB 패키지에 존재하는 인터페이스가 아니라 aopalliance 패키지에 존재하는 인터페이스다.

다음으로 프록시 팩토리를 사용해서 프록시 객체를 생성하는 테스트 코드를 작성해보자.

	@Test
    void test() {
        A target = new AImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvice(new LogAdvice());
        A proxy = (A) proxyFactory.getProxy();
        proxy.call();
    }

target이라는 A 인터페이스 타입의 구현체 인스턴스를 생성한다.이후 target을 ProxyFactory 생성자의 인자로 넣어주며 ProxyFactory를 생성한다.이후 Advice를 추가 하고 proxy 객체를 가져와 내부의 call() 메서드를 호출해보면 Advice가 적용된것을 확인할 수 있다.

이번에는 동일한 코드를 Advisor를 사용하여 proxy 객체를 생성해보자.

@Test
    void test() {
        A target = new AImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new LogAdvice()); //항상 참인 PointCut 사용
        proxyFactory.addAdvisor(advisor);
        A proxy = (A) proxyFactory.getProxy();
        proxy.call();
    }

앞서 배운것처럼 pointCut + advice가 Advisor이다. 여기서는 편의를 위해 항상 참값을 리턴하는 Pointcut을 사용하여서 Advisor를 생성했다.Advice는 위 예제와 동일하게 사용했다.

프록시 팩토리의 한계

이렇게 해서 프록시 객체를 팩토리를 통해서 손쉽게 생성하는 법을 배웠다.앞선 포스팅의 CGLIB이냐 JDK 동적 프록시냐에 대한 고민도 필요없고 단순히 프록시의 target을 인자로 넣어주면 곧바로 proxy 객체를 생성할 수 있다.다만, 이런식으로 proxy 객체를 손쉽게 스프링 빈으로 등록할 수 있겠지만 등록할 빈들의 개수가 수십,수백개가 된다면 어떻게 될까? 아마 빈들을 등록하는 config 파일의 설정 지옥이 펼쳐질 것이다.또한 현재 방식은 @ComponentScan으로 등록되는 빈들에 대해서 바꿔치기를 통해 프록시 빈을 등록할 수 없다.이러한 문제들을 해결하기 위해 스프링 빈 후 처리기라는 클래스가 존재한다.

빈 후처리기

스프링에서는 BeanPostProcessor라는 인터페이스를 제공한다. 해당 인터페이스의 postProcessAfterInitialization라는 메서드를 오버라이드하면 우리가 원하는 프록시 객체를 원본 객체 대신해서 스프링 빈으로 등록해 줄 수 있다. 직접 빈 후처리기 클래스를 만들어서 등록할 빈 타입이 아닌 다른 타입의 빈을 등록하는 테스트 코드를 작성해보자.

public class BeanPostProcessorTest {
    static class MyBeanPostProcessor implements BeanPostProcessor {
        private final Advisor advisor;

        public MyBeanPostProcessor(Advisor advisor) {
            this.advisor = advisor;
        }

        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
            if (bean instanceof A) {
                ProxyFactory proxyFactory = new ProxyFactory(bean);
                proxyFactory.addAdvisor(advisor); // pointcut + advice
                return proxyFactory.getProxy();
            }
            return bean;
        }
    }

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

    @Configuration
    static class BeanPostProcessorConfig {

        @Bean(name = "beanA")
        public A a() {
            return new A();
        }
        @Bean
        public Advisor advisor() {
            return new DefaultPointcutAdvisor(Pointcut.TRUE, new LogAdvice());
        }

        @Bean
        public MyBeanPostProcessor myBeanPostProcessor() {
            return new MyBeanPostProcessor(advisor());
        }

    }

    @Test
    void test() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);
        A beanA = (A) ac.getBean("beanA");
        beanA.helloA();
    }

}

차례대로 보면, 먼저 BeanPostProcessor 인터페이스의 구현체를 이너 클래스로 만들어준다. 이후 빈 등록 대상이 되는 객체인 클래스 A를 구현한다.이후 해당 빈들을 스프링 컨테이너에 등록하기 위한 Config를 작성해준다. A와 후처리기 그리고 A의 프록시 오브젝트에 적용될 Advisor 객체를 빈으로 등록해준다.

이후 마지막에 test() 메서드 내에 등록된 beanA라는 이름을 가진 오브젝트를 호출하면 우리가 구현한 Advisor가 적절하게 적용된것을 확인할 수 있다.참고로 해당 Advisor는 가장 간단한 TRUE만을 반환하는 포인트컷과 앞서 구현한 LogAdvice()로 구성되었다.

참고로 예제의 config에서 빈 A를 등록할 때 proxyFactory 등 프록시와 관련된 코드는 한 줄도 없었다.즉, 빈들을 컴포넌트 스캔으로 등록할 경우에도 위와 같은 빈 후처리기를 사용하면 자동으로 프록시 객체로 바꿔치기하여 등록시켜준다.

스프링에서 등록해주는 빈 후처리기

우리가 스프링 환경에서 AOP를 사용하는 경우 위와 같은 빈 후처리기를 직접 구현하여 사용하는 경우는 거의 없다고 봐도 무방하다.그러면 아마 위와 같은 빈 후처리기를 이미 스프링 빈에 등록하여 제공을 해준다고 유추할 수 있다.이에 대해 알아보자.

AnnotationAwareAspectJAutoProxyCreator이라는 빈 후처리기를 스프링을 미리 빈으로 등록해둔다. 해당 클래스의 postProcessAfterInitialization 메서드 구현을 보면 아래와 같다. 모든 구현을 알아보기는 어렵지만 javadoc을 읽어보면 인자로 들어온 bean 객체에 대해 proxy를 생성하는 기능을 수행하는것을 유추할 수 있다. getAdvices... 메서드가 누가 봐도 해당 빈에 해당 하는 Advice 또는 Advisor를 가져오는것임을 유추 가능한데 이 메서드가 wrapIfNecessary 메서드 내부에서 호출된다. 대략적으로 Advisor를 보고 이에 해당하는 빈들의 프록시를 알아서 등록해주는 기능을 제공해준다고 볼 수 있다.

	/**
	 * Create a proxy with the configured interceptors if the bean is
	 * identified as one to proxy by the subclass.
	 * @see #getAdvicesAndAdvisorsForBean
	 */
   @Override
	public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
		if (bean != null) {
			Object cacheKey = getCacheKey(bean.getClass(), beanName);
			if (this.earlyProxyReferences.remove(cacheKey) != bean) {
				return wrapIfNecessary(bean, beanName, cacheKey);
			}
		}
		return bean;
	}

정리해보면 이 빈 후처리기는 이름 그대로 자동으로 프록시를 생성해준다.또한 스프링 빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 스프링 빈에 자동으로 프록시 객체를 등록시켜준다.

@Aspect

이제 Advisor가 등록될 경우, 해당 Advisor를 알아서 찾고 내부의 포인트컷과 어드바이스를 찾아내어 프록시 객체를 만들어내는 과정까지 알게되었다. 마지막으로 Advisor를 쉽게 빈으로 등록하는 방식인 @Aspect를 알아보자.대부분의 AOP를 사용할 줄 알면 위 어노테이션을 통해 적용시킴을 알고 있을것이다.

@Slf4j
@Aspect
public class LogAspect {
    @Around("execution(* hello.proxy.app..*(..))")
    public Object excute(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("log by proxy");
        return joinPoint.proceed();
    }
}

아주 간단한 Aspect를 만들어보았다.간략히 설명하면 @Around가 적용된 메서드가 기존의 Advice 역할을 수행하고 내부의 value는 포인트컷이 된다. 포인트컷의 경우 AspectJ의 표현식으로 별도의 공부를 해주시면 된다. 위와 같은 Aspect를 생성한 뒤 이를 @Component를 붙이거나 직접 config에 스프링 빈으로 등록할 경우 Aspect를 기반으로 이에 해당하는 Advisor를 생성하고 이를 저장해둔다.저장된 Advisor 정보를 통해 스프링 빈들 중 적용될 대상이 있는지 체크하고 해당하는 경우는 프록시 객체를 생성하여 AOP를 적용시켜준다.

이렇게 해서 스프링 AOP가 어떻게 발전이 되고 그 내부동작이 어떻게 이루어지는지에 대해 알아보았다.