포스트

Spring

spring-internals-lab로 다시 읽는 Spring 6 - 프록시와 AOP

시리즈 spring-internals-lab로 다시 읽는 Spring 9편 중 6편 spring-internals-lab로 다시 읽는 Spring
  1. 1 spring-internals-lab로 다시 읽는 Spring 1 - 이 프로젝트는 무엇을 검증하려는가
  2. 2 spring-internals-lab로 다시 읽는 Spring 2 - BeanDefinition과 등록 단계
  3. 3 spring-internals-lab로 다시 읽는 Spring 3 - refresh()와 Bean 생성 파이프라인
  4. 4 spring-internals-lab로 다시 읽는 Spring 4 - 의존성 주입과 후보 선택
  5. 5 spring-internals-lab로 다시 읽는 Spring 5 - Bean lifecycle과 후처리기
  6. 6 spring-internals-lab로 다시 읽는 Spring 6 - 프록시와 AOP
  7. 7 spring-internals-lab로 다시 읽는 Spring 7 - @Transactional의 실체
  8. 8 spring-internals-lab로 다시 읽는 Spring 8 - DispatcherServlet과 MVC 요청 흐름
  9. 9 spring-internals-lab로 다시 읽는 Spring 9 - Spring Boot는 무엇을 자동으로 조립하는가

GitHub 저장소

이번 글의 질문

Spring AOP를 이해할 때 가장 먼저 잡아야 하는 건 @Aspect 문법이 아니다. 그보다 더 아래 질문이 있다.

  • 언제 JDK 프록시를 쓰고 언제 CGLIB을 쓰는가
  • 인터셉터 체인은 어떻게 진행되는가
  • final, private, self-invocation은 왜 AOP를 우회하는가
  • 수동 ProxyFactory와 자동 프록시 생성기는 어떻게 연결되는가

이번 글은 proxy-playground, method-timing-post-processor, mini-aop, mini-auto-proxy를 묶어서 AOP의 실체는 프록시 + 인터셉터 체인이라는 점을 확인한다.

프록시 선택 규칙은 생각보다 단순하다

기본 원칙은 대상이 인터페이스를 구현하는가로 갈린다.

대상기본 선택
인터페이스 구현JDK 동적 프록시
인터페이스 없음CGLIB 계열 서브클래스 프록시

공식 문서도 같은 규칙을 적는다(Proxying Mechanisms). ProxyFactory 실험에서도 이 규칙이 그대로 재현된다.

1
2
ProxyFactory pf = new ProxyFactory(new GreetableImpl());  // 인터페이스 있음
ProxyFactory pf2 = new ProxyFactory(new PlainGreeter());  // 인터페이스 없음

어떤 프록시가 선택되느냐에 따라 다음 제약이 따라온다.

  • JDK 프록시는 원본 클래스로 캐스팅할 수 없다.
  • CGLIB 프록시는 서브클래스이므로 원본 타입처럼 보일 수 있다.
  • final 클래스와 final 메서드는 CGLIB에서도 제약이 생긴다.

인터셉터 체인은 양파 구조다

AOP의 실체를 가장 잘 보여주는 타입은 ReflectiveMethodInvocation이다.

1
2
3
4
5
6
public Object proceed() throws Throwable {
    if (++index == interceptors.size()) {
        return method.invoke(target, arguments);
    }
    return interceptors.get(index).invoke(this);
}

proceed()가 호출될 때마다 다음 인터셉터로 한 칸 들어가고, 마지막에 target 메서드를 호출한 뒤 역순으로 빠져나온다.

flowchart LR
    A["Interceptor 1 before"] --> B["Interceptor 2 before"]
    B --> C["Interceptor 3 before"]
    C --> D["Target Method"]
    D --> E["Interceptor 3 after"]
    E --> F["Interceptor 2 after"]
    F --> G["Interceptor 1 after"]

실험에서도 Logging -> Authorization -> Timing 순서가 정확히 이런 양파 구조로 실행된다.

트랜잭션도, 측정도, 예외 변환도 이 구조 위에서 target 호출 앞뒤를 감싸는 인터셉터로 구현된다.

final과 private는 왜 우회되는가

실험 결과는 다음과 같다.

  • final 메서드: 인터셉터를 거치지 않는다.
  • private 메서드: 애초에 프록시 대상이 아니다.

이유는 언어 차원 제약 때문이다.

  • CGLIB/ByteBuddy 방식은 메서드 오버라이드가 가능해야 가로챌 수 있다.
  • final은 오버라이드가 불가능하다.
  • private는 서브클래스 입장에서 보이지도 않는다.

공식 문서도 final 메서드와 private 메서드를 같은 이유로 묶는다. 둘 다 “cannot be advised, because they cannot be overridden”(오버라이드할 수 없으므로 어드바이스를 적용할 수 없다)(Proxying Mechanisms). 그래서 이 제약은 Spring의 정책이라기보다 서브클래스 기반 프록시라는 구현 방식에서 나온다.

self-invocation이 안 되는 이유도 결국 객체가 둘이기 때문이다

이 문제는 문서에서 자주 읽지만, 코드로 보면 훨씬 명확하다.

sequenceDiagram
    participant Caller
    participant Proxy
    participant Target

    Caller->>Proxy: outerMethod()
    Proxy->>Target: outerMethod()
    Target->>Target: this.innerMethod()
    Note right of Target: 프록시로 되돌아가지 않음

프록시는 바깥에서 들어오는 호출만 가로챈다. target 안의 this.innerMethod()는 target 자신에게 가므로 프록시를 다시 통과하지 않는다.

이건 프록시를 누가 만들었는지와 무관하다.

  • 수동 ProxyFactory
  • 수동 BeanPostProcessor
  • 자동 프록시 생성기

어느 쪽이든 결과가 프록시 객체와 원본 객체 두 개로 나뉜다면 self-invocation 문제는 그대로 남는다.

자동 프록시 생성기는 새 알고리즘이 아니라 타이밍 자동화다

MethodTimingBeanPostProcessor와 DefaultAdvisorAutoProxyCreator를 비교해 보면 둘이 같은 일을 한다는 것이 보인다.

수동 버전은 대략 이렇다.

1
2
3
4
if (!AopUtils.canApply(advisor, bean.getClass())) return bean;
ProxyFactory proxyFactory = new ProxyFactory(bean);
proxyFactory.addAdvisor(advisor);
return proxyFactory.getProxy();

자동 버전은 프레임워크가 같은 질문을 대신 묻는다.

  • 이 Bean에 Advisor를 적용할 수 있는가
  • 적용 가능하면 프록시를 만들 것인가

자동 프록시 생성기는 새로운 AOP 원리를 추가하지 않는다. 같은 판정과 같은 프록시 생성을 컨테이너 파이프라인의 정해진 시점에서 대신 수행한다.

BeanPostProcessor로 만든 AOP와 자동 프록시의 공통점

두 방식을 나란히 놓으면 다음과 같다.

구분수동 BPP자동 프록시 생성기
프록시 생성 시점postProcessAfterInitializationpostProcessAfterInitialization
대상 판정직접 canApply 호출내부에서 canApply 계열 호출
최종 산출물프록시프록시
self-invocation 한계있음있음

결과는 같고, 차이는 누가 wiring(프록시를 만들어 Bean 자리에 끼워 넣는 일)을 담당하느냐에 있다.

BeanPostProcessor를 @Bean으로 만들 때 static이 안전한 이유

실험에서 실제로 걸린 함정도 있다. BeanPostProcessor를 @Configuration 안의 instance @Bean 메서드로 선언하면, Spring이 경고를 남긴다.

원인은 생성 순서다.

  • BeanPostProcessor는 다른 Bean들이 생성되기 전에 먼저 등록돼야 한다.
  • instance @Bean 메서드는 설정 클래스 인스턴스가 먼저 필요하다.
  • 그래서 후처리기를 만들려고 설정 클래스가 일찍 생성되고, 그 안의 다른 Bean은 후처리를 다 받지 못할 수 있다.

@Bean Javadoc에 따르면 non-static 메서드가 BeanPostProcessor를 반환하면 @Configuration 클래스가 일찍 초기화되고, static으로 두면 설정 클래스를 인스턴스화하지 않고 호출할 수 있다(Bean Javadoc).

그래서 static @Bean은 스타일 차이가 아니라, 후처리기를 만들 때 설정 클래스를 끌어오지 않기 위한 선택이다.

mini-spring은 AOP의 핵심만 남긴다

mini-aop와 mini-auto-proxy는 이 구조를 최소한의 타입으로 다시 만든다.

  • MethodInterceptor
  • MethodInvocation
  • MiniProxyFactory
  • MiniAutoProxyCreator

특히 MiniAutoProxyCreator는 “후처리기와 프록시 생성이 만나는 지점”을 코드 구조로 드러낸다.

그래서 Spring AOP를 이해할 때는 @Aspect 문법보다 다음 연결을 먼저 보면 된다.

1
2
3
4
Bean 생성 완료
→ BeanPostProcessor 개입
→ 조건 맞으면 프록시로 교체
→ 이후 모든 외부 호출은 인터셉터 체인 경유

정리

이 글의 주제들은 BeanPostProcessor가 Bean을 프록시로 바꾸는 한 지점에서 만난다. 수동이든 자동이든 그 지점이 같으므로 한계도 같다.

이제 다음 글에서 이 구조 위에 @Transactional이 어떻게 올라가는지 본다. 결국 트랜잭션도 별도 마법이 아니라, 인터셉터 체인 위에 얹힌 하나의 어드바이스다.

참고

  • Proxying Mechanisms — Spring Framework Reference, JDK·CGLIB 선택과 self-invocation
  • Bean — Spring Framework Javadoc, BeanPostProcessor-returning @Bean methods
Spring과 JVM 백엔드
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

변경이력

1번 수정

  1. docs(notes): correct the static bean post-processor reasoning in proxy aop

댓글

아직 댓글이 없습니다