Skip to main content

Command Palette

Search for a command to run...

Spring AOP 프록시의 두 얼굴 — JDK Dynamic Proxy와 CGLIB

Updated
9 min read

Spring에서 @Transactional을 붙였는데 트랜잭션이 적용되지 않은 경험, @Cacheable을 붙였는데 캐시가 동작하지 않은 경험이 한 번쯤은 있을 것입니다. 이 글은 이런 현상의 정체인 Spring AOP 프록시 — JDK Dynamic Proxy와 CGLIB — 의 동작 원리, 두 방식의 차이, 그리고 자주 부딪히는 함정을 코드와 함께 풀어냅니다.


1. 왜 프록시인가

횡단 관심사의 숙제

비즈니스 코드를 작성하다 보면 본질적인 로직과는 별개로 반드시 따라붙는 일들이 있습니다. 트랜잭션 시작과 커밋, 메서드 실행 시간 측정, 권한 검사, 캐시 조회와 저장, 예외 로깅 같은 것입니다. 이런 관심사를 횡단 관심사(Cross-Cutting Concern)라고 부릅니다. 여러 메서드에 공통으로 흩어지는 코드라는 뜻입니다.

만약 이 코드를 매 메서드마다 직접 작성한다면 어떨까요.

public Order placeOrder(Order order) {
    long start = System.currentTimeMillis();
    TransactionStatus tx = txManager.getTransaction(definition);
    try {
        Order saved = repository.save(order);
        txManager.commit(tx);
        log.info("placeOrder took {} ms", System.currentTimeMillis() - start);
        return saved;
    } catch (RuntimeException e) {
        txManager.rollback(tx);
        throw e;
    }
}

본래 한 줄이면 충분한 비즈니스 로직 repository.save(order)가 트랜잭션, 측정, 예외 처리 코드에 파묻혔습니다. 메서드가 100개라면 이 boilerplate가 100번 반복됩니다.

AOP의 해결책은 단순합니다. 비즈니스 코드는 비즈니스 로직만 담고, 횡단 관심사는 별도로 분리해서 메서드 호출을 가로채서 끼워 넣는 것입니다. Spring에서 이 가로채기를 책임지는 장치가 바로 프록시입니다.

프록시 패턴의 본질

프록시(Proxy)는 원래 객체를 대신해서 호출을 받아주는 대리인입니다. 호출자는 원래 객체와 동일한 인터페이스를 통해 프록시를 호출하지만, 프록시는 그 호출을 가로채서 부가 동작을 수행한 뒤 실제 객체에 위임합니다.

호출자는 자신이 프록시를 다루고 있다는 사실을 모릅니다. 그저 평소처럼 메서드를 호출할 뿐입니다. 부가 동작은 호출자도 모르고 실제 객체도 모르게 프록시 안에서 일어납니다.

Spring은 이 프록시를 런타임에 자동으로 생성해서 빈으로 등록합니다. 그래서 @Autowired로 주입받는 빈은 사실 우리가 작성한 클래스의 인스턴스가 아니라 그것을 감싼 프록시일 가능성이 높습니다.


2. Spring이 선택할 수 있는 두 가지 카드

Spring AOP는 프록시를 만들 때 두 가지 기술 중 하나를 선택합니다.

방식기반 기술대상
JDK Dynamic Proxyjava.lang.reflect.Proxy (JDK 표준)인터페이스 구현 클래스
CGLIB ProxyASM 기반 바이트코드 생성일반 클래스 (인터페이스 불요)

기본 선택 규칙은 명확합니다. 타깃이 인터페이스를 구현하면 JDK Dynamic Proxy, 그렇지 않으면 CGLIB. 이것이 Spring Framework의 전통적인 디폴트입니다. 단 Spring Boot 2.0 이후는 약간의 변화가 있는데, 이 부분은 뒤에서 별도로 다룹니다.

두 방식이 어떻게 다른지 하나씩 들여다봅니다.


3. JDK Dynamic Proxy — 인터페이스의 이름으로

동작 원리

JDK Dynamic Proxy는 Java 표준 라이브러리에 포함된 기능입니다. java.lang.reflect.Proxy.newProxyInstance 정적 메서드로 런타임에 새 클래스를 생성하고, 그 인스턴스를 돌려줍니다.

핵심은 두 가지입니다.

  1. 프록시 클래스는 지정한 인터페이스를 모두 구현합니다. 따라서 호출자가 인터페이스 타입으로 받을 수 있습니다.
  2. 모든 메서드 호출은 InvocationHandler.invoke로 위임됩니다. 이 핸들러 안에서 부가 로직을 실행하고 실제 객체를 호출합니다.
public interface OrderService {
    Order placeOrder(Order order);
}

public class OrderServiceImpl implements OrderService {
    public Order placeOrder(Order order) {
        return new Order(order.id(), "PLACED");
    }
}

public class LoggingHandler implements InvocationHandler {
    private final Object target;

    public LoggingHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("[before] " + method.getName());
        Object result = method.invoke(target, args);
        System.out.println("[after]  " + method.getName());
        return result;
    }
}

OrderService target = new OrderServiceImpl();
OrderService proxy = (OrderService) Proxy.newProxyInstance(
    target.getClass().getClassLoader(),
    new Class[] { OrderService.class },
    new LoggingHandler(target)
);

proxy.placeOrder(new Order(1L, "NEW"));
// [before] placeOrder
// [after]  placeOrder

흐름

런타임에 만들어지는 프록시 클래스의 이름은 보통 $Proxy0, $Proxy1 같은 식입니다. 이 클래스는 지정한 인터페이스를 구현하고, 각 메서드는 단순히 InvocationHandler.invoke를 호출하도록 자동 생성됩니다.

강점과 한계

강점은 분명합니다. JDK 표준 기능이므로 외부 라이브러리 의존성이 없고, 프록시 생성 비용이 가볍습니다.

그러나 결정적 제약이 있습니다.

  • 타깃은 반드시 인터페이스를 구현해야 합니다. 인터페이스가 없으면 Spring은 JDK Dynamic Proxy를 선택할 수 없습니다.
  • 인터페이스에 선언된 메서드만 프록시됩니다. 구현 클래스에만 존재하는 public 메서드는 프록시되지 않습니다.
  • 호출자는 인터페이스 타입으로 받아야 합니다. 구현체 타입으로 주입받으면 ClassCastException이 발생합니다.

이 마지막 항목이 실제로 자주 마주치는 함정입니다.

@Service
public class OrderServiceImpl implements OrderService { ... }

@Autowired
private OrderServiceImpl orderService; // BeanNotOfRequiredTypeException

JDK Dynamic Proxy는 OrderService 인터페이스만 구현할 뿐 OrderServiceImpl을 상속하지 않으므로 캐스팅에 실패합니다. 이런 한계를 우회하려고 등장하는 것이 CGLIB입니다.


4. CGLIB — 자식 클래스를 만들어 가로채다

동작 원리

CGLIB(Code Generation Library)은 ASM 바이트코드 조작 라이브러리를 기반으로 런타임에 새 클래스를 생성합니다. 핵심은 인터페이스가 아니라 타깃 클래스의 자식 클래스를 만들어 메서드를 오버라이드한다는 점입니다.

public class OrderService {  // 인터페이스 없음
    public Order placeOrder(Order order) {
        return new Order(order.id(), "PLACED");
    }
}

Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderService.class);
enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("[before] " + method.getName());
        Object result = proxy.invokeSuper(obj, args);
        System.out.println("[after]  " + method.getName());
        return result;
    }
});

OrderService cglibProxy = (OrderService) enhancer.create();
cglibProxy.placeOrder(new Order(1L, "NEW"));

Enhancer가 만들어내는 클래스는 대략 이런 모양입니다(개념적인 의사 코드).

public class OrderService$$EnhancerByCGLIB extends OrderService {
    private MethodInterceptor interceptor;

    @Override
    public Order placeOrder(Order order) {
        return (Order) interceptor.intercept(this, METHOD_PLACE_ORDER, new Object[]{order}, METHOD_PROXY);
    }
}

오버라이드된 메서드는 MethodInterceptor.intercept로 우회합니다. 인터셉터가 부가 동작을 처리한 뒤, MethodProxy.invokeSuper로 부모 클래스의 원본 메서드를 호출합니다. MethodProxy.invokeSuper는 리플렉션 기반의 Method.invoke보다 빠른 직접 호출 경로를 사용합니다.

흐름

강점과 한계

가장 큰 강점은 인터페이스가 없어도 프록시할 수 있다는 점입니다. 일반 클래스, 추상 클래스 모두 가능합니다. 호출자가 구현체 타입으로 받아도 문제없습니다 — 어차피 자식 클래스니까요.

그러나 자식 클래스를 만든다는 본질에서 비롯되는 제약이 있습니다.

  • final 클래스는 프록시할 수 없습니다. 상속이 불가능하기 때문입니다.
  • final 메서드와 private 메서드는 가로챌 수 없습니다. 오버라이드 자체가 불가능하므로 프록시는 만들어지지만 어드바이스가 적용되지 않습니다.
  • 생성자가 두 번 호출됩니다. 부모 클래스 인스턴스를 만들기 위해 한 번, 자식 프록시 인스턴스를 만들기 위해 한 번. 이로 인해 생성자에 부수효과가 있는 클래스는 예상 못한 동작을 보입니다.
  • 프록시 생성 비용이 JDK Proxy보다 무겁습니다. 바이트코드를 새로 만들어야 하기 때문입니다.

Spring은 자체 사용을 위해 CGLIB를 org.springframework.cglib 패키지로 리패키징해서 spring-core에 포함했습니다. 그래서 Spring을 쓰면 별도 의존성 없이 CGLIB 프록시가 동작합니다.


5. 두 방식 정면 비교

항목JDK Dynamic ProxyCGLIB
기반 기술java.lang.reflect.ProxyASM 바이트코드
프록시 생성 방식인터페이스 구현 클래스 생성타깃 클래스의 자식 클래스 생성
타깃 요건인터페이스 구현 필수인터페이스 불필요
final 클래스 프록시가능 (인터페이스만 보면 됨)불가능
final 메서드 가로채기가능불가능
private 메서드 가로채기불가능 (인터페이스에 못 둠)불가능 (오버라이드 불가)
호출 비용리플렉션 기반 (Method.invoke)MethodProxy.invokeSuper 직접 호출
프록시 생성 비용가벼움무거움 (바이트코드 생성)
외부 의존성없음 (JDK 표준)spring-core에 리패키징 포함
생성자 호출 횟수1회2회 (부모 + 자식)

요약하면, JDK Proxy는 가볍고 표준적이지만 인터페이스가 필수이고, CGLIB는 유연하지만 무겁고 final 제약이 있다는 트레이드오프입니다.


6. Spring은 언제 어느 쪽을 고르는가

Spring Framework의 전통적인 디폴트

@EnableAspectJAutoProxy, @EnableTransactionManagement, @EnableCaching 같은 설정 어노테이션은 모두 proxyTargetClass 속성을 가집니다. 기본값은 false입니다.

선택 규칙은 다음과 같습니다.

proxyTargetClass=false일 때조차도 인터페이스가 없으면 CGLIB로 폴백한다는 점이 중요합니다. 즉 CGLIB는 항상 마지막 보루 역할을 합니다.

Spring Boot 2.0 이후의 변화

Spring Boot 2.0부터는 spring.aop.proxy-target-class 프로퍼티의 기본값이 true로 변경되었습니다. 즉 Spring Boot 환경에서는 인터페이스 유무와 관계없이 기본적으로 CGLIB가 사용됩니다.

이 변경의 의도는 명확합니다. 인터페이스 타입이 아니라 구현 클래스 타입으로 주입받았을 때 발생하는 캐스팅 실패를 막고, 사용자가 의식하지 않아도 모든 경우에 동일한 프록시 동작을 보장하기 위함입니다.

만약 명시적으로 JDK Dynamic Proxy를 쓰고 싶다면 다음과 같이 설정합니다.

spring:
  aop:
    proxy-target-class: false

다만 한 가지 주의점이 있습니다. Spring Boot의 이 글로벌 설정은 @EnableAspectJAutoProxy(proxyTargetClass = false) 같은 어노테이션 설정보다 우선합니다. 어노테이션을 통해 JDK Proxy를 강제하려면 위의 글로벌 프로퍼티도 함께 꺼야 한다는 사실이 GitHub 이슈에서 보고된 바 있습니다.


7. 실전에서 자주 부딪히는 함정

함정 1: 자기 호출(Self-Invocation)

가장 유명한 함정입니다. 같은 클래스 안에서 메서드를 호출하면 어드바이스가 적용되지 않습니다.

@Service
public class OrderService {

    public void placeOrder(Order order) {
        // 외부에서 호출하면 트랜잭션 안에서 실행되지만,
        // 내부에서 호출하면 트랜잭션이 적용되지 않음
        this.saveAndNotify(order);
    }

    @Transactional
    public void saveAndNotify(Order order) {
        repository.save(order);
        notifier.send(order);
    }
}

placeOrder가 외부에서 호출되면 프록시가 가로채지만, 그 안의 this.saveAndNotify(order)프록시가 아니라 실제 객체의 메서드를 직접 호출합니다. 프록시는 자식 클래스(CGLIB) 또는 인터페이스 구현체(JDK Proxy)지 this 자신이 아니기 때문입니다.

해결책은 셋입니다.

  1. 자기 자신을 주입받아 프록시를 통해 호출
@Service
public class OrderService {
    @Autowired
    private OrderService self;

    public void placeOrder(Order order) {
        self.saveAndNotify(order);
    }
}
  1. 다른 빈으로 분리 — 가장 권장되는 방식
@Service
public class OrderFacade {
    private final OrderTransactionalService txService;

    public void placeOrder(Order order) {
        txService.saveAndNotify(order);
    }
}
  1. AspectJ로 컴파일/로드 타임 위빙 — 프록시 메커니즘이 아니라 바이트코드 자체를 변경하므로 self-invocation에도 어드바이스가 적용됩니다. 다만 Spring AOP보다 도입 비용이 큽니다.

이 함정은 @Transactional뿐만 아니라 @Cacheable, @Async, @Secured, @PreAuthorize프록시 기반 어드바이스 전체에 동일하게 적용됩니다.

함정 2: final 메서드의 침묵

CGLIB 프록시 환경에서 @Transactionalfinal 메서드에 붙이면 어드바이스가 적용되지 않습니다. 자식 클래스가 오버라이드할 수 없기 때문입니다. 더 안 좋은 점은 컴파일 에러도 런타임 에러도 발생하지 않는다는 것입니다. 그저 조용히 무시될 뿐입니다.

Spring 6 부터는 일부 검사가 강화되어 @Transactional이 붙은 final 메서드에 대해 경고를 출력하는 경우가 있지만, 모든 어드바이스가 동일한 검사를 수행하지는 않습니다. 안전한 습관은 AOP 대상이 될 가능성이 있는 빈의 메서드는 final을 붙이지 않는 것입니다.

함정 3: 생성자 안의 this 호출

CGLIB 프록시는 생성자가 두 번 호출됩니다. 더 중요한 사실은 프록시의 생성자에서 호출하는 메서드들이 어드바이스를 거치지 않는다는 점입니다. 객체 초기화 도중에는 프록시 인터셉터의 콜백이 아직 연결되어 있지 않을 수 있기 때문입니다.

생성자나 @PostConstruct 안에서 자기 자신의 트랜잭션 메서드를 호출하면 의도대로 동작하지 않습니다. 이런 경우는 ApplicationListener<ContextRefreshedEvent> 같은 별도 이벤트 후크로 분리하는 것이 안전합니다.

함정 4: private 메서드는 영원히 가로채지 못한다

JDK Proxy든 CGLIB든 private 메서드는 가로챌 수 없습니다. 인터페이스에 둘 수도 없고 오버라이드할 수도 없기 때문입니다. @Transactional을 private 메서드에 붙이면 무시됩니다. 외부에서 접근 가능한 protected 또는 public으로 바꿔야 어드바이스가 적용됩니다.


8. 어떤 기준으로 선택할까

대부분의 경우 Spring(특히 Spring Boot 2.0 이상)이 골라주는 기본값을 그대로 쓰는 것이 무난합니다. 그래도 의식적으로 선택해야 할 경우가 있습니다.

JDK Dynamic Proxy를 선호할 만한 상황

  • 인터페이스 기반 설계를 일관되게 유지하고 싶은 경우
  • final 클래스나 final 메서드를 자유롭게 사용하고 싶은 경우
  • 프록시 생성 오버헤드를 최소화하고 싶은 경우(다만 일반적으로 무시할 만한 수준)
  • JDK 표준만으로 동작 보장을 원할 때

CGLIB을 선호할 만한 상황

  • 인터페이스 정의 없이 구현 클래스만으로 빠르게 개발하는 경우
  • 호출자가 구현체 타입으로 주입받는 코드 스타일을 유지하고 싶은 경우
  • 인터페이스에 없는 public 메서드까지 어드바이스 대상에 포함하고 싶은 경우
  • Spring Boot의 디폴트를 유지해 모든 경우에 일관된 프록시 동작을 보장하고 싶은 경우

기억해 둘 한 가지 원칙은 둘 중 무엇을 쓰든 self-invocation, final, private 함정은 피해갈 수 없다는 것입니다. 이것들은 프록시라는 메커니즘 자체의 본질적 제약이기 때문입니다.


9. 정리

Spring AOP의 프록시 메커니즘은 두 가지 카드를 갖고 있습니다.

  • JDK Dynamic Proxyjava.lang.reflect.Proxy로 인터페이스를 구현하는 프록시 클래스를 만들고, 모든 호출을 InvocationHandler.invoke로 라우팅합니다. 가볍지만 인터페이스가 필요합니다.
  • CGLIB은 ASM으로 타깃 클래스의 자식 클래스를 생성하고, 오버라이드한 메서드를 MethodInterceptor.intercept로 라우팅합니다. 인터페이스가 필요 없는 대신 final 제약이 있습니다.

Spring Framework의 전통적인 디폴트는 "인터페이스가 있으면 JDK, 없으면 CGLIB"이지만, Spring Boot 2.0 이후로는 기본적으로 CGLIB을 사용합니다.

어느 쪽을 쓰든 메서드 호출이 프록시를 통과해야만 어드바이스가 동작한다는 본질적인 제약은 동일합니다. 그래서 self-invocation, final 메서드, private 메서드, 생성자 안의 호출은 모든 프록시 기반 기능(@Transactional, @Cacheable, @Async, @Secured 등)에서 공통으로 빠지는 함정입니다.

@Autowired 뒤에 숨겨진 객체가 사실은 프록시였다는 사실, 그 프록시가 어떻게 만들어지고 어떤 호출만 가로챌 수 있는지를 이해하는 것 — 이것이 Spring AOP 어노테이션이 "왜 안 되는지"를 디버깅할 수 있는 출발점입니다.


참고자료

More from this blog

LangChain, LangGraph, LangSmith — 첫 AI 에이전트를 만들기 전에 알아야 하는 세 도구의 역할

처음으로 AI 에이전트를 만들어 보려는 백엔드 개발자를 대상으로 합니다. 이름이 비슷한 세 도구 — LangChain, LangGraph, LangSmith — 가 각각 어떤 문제를 풀려고 등장했는지, 어떤 관계로 묶여 있는지, 그리고 어디서부터 손을 대야 하는지를 한 흐름으로 정리합니다. 함께 자주 등장하는 단어(Tool, ReAct, Function C

May 14, 202613 min read

Spring @Transactional 동작 원리 — 프록시, 트랜잭션 매니저, 전파와 롤백의 진실

메서드 위에 @Transactional 한 줄을 붙이면 Spring은 그 호출을 가로채 트랜잭션을 시작하고, 예외가 나면 롤백하고, 정상 종료되면 커밋합니다. 이 글은 그 한 줄 뒤에서 일어나는 일을 코드 흐름과 함께 풀어냅니다. 프록시가 어떻게 메서드를 가로채는지, TransactionInterceptor가 어떤 순서로 매니저를 호출하는지, 전파 속성과

May 14, 202612 min read

끄적끄적 테크 블로그

47 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ