Spring AOP의 두 얼굴: JDK Dynamic Proxy vs CGLIB

seonwoo_jung·2026년 5월 25일

1. 도입

@Transactional이 인터페이스에 붙었을 땐 잘 동작하다가, 같은 클래스 안에서 다른 메서드를 호출(self-invocation)했더니 트랜잭션이 안 걸리는 걸 보고 "이게 어떻게 만들어진 객체인가" 부터 다시 정리했다. Spring AOP는 결국 프록시 객체를 끼워 넣는 방식이고, 그 프록시를 만드는 방법이 두 가지다. JDK Dynamic Proxy와 CGLIB. 어떤 기준으로 갈리는지, 동작 차이가 무엇인지 따라가 본 메모.

2. 핵심 개념

Spring AOP는 런타임 프록시 기반이다. 컴파일 타임 위빙(AspectJ)이 아니라, 빈을 감싸는 다른 객체를 만들어 끼워 넣는다.

Spring Reference §6.8에 따르면, AOP 적용 대상 빈은 컨테이너가 원본 대신 프록시 인스턴스를 등록한다. 두 가지 구현 전략이 있다.

  • JDK Dynamic Proxy: 인터페이스 기반. java.lang.reflect.Proxy로 인터페이스를 구현한 동적 클래스를 만든다.
  • CGLIB: 클래스 기반. 대상 클래스를 상속한 서브클래스를 바이트코드로 생성한다.

선택 규칙(공식 문서 기준):

  • 대상이 하나 이상의 인터페이스를 구현 → 기본은 JDK Proxy.
  • 인터페이스가 없거나, proxyTargetClass=true로 강제 → CGLIB.
  • Spring Boot 2.x부터는 기본값이 CGLIB로 바뀐 것으로 알려져 있다(spring.aop.proxy-target-class=true).

3. 내부 동작

JDK Dynamic Proxy

// 의사 코드: Spring이 내부적으로 하는 일과 유사
Object proxy = Proxy.newProxyInstance(
    classLoader,
    new Class<?>[]{ MyService.class }, // 인터페이스 배열
    (p, method, args) -> {
        // before advice
        Object ret = method.invoke(target, args); // 실제 빈에 위임
        // after advice
        return ret;
    });

핵심은 인터페이스 타입으로만 캐스팅 가능하다는 점이다. MyServiceImpl impl = (MyServiceImpl) proxy;ClassCastException이 난다. 의존성 주입을 인터페이스 기준으로 받으면 문제가 없지만, 구현체 타입으로 받으면 깨진다.

CGLIB

대상 클래스를 상속한 서브클래스를 만들어 메서드를 오버라이드하고, 그 안에서 인터셉터(MethodInterceptor)를 호출한다.

MyService (원본)
   ▲
   │ extends
MyService$$EnhancerBySpringCGLIB$$abc  (런타임 생성)
   - 메서드를 오버라이드해서 advice 체인 호출

제약:

  • final 클래스나 final 메서드는 오버라이드 못 함 → 어드바이스가 안 걸린다.
  • 서브클래스이므로 부모 클래스 생성자가 두 번 호출되는 시점이 있다(상속 구조상 불가피). 생성자에 사이드 이펙트가 있으면 주의.

자주 헷갈리는 지점

  1. Self-invocation 문제: 두 방식 모두 외부에서 들어오는 호출만 가로챈다. 같은 객체 안에서 this.other()로 부르면 프록시를 거치지 않는다. 트랜잭션이 누락되는 전형적 원인.
  2. @Transactionalprivate 메서드에 안 걸리는 이유: CGLIB는 private를 오버라이드할 수 없고, JDK Proxy는 애초에 인터페이스 메서드만 본다.

4. 비교 표

항목JDK Dynamic ProxyCGLIB
전제 조건인터페이스 필요인터페이스 불필요
생성 방식java.lang.reflect.Proxy바이트코드로 서브클래스 생성
캐스팅인터페이스 타입만구현 클래스 타입 가능
final 제약없음(인터페이스 메서드라 무관)final 메서드/클래스 불가
생성 비용상대적으로 가벼움바이트코드 생성으로 더 무거움
기본값(최근 Spring Boot)기본

5. 정리

  • Spring AOP의 프록시는 인터페이스 유무proxyTargetClass 설정에 따라 JDK/CGLIB가 갈린다.
  • 둘 다 외부 호출만 가로채므로 self-invocation은 프록시를 우회한다. @Transactional이 안 먹는 흔한 원인.
  • final이 붙은 클래스/메서드는 CGLIB로도 어드바이스가 안 걸린다는 점을 코드 작성 시 의식하면 디버깅 시간을 줄일 수 있다.

다음에 더 파고들 만한 주제:

  • AspectJ 컴파일 타임/로드 타임 위빙과의 차이 (self-invocation 한계 극복).
  • Spring ProxyFactory API로 직접 프록시를 만들어 advice 체인을 들여다보기.

참고 자료

  • Spring Framework Reference Documentation §6.8 "Proxying Mechanisms"
  • java.lang.reflect.Proxy Javadoc
  • CGLIB 프로젝트 문서 (현재는 Spring 내부에 패키징되어 org.springframework.cglib로 재배포)

0개의 댓글