자바 디자인 패턴 용어 사전
실무 적용self-invocation · 프록시 자기 호출

자기 호출

같은 객체 안에서 this로 부르면 프록시를 안 거친다. @Transactional이 안 걸리는 1순위 원인.

같은 객체 안에서 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이어도 내부 호출이면 안 걸린다.
  • "프록시 버그다" → 프록시의 원리적 한계다. 설계로 푸는 문제다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 실무 적용 심화 — 프레임워크 소스로 읽는 패턴