
스프링부트에서는 어노테이션 기반의 설정을 지원하며 이를 AutoConfiguration 이라고 부른다.
또한 @Transactional , @Async , @Cacheable , @PreAuthorize 과 같은 어노테이션은 개발자가 애플리케이션을 개발할 때 가장 많이 사용하는 어노테이션 중 하나이며, 기반은 전부 AOP Proxy를 기반으로 동작한다.
AOP란 Aspect Oriented Programming 이라 불리며,
관점지향 프로그래밍 기법이라고 많이 알려져있다.
이 글은 스프링 AOP의 내부 동작 원리에 대해서 다룰 것이기에,
Aspect, Advice 와 같은 AOP 이론적인 개념에 대해서는 다루지 않겠다.
AOP는 스프링의 3가지 핵심 철학 중 하나이며, 이를 이해하는 것은
스프링의 내부 동작 원리를 이해하는 데에 큰 도움이 된다.
AOP를 구현하는 방법은 크게 두 가지 방법으로 나뉜다.
프록시 기반은 디자인 패턴 중, 프록시 패턴을 적용하는 방법이다.
원본 객체의 프록시 객체를 대신 생성하여, 원본 객체의 메소드를 호출하는 것처럼 보이지만 사실은 프록시 객체의 메소드를 호출시켜서,
공통 관심사 로직을 수행한 후에, 원본 객체의 메소드를 호출시키는 방식이다.
프록시 객체를 생성하는 방식은 두 가지가 존재한다.
위빙 기반으로도 AOP를 구현할 수 있으며, 주로 AspectJ 라이브러리가 이 방식으로 AOP를 구현할 수 있도록 지원한다.
위빙이란, 바이트 코드를 조작해서 공통 관심사 로직(Advice)을 바이트 코드 형태로 끼워넣는 방식을 말한다.

위빙 기반으로 AOP를 구현할 경우, 런타임 시점이 아니라, 컴파일 시점 또는 클래스 로드 시점에 바이트 코드 레벨에서 로직을 고정시켜버리기 때문에 프록시 기반의 Self Invocation 문제를 해결할 수 있는 장점이 있다.
위빙의 종류에는 두 가지 방식이 있다.
스프링에서 채택하는 AOP 메커니즘은 프록시 기반 AOP이다.
@Transactional 어노테이션을 붙인 메소드를 자기 내부에서 호출하면
트랜잭션이 열리지 않는 문제의 원인도 바로 프록시 기반의 AOP이기 때문이다.
또한 스프링을 사용하는 개발자는 @Async , @Transactional , @Cacheable , @PreAuthorized 와 같은 메소드 레벨에 부착하는 다양한 어노테이션을 사용한다.
위와 같은 어노테이션들은 모두 AOP 프록시를 기반으로 동작하며,
스프링이 제공하는 편리한 기능 덕분에 캐싱, 트랜잭션, 비동기 작업을 손쉽게 사용할 수 있는 것이다.
여기서 Spring AOP는 프록시 기반으로 동작하지만, 실제로 여러 어노테이션이 복합적으로 붙여진 메소드가 호출된다면 어떻게 실행되는지 알아보자.
@Cacheable
@Transactional
public Object doSomething(){
...
}
@Async
@Transactional
public Object doSomething2(){
...
}
실제로 위와 같이 어노테이션을 붙이면 어떻게 동작할까?
@Cacheable + @Transactiaonl 의 경우에는 캐시 추상화의 기능도 적용이 되어야 하고, 트랜잭션도 열려야 한다.
@Cacheable 과 @Transactional 모두 AOP 프록시를 기반으로 동작하기 때문에, 관련 작업을 위한 부가적인 로직이 모두 프록시 객체에서 발생한다.
ex) Cache Hit 여부 검증 및 트랜잭션 Open 로직 등
먼저 스프링에서는 아래와 같은 AopProxy 인터페이스를 제공한다.
AOP를 수행하기 위한 프록시 객체의 생성을 추상화하기 위해 존재한다.
프록시 객체의 생성 방식은 CGLib , JdkDynamicProxy 두 가지가 존재하기 때문에, AopProxy 인터페이스로 추상화를 진행하고
원본 객체의 조건에 따라서 CGLib를 통해 프록시 객체를 생성할지 Jdk Dynamic Proxy를 통해 생성할지를 결정한다.
package org.springframework.aop.framework;
public interface AopProxy {
Object getProxy();
Object getProxy(@Nullable ClassLoader classLoader);
Class<?> getProxyClass(@Nullable ClassLoader classLoader);
}
실제로 아래 사진처럼, CglibAopProxy 와 JdkDynamicAopProxy 클래스가 AopProxy 인터페이스를 implements 하고 있다.

실제 코드를 보면 아래와 같다.
스프링 레벨이 아닌, 순수하게 JDK만 가지고 프록시를 구현하려면 Jdk에서 제공하는 InvocationHandler를 구현해야 하고
Cglib는 런타임에 바이트 코드 조작을 통해서 내부적으로 원본 클래스를 상속하는 클래스를 정의하여 AOP 프록시를 구현한다.
| CglibAopProxy | JdkDynamicAopProy |
|---|---|
![]() | ![]() |
요약하자면, AopProxy 인터페이스는 AOP 프록시 객체를 생성하는 방법을 추상화한 인터페이스이다.
실제로 각각의 AopProxy 구현체들 내부에는
AopChain이 존재한다.
AopChain이란, 하나의 객체에서 수행되는 여러 공통 관심사 로직을 처리하는 로직을 여러 체인 처럼 연결해놓은 것을 말한다.
예를들어 아래와 같은 메소드가 있다고 가정해보자.
@Slf4j
@Component
public class TestService {
@Async
@Transactional
public void test2(){
log.info("{}, 스레드 명 : {}", this.getClass().getName(), Thread.currentThread().getName());
}
}
위 메소드는 @Async에 의한 비동기 동작과
@Transactional에 의한 트랜잭션 동작이 함께 발생해야 한다.
결과적으로는 상위 트랜잭션의 여부와 관계 없이 다른 스레드에서 실행되므로, 트랜잭션이 항상 새롭게 열리는 특징을 가지고 있고
스프링에서 기본적으로 제공하는 ExecutorService 또는 비동기 스레드에서 실행된다는 특징이 있다.
하지만 어떻게 두 가지 기능이 동시에 적용되는 걸까?
그 비밀은 바로 AopChain에 있다.
먼저 아래와 같은 코드로 위의 @Async + @Transactional 메소드를 실행시켰다.
public void run(ApplicationArguments args) throws Exception {
log.info("{}, 스레드 명 : {}", this.getClass().getName(), Thread.currentThread().getName());
service.test2(); <---- Breaking Point
}
위 코드에서 service.test2() 를 호출하는 부분에 브레이킹 포인트를 찍고, 안쪽으로 들어가보자.
안쪽으로 들어오면 DynamicAdvisedInterceptor 의 intercept() 로 오는 것을 볼 수 있다.
(DynamicAdvisedInterceptor는 CglibAopProxy에 static inner class로 존재한다.)

위의 DynamicAdvisedInterceptor는 MethodInteceptor 인터페이스를 구현하고 있으며,
MethodInterceptor는 CGLib 에서 프록시 객체가 원본 객체의 메소드를 호출하기 전에, AOP 부가 로직들을 실행하기 위한 인터페이스이다.
프록시 객체가 원본 객체의 메소드를 호출하기 전에, MethodInterceptor 가 가로채기 때문에 이름이 붙여진 것이라고 이해하면 되겠다.
프록시 객체가 내부적으로 어떻게 MethodInterceptor 를 호출하는지는 여기서는 다루지 않겠다.
package org.springframework.cglib.proxy;
public interface MethodInterceptor extends Callback {
public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
CGLib 방식으로 프록시를 생성할 경우, 원본 객체가 어떤 종류이든 Interface를 implements 하지 않으면, 스프링은 CGLib 방식으로 프록시 객체를 생성한다.
그렇기 때문에 service.test2()를 호출하면 DynamicAdvisedInterceptor 내부로 이동하는 것이다.

지금 현재 브레이크 포인트에 걸린 초록색 줄을 보면,
아래와 같은 코드가 보일 것이다.
List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);
이 코드는 AOP 프록시 객체 내부에서 실행할 여러 공통 관심사 로직을 수행하는 Advice들의 체인이다.
예를 들어, @Async + @Transactional의 경우 스레드 풀에 태스크를 제출하는 로직을 가지는 Advice 와 트랜잭션 매니저를 사용하여 트랜잭션을 시작하는 Advice가 마치 체인처럼 연결되어 있는 것이다.
(서블릿/시큐리티 필터체인과 유사하다고 생각하면 되겠다.)
this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);
위 코드 내부로 들어가게 되면, 아래와 같은 코드로 이동할 수 있다.
아래의 사진에서 중간 과정은 모두 생략하고, return 에 대해서만 살펴보겠다.

위 사진에서 결과만 말하자면
AnnotationAsyncExecutionInterceptor 그리고 TransactionalInterceptor 가 리스트에 담겨있는 것을 볼 수 있다.

AnnotationAsyncExecutionInterceptorExecutorService 에 제출하는 로직을 가지고 있다.TransactionalInterceptorTransactionManager 를 사용해서 트랜잭션을 시작하고, 동기화 매니저를 통해서 동기화를 수행하는 로직을 가지고 있다.위의 결과는 service.test2() 메소드에 @Async 와 @Transactional 이 붙어있었기 때문에, 내부적으로 비동기 작업과 트랜잭션 작업을 수행하는 Advice 들을 가져올 수 있는 것이다. (두 가지 기능을 모두 지원해야 하기 때문에)
만약 @Cacheable 까지 붙어있었다면, 캐싱 관련 Interceptor 도 리스트에 포함이 되어 있었을 것이다.
AOP를 공부하다보면, Interceptor 라는 단어에 대해서 많이 헷갈릴 수도 있다.
실제로 AOP에서 매우 중요한 역할을 하지만, 이름이 같은 두 인터페이스가 존재하기 때문이다.
org.springframework.cglib.proxy.MethodInterceptor org.aopalliance.intercept.MethodInterceptor위와 같다.
다만 역할과 기능이 다르다.
이 인터페이스는 스프링에서 CGLib 방식으로 생성된 프록시 객체가 원본 메소드를 호출하기 전에 가로채서 공통 관심사 로직을 수행하기 위한 인터페이스이다.

처음에 service.test2() 를 호출한 후의 호출 스택을 보면 TestService$$SpringCGLIB$$0 이 호출되고,
MethodInterceptor 의 구현체인 DynamicAdvisedInterceptor 으로 이동하는 것을 볼 수 있다.
그래서 실제 AOP 공통 관심사 로직의 수행은 org.springframework.cglib.proxy.MethodInterceptor 에서 AopChain에 의해 일어난다. (트랜잭션 오픈, 캐시 조회, 비동기 스레드 제출과 같은 작업)
그림으로 그려보면 아래와 같다.

즉, org.sprignframework.cglib 패키지 내에 존재하는 MethodInterceptor는 프록시 객체를 구현하기 위해서 사용되는 인터페이스이다.
반대로 org.aopappliance.intercept 패키지에 존재하는 MethodInterceptor는 공통 관심사 로직을 수행하는Advice의 추상화라고 보면된다.
AnnotationAsyncExecutionInterceptor 가 작업을 비동기 스레드 풀에 제출하는 로직을 소유하는 것처럼
aopalliance 패키지 내에 존재하는 인터페이스는 Advice 들의 최상위 인터페이스라고 할 수 있겠다.
실제로 AnnotationAsyncExecutionInterceptor 과 TransactionalInterceptor 는 아래와 같은 계층 구조를 가진다.
| AnnotationAsyncExecutionInterceptor | TransactionalInterceptor |
|---|---|
![]() | ![]() |
AopChain에 대해서 알고나면 숙지해야 할 사항이 있다.
바로 체인의 순서에 따라서 동작이 달라질 수 있다는 점이다.
개발자가 @Async , @Transactional , @Cacheable 과 같은 어노테이션들을 한 번에 사용했다고 해보자.
이때 중요한 것이 AopChain 의 내부 순서이다. 즉 @Async 로직을 담당하는 interceptor 가 먼저 수행될 수도 있고, @Cacheable 로직을 담당하는 interceptor 가 먼저 수행될 수도 있다.
시큐리티 필터체인을 구성할 때도 중요한 것이 필터 체인들의 순서였기에, Aop에서도 마찬가지로 내부 체인들의 순서가 중요하다.
내부 interceptor 들의 순서를 알고 있으면 트랜잭션이 먼저 열리고 -> 캐시를 조회하고 -> 비동기가 수행되는 것과 같은 아주 세밀한 동작을 파악할 수 있을 것이다.
또한 여러 개의 어노테이션을 사용할 때도, 문제가 발생하면 어디서 발생했는지 오류를 해결하기 쉬울 것이다.
지금까지 설명한 내용들을 그림으로 도식화 해보면 다음과 같다.

수정) 초기 흐름도가 잘못된 흐름으로 그려져 있던 오류 수정
service.test2() 호출test2() 호출MethodInterceptor.intercept(..) 호출test2() 호출Spring AOP을 이용하면, 원본 객체의 프록시 객체가 MethodInterceptor 를 대신 호출하여 공통 AOP 로직을 수행한다.
이 MethodInterceptor 내부에는 실제 프록시 기반으로 동작하는 공통 로직들(@Cacheable, @Transaction, @Async 등)이 체인처럼 연결되어 있다. 이 체인들이 모두 실행된 후에, 다시 프록시 객체로 돌아오게 되고 원본 객체의 메소드가 실행된다.
스프링 AOP는 스프링을 사용하는 개발자라면 필수로 알고있어야 하는 개념이며,
내부적으로 어떻게 동작하는지 이해하면, 내부 원리를 몰랐을 때보다 스프링이 제공하는 기능들을 더 정교하게 사용할 수 있을 것이다.
@EventListener의 경우에도 스프링 AOP 프록시 기반으로 동작하는지 궁금했는데,
찾아본 결과 AOP 프록시는 프록시 패턴을 적용하기 위해서 사용하는 방법이고
이벤트를 구독하고 수신하여 처리하는 @EventListner는 구독/발행 패턴에 속하기 때문에
Spring AOP 로직으로 동작하지 않는다.
하지만 디버깅을 해보면 ApplicationEventMulticaster를 사용해서, 특정 이벤트를 구독한 리스너들에게 이벤트를 전달하는 방식으로 구현이 되어 있다.