
백엔드 개발에서 트랜잭션, 로깅, 보안, 캐싱 등은 핵심 비즈니스 로직이 아닙니다. 관심사의 분리가 필요한 기능들이며, 이 기능들을 코드에 직접 섞으면 관심사의 분리는 무너집니다.
관심사의 분리를 위해 꼭 알아야 할 것은 Spring AOP이며, 추가로 Spring이 AOP를 왜 Proxy 기반으로 구현했는지도 파악해야 합니다.
오늘은 실제로 어떻게 Proxy를 생성하고, 호출을 가로채는지 내부 구조까지 분석해보겠습니다.
Proxy란 대리 객체입니다.
Client -> Proxy -> Target
Spring 컨테이너에 등록되는 Bean은 Target이 아니라 Proxy 객체입니다.
Client -> Proxy (Bean으로 등록)
└── Target
즉, Bean 생성 이후 Target이 Proxy로 치환됩니다. 이것을 이해해야 self-invocation 문제를 이해할 수 있습니다.
Proxy 생성의 핵심은 BeanPostProcessor입니다.
Spring Bean 생성 흐름은 다음과 같습니다.
- Bean 인스턴스 생성
- Dependency Injection
- BeanPostProcessor.beforeInitialization
- 초기화
- BeanPostProcessor.afterInitialization
여기서 AbstractAutoProxyCreator가 afterInitialization 단계에서 개입합니다.
동작 과정은 다음과 같습니다.
즉, Bean 초기화가 끝난 후 최종적으로 Proxy로 교체됩니다.
Spring은 두 가지 방식을 사용합니다.
java.lang.reflect.Proxy 사용구조
Proxy
-> InvocationHandler
-> Method.invoke(target)
특징
생성되는 클래스 예시
UserService$$SpringCGLIB$$0
구조
Target$$SpringCGLIB
override method()
-> MethodInterceptor
-> target 호출
특징
Spring Boot 3 기준 기본값은 CGLIB입니다.
@Transactional이 붙으면 Spring은 TransactionInterceptor를 Advice로 등록합니다.
호출 흐름은 다음과 같습니다.
Controller
-> Proxy
-> TransactionInterceptor
-> PlatformTransactionManager
-> Target
동작 순서
1. 트랜잭션 시작
2. target 메서드 실행
3. commit 또는 rollback
여기서 중요한 점은 트랜잭션 상태는 ThreadLocal 기반으로 관리된다는 점입니다.
현재 트랜잭션 정보는 TransactionSynchronizationManager에 저장됩니다.
문제 코드:
public void outer() {
inner();
}
실제 호출 구조
Client -> Proxy.outer()
-> Target.inner()
inner()는 Proxy를 거치지 않습니다. 즉, Advice가 적용되지 않습니다.
이것은 프록시 기반 구조의 한계입니다.
한계점은 다음과 같습니다.
이 한계를 해결하려면 컴파일 타임 위빙 방식이 필요합니다.
대표적으로 AspectJ가 있습니다.
Spring AOP는 단순 어노테이션 기능이 아닙니다.
위 4가지를 이해해야 @Transactional이 왜 동작하지 않는지를 설명할 수 있습니다.
Spring AOP는 런타임에 Bean을 감싸는 Proxy 객체일 뿐입니다.