⚠️ Spring의 Proxy 기반 동작을 모르면 이해하기 어렵습니다.
1. Introduction
📌 How Does Spring Determine Proxy Order?
바로 이전 포스팅에서 kotlin의 trailing lambda를 사용해 Spring AOP를 벗어나는 방법에 대해 알아보았다.
그런데 포스팅을 쓰면서, '여러 AOP가 동작하는 경우엔 어떻게 해야 하지?'라는 의문이 자연스레 뒤따라 왔다.
물론 kotlin이 익숙칠 않아서 넘긴 것도 있지만, 그보다 Proxy 실행 순서를 알 수가 없었다.
@Service
class UserService {
@Transactional
@Cacheable
public User createUser(UserSaveCommand command) { ... }
}
예를 들어, 위와 같은 형태의 메서드라면 @Transactional이 우선 선택될까, 아니면 @Cacheable이 먼저 선택될까?
- 두 어노테이션만 봤을 때는 @Cacheable에 해당하는 Advice가 우선 실행되는 것이 합리적이다.
- 그러나, Spring과 그와 관련된 모든 Advice가 고정 상수로 순서를 할당해두었을까? 그게 가능은 한 일인가?
- 만약 그렇다면, 개발자는 편의를 위해 사용하는 모든 어노테이션의 순서를 알아야 한다.
아마도 느낌 상으로는 선언된 순서대로 실행되지 않을까~ 라는 감이 오긴 했으나,
이는 내가 Proxy 동작 방식을 제대로 이해하지 못 하고 있는 걸 설명할 뿐이다.
그럼 뭐다? 찢어버려야지.
평소처럼 블로그 제목 값 하러 떠나봅시다.
2. How does Spring create a proxy instance and execute it?
📌 Register Proxy Bean
⚠️ @Transactional이 선언된 클래스 메서드의 Bean 초기화부터 Proxy 생성, 실행까지 모든 과정을 분석하기 위한 파트입니다.
@Slf4j
@DomainService
@RequiredArgsConstructor
public class ChatMemberBanService {
@Transactional
public void execute(ChatMemberBanCommand command) {
}
}
가장 친숙하고, 그렇기에 가장 만만한 Tx를 시작으로 알아보자.
이 서비스는 그저 호출하면 Tx가 실행되기만 하고 꺼지도록 만들었다.
예전 같았으면, 무식하게 IDE에서 ctrl + 마우스 좌클릭을 연타해가며 Advice를 찾았겠지만, 이번에는 조금 더 현명하게 찾아보자.
@Slf4j
@SpringBootTest
public class ChatMemberBanServiceIntegrationTest {
@Autowired
private ChatMemberBanService sut;
@Test
void checkProxyTest() {
// 실제 객체가 프록시인지 확인
assertTrue(AopUtils.isAopProxy(sut));
// 어떤 Advisor들이 적용되었는지 확인
if (sut instanceof Advised) {
Advisor[] advisors = ((Advised) sut).getAdvisors();
for (Advisor advisor : advisors) {
log.info("Applied advisor: {}", advisor.getClass().getName());
log.info("Advice type: {}", advisor.getAdvice().getClass().getName());
}
}
}
}
멀티 모듈이다보니 실제로는 이보다 훨씬 복잡한 설정이 필요하지만, 자질구레한 설정은 모두 제거해버렸다.
이 테스트의 역할은 다음과 같다.
(용어의 혼동을 막기 위해 우선, SUT은 Service Under Test, 즉 테스트 타겟 컴포넌트다.)
- 선언적 Tx가 붙은 클래스 혹은 메서드는 Spring이 Proxy로 만든다고 한다. 그렇다면 SUT 또한 프록시임이 검증되어야 한다. (Spring Context가 잘 올라갔는지 확인용)
- SUT이 Advice된 객체라면, 할당된 Advisor 정보들을 차례로 출력한다.
이를 실행해보면, 다음과 같은 로그를 얻을 수 있다.
Applied advisor: org.springframework.transaction.interceptor.BeanFactoryTransactionAttributeSourceAdvisor
Advice type: org.springframework.transaction.interceptor.TransactionInterceptor
저곳으로 가면 해답을 얻을 수 있을까?
이름만 보면 대충 BeanFactoryTransactionAttributeSourceAdvisor가 받아서 Tx 설정을 수행하면, Interceptor가 최종 과정을 수행하는 거 같은데, 무작정 의존성을 체크할 순 없으니 조금 더 현명하게 접근해보자.
나의 겸손하기 그지 없는 두뇌에 들어있는 지식에 의하면, Spring Container 초기화 과정은 다음과 같다.
- 애플리케이션 컨텍스트 생성 및 설정 로드
- BeanFactoryPostProcessor 실행
- BeanPostProcessor 등록
- 싱글톤 빈 인스턴스화
- 프록시 필요성 검사
- 프록시 생성
Spring이 가장 처음 Bean을 초기화하기 위해 org.springframwork.context.support.AbstractApplicationContext 클래스의 refresh()를 호출하며 시작한다고 알고 있다.
온갖 디자인 패턴이 난무하는 진귀한 풍경을 보고 감탄하다가, 정신차렸더니 이상한 클래스까지 넘어가버려서 혼났다.
여튼 refresh() 주석을 보면 AnnotationAwareAspectJAutoProxyCreator 같은 프록시 담당 객체들을 등록하는 부분이 있다.
아마도 어노테이션마다 제각기 Proxy로 만드는 담당자가 다른 거 같다.
(그야, @Transactional 처리하는 프록시가 @PreAuthorize 까지 담당하진 않을 테니, 당연한 이야기다.)
그리고 finishRefresh() 바로 전 단계에서 finishBeanFactoryInitalization()를 호출하는데 모든 싱글톤 빈을 초기화 한다고 나와있다.
@Configuration 설정의 빈들을 가장 먼저 초기화하고, 마지막으로 나머지 싱글톤 빈들을 죄다 등록한다고 한다.
AbstractBeanFactory가 이끄는 대로 getBean() 메서드를 계속 추적하면, 드디어 org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory이 등장한다.
이 코드를 보면 주의 깊게 봐야하는 시점이 있는데,
// Give BeanPostProcessors a chance to return a proxy instead of the target bean instance.
Object bean = resolveBeforeInstantiation(beanName, mbdToUse);
if (bean != null) {
return bean;
}
510~514번째 라인에서, 실제 빈을 생성하기 전에 Proxy를 반환할 기회를 준다고 적혀있다.
즉, AbstractApplicationContext에서 등록했던 BeanPostProcessor들 중, 해당 빈을 처리하기 보다 적합한 존재가 있다면, 역할을 위임하겠다는 것이다.
그런데 찾아보니 @Transactional의 경우엔 여기서 처리되지 않는다고 한다.
resolveBeforeInstantiation()으로 초기화되는 빈들은 보통 빈 인스턴스가 생성되기 전에 프록시를 만들 기회를 제공하고 싶은 경우, 즉 초기화가 너무 무거운 케이스들에 대한 빠른 경로를 제공해주기 위해 사용한다고 한다. (GPT 피셜)
그럼 내가 살펴봐야 할 건 doCreateBean 쪽으로 가봐야 한다.
여기가 본격적으로 인스턴스 생성, 프로퍼티 설정, 초기화 메서드 호출, 마지막으로 BeanPostProcessor 들을 적용하는 부분이다. (코드가 너무 길어서, 네모 박스는 생성만 보여주고 있다.)
- createBeanInstance(beanName, mdb, args)
- 실제 빈의 인스턴스 생성
- 생성자를 통해 순수한 자바 객체를 생성한다. (의존성 주입 전)
- populateBean(beanName, mdb, instanceWrapper)
- 생성된 인스턴스에 프로퍼티 값들을 설정한다.
- 이 단계가 끝나면, 빈이 필요로 하는 모든 의존성이 주입된 상태가 된다.
- exposedObject = initializeBean(beanName, exposedObject, mdb)
- 초기화 메서드(@PostConstruct 등)가 실행된다.
- 마지막으로 BeanPostProcessor들이 실행. (여기서 AnnotationAwareAspectJAutoProxyCreator가 @Transactional을 발견하고, 냅다 프록시를 만들어 버린다.)
이번 내용하곤 상관 없지만, 순환 참조도 여기서 처리하고 있었다. 덜덜
(3)번 단계를 실제로 조금 더 타고 들어가면, 이런 코드를 볼 수 있다.
현재 만들어진 빈에다가 Processor들을 하나씩 대조해가면서, Proxy를 만들 지 판단하고 있다.
지긋지긋한 Deprecated가 날 반겨주지만, 지금도 머리 터질 거 같은데 저런 거까지 신경 써 줄 겨를이 없다.
지금까지 알아본 순서를 정리하면 다음과 같다.
finishBeanFactoryInitialization(beanFactory)
→ beanFactory.preInstantiateSingletons() // 모든 싱글톤 빈 초기화 시작
→ createBean() // ChatMemberBanService 생성
→ doCreateBean() // 실제 인스턴스 생성
→ initializeBean() // 생성된 빈 초기화
→ applyBeanPostProcessorsAfterInitialization() // 후처리기 적용
→ AnnotationAwareAspectJAutoProxyCreator.postProcessAfterInitialization()
📌 AnnotationAwareAspectJAutoProxyCreator
일단 더 파고 들어가기 전에, 여기서 다시 한 번 재정비를 해주자.
@Slf4j
@Component
class ProxyTracker implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
beanFactory.addBeanPostProcessor(new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof ChatMemberBanService) {
log.info("=== ChatMemberBanService bean creation ===");
log.info("Bean name: {}", beanName);
log.info("Bean class: {}", bean.getClass().getName());
}
return bean;
}
});
}
}
ChatMemberBanService가 빈으로 등록되는 시점을 로그로 구분할 수 있게 만들어봤다.
CglibAopProxy? AnnotationTransactionAttributeSource?? 뭐 이상한 게 보이긴 하는데, 중간에 내가 찾고 있던 BeanProcessor도 같이 등장한다.
하지만 AnnotationAwareAspectJAutoProxyCreator를 아무리 뒤져봐도, postProcessAfterInitialization()은 보이지 않는다.
상속 관계를 확인해보면,
AnnotationAwareAspectJAutoProxyCreator
-> AspectJAwareAdvisorAutoProxyCreator
-> AbstractAdvisorAutoProxyCreator
-> AbstractAutoProxyCreator
여기까지 도달해야 원하는 메서드를 찾을 수 있게 된다.
wrapIfNecessary()를 보면 Proxy 생성 전에, 해당 Service에 매칭되는 Advice와 Advisor를 가져오는 단계가 있다.
쭉쭉 들어가보면, Advisor 후보 리스트를 골라서, 그 중에서도 가장 적합한 걸 고르고, 없으면 DO_NOT_PROXY를 던지게끔 만들어 놨는데 여기가 가장 중요한 부분이다.
정확히는 getAdvicesAndAdvisorsForBean()이 내부 동작 때문인데, 여기서 결정된 Advisor의 순서가 결정된다.
다만, 여길 딥하게 파면 끝이 없어져서, 왜 중요한 지는 [Section 3.3 - The Hidden Complexity of Advisor Ordering]에서 다룰 것이다.
뭐, 여튼 ChatMemberBanService는 처음으로 해당 BeanProcessor에 노출되어서 그런지 DO_NOT_PROXY를 받고, PROXY 생성 단계로 넘어가게 되었다.
여기서 JDK 동적 프록시 혹은 CGLIB 프록시를 생성한다는데, 우리는 로그에서 확인했듯이 CglibAopProxy 로그가 나왔고, 주석에 CGLIB 기반 AopProxy라고 적혀있었다.
좀 더 추가하자면, ChatMemberBanService가 인터페이스를 구현하지 않으면 CGLIB, 인터페이스를 구현하면 JDK Dynamic Proxy를 적용한다고 한다.
그래서인지 Interface 형태인 Repository 들은 JDK dynamic proxy로 생성된다.
이게 끝나면, BeanFactoryTransactionalAttributeSourceAdvisor가 Proxy에 추가되고, (사진을 빼먹었는데) TransactionInterceptor가 Advice로 설정되는 단계를 거치게 된다.
얘네가 뭐냐고 한다면, 가장 처음에 로그로 확인했던 애들이었다. ㅋㅋㅋ
📌 Execution Proxy Method
정확히 Proxy가 호출되는 시작점을 찾고 싶었으나, 대차게 실패했다.
당연히 CglibAopProxy의 여러 invoke() 메서드 중 하나겠거니 싶었는데 디버깅에 잡히지도 않고, MethodInterceptor 인터페이스에도 잡히질 않는다.
아쉽지만, BeanFactoryTransactionAttributeSourceAdvisor부터 시작해야겠다.
org.springframework.transaction.interceptor.BeanFactoryTransactionAttributeSourceAdvisor는 Pointcut(어디에 적용할지)과 Advice(무엇을 적용할지)에 대한 정보를 가지고 있다.
Proxy의 메서드가 호출되면, 해당 메서드에 연결된 Advisor를 호출하는데, 그게 BeanFactory~Advisor다.
실제로 sut.execute()를 하면, TransactionalInterceptor 보다도 우선 호출된다.
"어디에 적용할지"라는 정보를 담고있는 TransactionAttributeSourcePointcut은 내부에서 matches() 메서드로, 주어진 메서드에 @Transactional이 적용되어야 하는지 검사한다.
그 후, 상속받은 클래스인 AbstractBeanFactoryPointcutAdvisor의 getBean()을 호출하면, 여기서 TransactionalInterceptor을 반환한다.
그러면 TransactionInterceptor에 도달하여, Proxy 실행(invocation.proceed()) 전에 Tx를 실행하는 것이다.
정리하자면 다음과 같다.
메서드 호출
→ BeanFactoryTransactionAttributeSourceAdvisor 검사
→ TransactionAttributeSourcePointcut이 메서드 확인
→ TransactionAttributeSource가 @Transactional 속성 분석
→ 적용 대상이면 TransactionInterceptor 실행
3. How Does Spring Determine the Order of Advisors for Execution?
📌 Default Order
@Transactional 마냥, @Cacheable도 전부 까보기엔 시간이 너무 오래 걸리니, 간단하게 확인해보자.
@Service
public class ChatMemberBanService {
@Transactional
@Cacheable(value = "somethingResult", key = "#command.adminId()")
public String execute(ChatMemberBanCommand command) {
return "success";
}
}
execute() 메서드에 @Cacheable을 선언하면, Spring은 순서를 어떻게 결정할까?
우리는 당연히 캐싱이 먼저 실행되는 것이 합리적이라는 것을 알고 있다.
하지만, Spring이 이러한 합리성을 판단하고, 적절하게 순서를 보장할 수 있을까?
@Cacheable을 적용하면,
- advisor: BeanFactoryCacheOperationSourceAdvisor
- advice: CacheInterceptor
임을 확인할 수 있는데, 주목해야 할 것은 TransactionalInterceptor보다 앞선다는 것이다.
// 어떤 Advisor들이 적용되었는지 확인
if (sut instanceof Advised) {
Advisor[] advisors = ((Advised) sut).getAdvisors();
for (Advisor advisor : advisors) {
log.info("======= Applied advisor: {} =======", advisor.getClass().getName());
log.info("======= Advice type: {} =======", advisor.getAdvice().getClass().getName());
}
}
우리는 SUT 객체의 Advisor를 순차적으로 출력했기 때문에, 실제 Spring이 proxy의 advice를 호출하는 것도 별반 다르지 않을 것이다.
sut.execute(ChatMemberBanCommand.of(1L, 2L, 3L));
sut.execute(ChatMemberBanCommand.of(1L, 2L, 3L));
똑같은 메서드를 두 번 호출해보자.
가설이 들어맞는다면, 위에서 메서드 실행 결과를 캐싱하면, 두 번째 메서드는 실행이 되지 않을 것이다.
만약 Tx Advice가 먼저 동작한다면, 적어도 "Getting transaction"은 두 번 실행되었어야 함이 맞다.
하지만 실제로는 한 번밖에 출력되지 않으므로, 캐싱이 Tx보다 우선 수행된다는 것을 알 수 있다.
이건 직접 보고도 믿기 힘든 결과였는데, 22년도 자료들만 봐도 둘 중 무엇이 먼저 호출될 지 알 수 없다는 이유였다.
그런데 이걸 몇 번을 돌려봐도 같은 결과가 나온다면, 큰 수의 법칙에 의해 언제나 캐싱 Advice가 우선 동작한다고 볼 수 있는 게 아닐까?
그렇다면, 그 사이에 Spring Boot 개발자들은 진정한 "Convention over Configuration"이라는 Spring Boot 철학을 이뤄내버렸다는 것인가?
📌 Advisors Determine Proxy Order
@TestConfiguration
@EnableCaching
@EnableTransactionManagement
class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("somethingResult");
}
}
Spring Boot 개발자라면, 다소 생소할 @EnableTransactionManagement를 명시적으로 선언해주었다.
왜냐면 스부는 @EnableAutoConfiguration 안에 기본으로 포함되어 있으며, 이는 @SpringBootApplication에 포함되어 있으므로, 나도 모르는 새에 이미 적용되어 있기 때문이다.
이대로 실행하면, 당연히 이전과 똑같은 결과를 얻는다.
그런데 재밌는 점은 @EnableCaching과 @EnableTransactionManagement의 순서를 역전하면, 결과가 뒤집어 진다는 것이다.
즉, Advice의 실행 순서는 Proxy에 선언된 어노테이션 순서가 아니라, Spring Container에 등록된 Advisor의 순서에 의해 결정된다고 볼 수 있다.
(절대적이라고 보기는 어렵다. 이 다음의 detail 파트에서 알아보자.)
그리고 이 순서는 Advisor의 order로 설정하는데, 이 둘은 모두 기본으로 LOWEST_PRECEDENCE를 가지고 있다.
그런데 왜 지금까지는 무조건 Caching이 먼저 동작했던 걸까?
스프링의 자동 설정들은 일반적으로 "해당 빈이 존재하지 않으면 등록한다"라는 조건을 건다.
예를 들어, 개발자가 TransactionManagerment를 명시적으로 빈에 올라오지 못 하게 막은 게 아니라면, 자동으로 빈을 추가하게 되는 것이다.
그런데 이 작업은 개발자가 직접적으로 명시한 모든 Configuration Bean을 모두 설정한 이후에 수행한다.
즉, 우리가 명시적으로 선언한 캐싱과 관련한 Advisor가 먼저 올라온 후, 그 다음으로 자동 설정에 의해 Tx Advisor가 등록되기 때문에 언제나 캐싱이 앞설 수 있었던 것이다.
만약 @EnableTransactionManagement를 명시적으로 @EnableCaching 보다 먼저 선언하면, 이 결과가 뒤집힐 수 있게 되는 것이다.
📌 The Hidden Complexity of Advisor Ordering
이 파트는 쓰기가 조심스러운데, 너무 어려워서 정확하지도 않고,
시작부터 결론을 어떻게 내야 할 지 모르겠어서 안 쓰려다가 최대한 다듬어서 써보기로 했다.
Advisor가 언제나 등록된 순서대로 호출된다고 했지만, 실제로는 그게 확실하다고 보기 어렵다.
AbstractAdvisorAutoProxyCreator.sortAdvisor()는 AnnotationAwareOrderComparator라는 비교 클래스로 Ordered 구현체를 우선 정렬하는 단계를 거친다.
물론 이는 추상 클래스의 경우고, 구체 클래스에서는 다른 조건들을 더 추가해두기도 했다.
이 함수는 어디서, 언제 실행될까?
위에서 잠깐 언급하고 넘어갔던 getAdvicesAndAdvisorForBean() 녀석을 다시 살펴보도록 하자.
해당 메서드를 들어가보면 findEligibleAdvisors, 즉 자격이 있는 Advisors 함수를 또 한 번 호출한다.
이 함수는 다음과 같이 동작한다.
- 후보가 되는 Advisor들을 탐색한다.
- (1)에서 자격이 있는 Advisor들을 필터링한다.
- (2)의 결과값이 존재한다면, Advisor를 정렬한다.
(3)에서 드디어 sortAdvisors()가 등장했다.
추상 클래스의 AdvisorAutoProxyCreator는 단순히 Order를 기준으로 정렬하지만, 구체 클래스에서 정렬 로직을 어떻게 바꾸는 가에 따라서
캐시 어드바이스가 앞에 올 수도, 트랜잭션 어드바이스가 앞에 올 수도 있게 된다.
즉, Spring이 Advisor의 순서를 결정하는 것은 단순한 등록 순서나 @Order 값보다, 더 복잡한 규칙들의 조합이 적용될 수도 있다는 점이다.
4. Conclustion
📌 마무리를 어떻게 해야 되나...
이렇게까지 딥하게 조사할 생각은 아니었는데, 재밌어서 끝도 없이 파고 들어버렸다.
덕분에 마무리를 어떻게 해야 할 지 감이 오질 않는다.
10분 정도 고민해봤는데, 그냥 정리만 하고 끝내야겠다.
- @Transactional, @Cacheable 등이 선언된 메서드를 만나면, Spring은 인스턴스 생성 후, Advice로 감싸진 Proxy 객체를 만든다.
- Proxy가 생성되면 Advisor과 Advice들이 배열로 넘겨지는데, 정렬 조건에 따라 Advisor 호출 순서가 결정된다.
- order 값이 동일하면 Advisor의 호출 순서를 보장할 수 없다.