@Transactional이 인터페이스에 붙었을 땐 잘 동작하다가, 같은 클래스 안에서 다른 메서드를 호출(self-invocation)했더니 트랜잭션이 안 걸리는 걸 보고 "이게 어떻게 만들어진 객체인가" 부터 다시 정리했다. Spring AOP는 결국 프록시 객체를 끼워 넣는 방식이고, 그 프록시를 만드는 방법이 두 가지다. JDK Dynamic Proxy와 CGLIB. 어떤 기준으로 갈리는지, 동작 차이가 무엇인지 따라가 본 메모.
Spring AOP는 런타임 프록시 기반이다. 컴파일 타임 위빙(AspectJ)이 아니라, 빈을 감싸는 다른 객체를 만들어 끼워 넣는다.
Spring Reference §6.8에 따르면, AOP 적용 대상 빈은 컨테이너가 원본 대신 프록시 인스턴스를 등록한다. 두 가지 구현 전략이 있다.
java.lang.reflect.Proxy로 인터페이스를 구현한 동적 클래스를 만든다.선택 규칙(공식 문서 기준):
proxyTargetClass=true로 강제 → CGLIB.spring.aop.proxy-target-class=true).// 의사 코드: 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이 난다. 의존성 주입을 인터페이스 기준으로 받으면 문제가 없지만, 구현체 타입으로 받으면 깨진다.
대상 클래스를 상속한 서브클래스를 만들어 메서드를 오버라이드하고, 그 안에서 인터셉터(MethodInterceptor)를 호출한다.
MyService (원본)
▲
│ extends
MyService$$EnhancerBySpringCGLIB$$abc (런타임 생성)
- 메서드를 오버라이드해서 advice 체인 호출
제약:
final 클래스나 final 메서드는 오버라이드 못 함 → 어드바이스가 안 걸린다.this.other()로 부르면 프록시를 거치지 않는다. 트랜잭션이 누락되는 전형적 원인.@Transactional이 private 메서드에 안 걸리는 이유: CGLIB는 private를 오버라이드할 수 없고, JDK Proxy는 애초에 인터페이스 메서드만 본다.| 항목 | JDK Dynamic Proxy | CGLIB |
|---|---|---|
| 전제 조건 | 인터페이스 필요 | 인터페이스 불필요 |
| 생성 방식 | java.lang.reflect.Proxy | 바이트코드로 서브클래스 생성 |
| 캐스팅 | 인터페이스 타입만 | 구현 클래스 타입 가능 |
final 제약 | 없음(인터페이스 메서드라 무관) | final 메서드/클래스 불가 |
| 생성 비용 | 상대적으로 가벼움 | 바이트코드 생성으로 더 무거움 |
| 기본값(최근 Spring Boot) | — | 기본 |
proxyTargetClass 설정에 따라 JDK/CGLIB가 갈린다.@Transactional이 안 먹는 흔한 원인.final이 붙은 클래스/메서드는 CGLIB로도 어드바이스가 안 걸린다는 점을 코드 작성 시 의식하면 디버깅 시간을 줄일 수 있다.다음에 더 파고들 만한 주제:
ProxyFactory API로 직접 프록시를 만들어 advice 체인을 들여다보기.java.lang.reflect.Proxy Javadocorg.springframework.cglib로 재배포)