같은 객체 안에서 this로 메서드를 부르면 프록시를 거치지 않아 AOP가 적용되지 않는 현상.
@Service
public class OrderService {
public void placeAll(List<Order> orders) {
for (Order o : orders) save(o); // ← this.save(o)
}
@Transactional
public void save(Order o) { ... } // 트랜잭션이 걸리지 않는다
}
왜 이런 일이 생기나
스프링은 빈을 프록시로 감싸서 컨테이너에 등록한다
호출자 → [프록시] → 실제 OrderService.placeAll()
└→ this.save()
this 는 '진짜 객체' 다. 프록시는 이미 통과했다
→ 다시 프록시를 탈 이유가 없다
프록시는 '밖에서 들어오는 호출' 만 가로챌 수 있다
객체 내부의 호출은 프록시가 볼 방법이 원리적으로 없다
같은 이유로 @Cacheable·@Async·@Retryable·@PreAuthorize가 모두 동일하게 무시된다. "애너테이션을 붙였는데 왜 안 걸리죠"의 대부분이 이것이다.
해결책 — 위에서부터 시도한다
-
① 대상 메서드를 다른 빈으로 분리한다
- 호출이 빈 경계를 넘으므로 자연히 프록시를 탄다. 설계상 가장 깨끗하다
-
② 자기 프록시를 주입받아 호출한다
- private final ObjectProvider<OrderService> self;
- self.getObject().save(o);
- (필드로 자기 자신을 주입하면 순환 참조 경고가 날 수 있어 Provider 를 쓴다)
-
③ AopContext.currentProxy() 로 캐스팅
- @EnableAspectJAutoProxy(exposeProxy = true) 가 필요하다
- 코드가 스프링에 강하게 묶여 권장하지 않는다
함께 걸리는 제약
-
private 메서드 — 프록시가 오버라이드할 수 없다 → 적용 안 됨
-
final 메서드 — CGLIB 이 상속으로 만드는데 오버라이드 불가 → 적용 안 됨
-
final 클래스 — 상속 자체가 불가 → 프록시 생성 실패
-
코틀린 클래스·메서드는 기본이 final 이라 all-open 플러그인이 필요하다
진단 방법
- 로그에 트랜잭션 시작이 안 찍힌다
- logging.level.org.springframework.transaction.interceptor=TRACE
- 디버거에서 대상 객체 클래스명을 본다
- OrderService$$SpringCGLIB$$0 이면 프록시, OrderService 면 실제 객체
- 실제 객체에서 시작된 호출이면 그 아래는 전부 프록시를 안 탄다
프록시가 만들어지는 두 방식
- JDK 동적 프록시 — java.lang.reflect.Proxy.newProxyInstance(loader, interfaces, handler)
- 인터페이스만 프록시할 수 있다
- 생성 클래스 이름: $Proxy0, $Proxy1 …
CGLIB / ByteBuddy 대상 클래스를 상속한 서브클래스를 런타임에 만든다
- 인터페이스가 없어도 된다
- Spring 6 기준 이름: Foo$SpringCGLIB$0
- (예전: Foo$EnhancerBySpringCGLIB$1a2b3c)
Spring Boot 2.0부터 기본값이 CGLIB 이다 (spring.aop.proxy-target-class=true)
최소 구현으로 보는 원리
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class<?>[]{ OrderService.class },
(p, method, args) -> {
long t0 = System.nanoTime();
try {
return method.invoke(realTarget, args); // 진짜 대상 호출
} finally {
System.out.printf("%s %.2fms%n", method.getName(),
(System.nanoTime() - t0) / 1e6);
}
});
@Transactional이 하는 일도 정확히 이 모양이다 — method.invoke 앞뒤에 트랜잭션 시작·커밋이 들어갈 뿐이다. 그리고 realTarget 안에서 일어나는 호출은 이 람다를 절대 다시 타지 않는다.
면접 함정
- ❌ "public으로 바꾸면 된다" → public이어도 내부 호출이면 안 걸린다.
- ❌ "프록시 버그다" → 프록시의 원리적 한계다. 설계로 푸는 문제다.