이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Spring은 원본 코드를 바꾸지 않고 어떻게 Advice를 실행하는가?
  • JDK Dynamic Proxy와 CGLIB Proxy는 언제 달라지는가?
  • 왜 내부 메서드 호출에서는 AOP가 적용되지 않을 수 있는가?
  • Spring Boot에서 AOP Proxy 기본값은 무엇이고 실무에서는 어떻게 판단해야 하는가?

개요

Spring AOP는 순수 Java로 구현되며 특별한 컴파일 과정이 필요하지 않다. Spring AOP는 Spring IoC와 결합해 일반적인 엔터프라이즈 문제를 해결하는 데 초점을 둔다.

Spring AOP는 프록시 기반 시스템이다. 공식 문서는 Proxy를 통해 호출이 들어올 때 Advice 체인이 실행될 수 있지만, Target 객체 내부의 this 호출은 Proxy를 통과하지 않으므로 Advice가 실행되지 않는다고 설명한다. Spring Boot의 AOP 자동 설정을 사용할 때는 기본적으로 클래스 기반 CGLIB Proxy가 사용되며, JDK Proxy를 쓰려면 spring.aop.proxy-target-class=false를 설정한다.

전체 동작 흐름

Spring AOP의 흐름은 Bean 생성 과정에서 시작된다.

  1. Spring Container가 Bean 정의를 읽는다.
  2. @Aspect Bean과 Advisor 후보를 찾는다.
  3. 각 Bean이 Pointcut 적용 대상인지 검사한다.
  4. 대상이면 원본 Bean을 그대로 노출하지 않고 Proxy를 만든다.
  5. 다른 Bean이 해당 Bean을 주입받을 때 Proxy가 주입된다.
  6. 런타임 호출이 Proxy로 들어오면 Advice 체인을 실행한 뒤 Target 메서드를 호출한다.

중요한 점은 “AOP가 메서드 내부에 코드를 끼워 넣는다”가 아니라 “호출자가 들어오는 문 앞에 Proxy를 세운다”이다.

JDK Dynamic Proxy

JDK Dynamic Proxy는 인터페이스를 기반으로 Proxy를 만든다. Target이 OrderService 인터페이스를 구현하고 있고, 호출자가 OrderService 타입으로 주입받는 구조라면 자연스럽게 동작한다.

public interface OrderService {
    OrderResult order(OrderCommand command);
}
 
@Service
public class OrderServiceImpl implements OrderService {
    @Override
    public OrderResult order(OrderCommand command) {
        return OrderResult.success();
    }
}

이 구조에서 Proxy는 OrderService 인터페이스를 구현한 객체로 만들어질 수 있다. 따라서 호출자는 구현 클래스가 아니라 인터페이스 타입에 의존하는 편이 안정적이다.

JDK Proxy의 핵심 제약은 인터페이스 기반이라는 점이다. Proxy는 인터페이스 타입으로 노출되므로 구현 클래스 타입으로 주입받으려 하면 기대와 다르게 동작하거나 타입 문제가 생길 수 있다.

CGLIB Proxy

CGLIB Proxy는 클래스를 상속해서 Proxy를 만든다. 인터페이스가 없어도 클래스 기반으로 Proxy를 만들 수 있다는 장점이 있다.

@Service
public class OrderService {
    public OrderResult order(OrderCommand command) {
        return OrderResult.success();
    }
}

Spring Boot는 기본적으로 CGLIB 기반 Proxy를 사용하도록 자동 설정한다. 그래서 인터페이스가 없는 서비스 클래스에도 AOP가 적용되는 경우를 자주 본다.

그러나 CGLIB은 상속 기반이므로 Java 언어의 상속 제약을 받는다. final 클래스는 상속할 수 없고, final 메서드는 오버라이딩할 수 없다. private 메서드도 오버라이딩할 수 없으므로 Advice 대상이 될 수 없다. 따라서 클래스 기반 Proxy를 쓴다고 해서 모든 메서드가 자동으로 Advice 대상이 되는 것은 아니다.

Proxy 호출과 내부 호출

가장 중요한 함정은 self-invocation이다.

@Service
public class OrderService {
 
    public void order() {
        validate();
        save();
    }
 
    @Auditable
    public void save() {
        // 저장 로직
    }
}

외부에서 orderService.save()를 호출하면 Proxy를 통과할 수 있다. 그러나 order() 내부에서 this.save() 형태로 호출되면 이미 Target 객체 안에 들어온 뒤 자기 자신의 메서드를 호출하는 것이다. 이 호출은 Proxy를 다시 거치지 않는다. 따라서 save()에 붙은 Advice가 실행되지 않을 수 있다.

이 문제를 해결하는 가장 좋은 방법은 구조를 바꾸는 것이다.

@Service
public class OrderFacade {
 
    private final OrderSaveService orderSaveService;
 
    public OrderFacade(OrderSaveService orderSaveService) {
        this.orderSaveService = orderSaveService;
    }
 
    public void order() {
        orderSaveService.save();
    }
}
 
@Service
public class OrderSaveService {
 
    @Auditable
    public void save() {
        // 저장 로직
    }
}

이렇게 Bean을 나누면 OrderFacade에서 OrderSaveService로 가는 호출이 Spring Proxy를 통과할 수 있다. self-injection이나 AopContext.currentProxy()를 사용하는 방법도 있지만, 공식 문서는 구조를 바꿔 self-invocation 자체를 피하는 방식을 가장 덜 침투적인 해법으로 설명한다. 특히 AopContext.currentProxy()는 코드를 Spring AOP에 강하게 결합시키므로 기본 해법으로 삼지 않는다.

Proxy 디버깅 방법

실무에서 AOP가 적용되지 않을 때는 추측하지 말고 Proxy 여부를 확인한다.

import org.springframework.aop.support.AopUtils;
 
@Component
public class ProxyInspector implements ApplicationRunner {
 
    private final OrderService orderService;
 
    public ProxyInspector(OrderService orderService) {
        this.orderService = orderService;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        log.info("isAopProxy={}", AopUtils.isAopProxy(orderService));
        log.info("isJdkProxy={}", AopUtils.isJdkDynamicProxy(orderService));
        log.info("isCglibProxy={}", AopUtils.isCglibProxy(orderService));
        log.info("class={}", orderService.getClass());
    }
}

이 검사는 학습용과 디버깅용으로 유용하다. 운영 코드의 핵심 로직이 Proxy 타입에 의존하게 만들지는 않는다.

Spring Boot 설정 판단

Spring Boot의 AOP 자동 설정에서는 기본적으로 CGLIB Proxy를 사용한다. JDK Proxy를 명시적으로 사용하고 싶다면 다음 설정을 둔다.

spring.aop.proxy-target-class=false

실무 판단은 다음 기준이 좋다.

  • 인터페이스 중심 설계를 강하게 유지하고 싶다면 JDK Proxy를 검토한다.
  • 인터페이스가 없는 서비스가 많거나 클래스 기반 주입이 많은 코드베이스라면 CGLIB 기본값이 편하다.
  • 라이브러리나 테스트 코드에서 구현 클래스 타입을 강하게 가정하고 있다면 Proxy 타입 문제를 먼저 점검한다.
  • final 클래스와 final 메서드가 기본인 Kotlin 코드에서는 Spring의 all-open 플러그인 같은 언어별 조건도 함께 고려해야 한다.

Advice 체인과 순서

하나의 메서드에 여러 Advice가 붙을 수 있다. 예를 들어 트랜잭션, 보안, 로깅, 재시도가 모두 같은 메서드에 적용될 수 있다. 이때 실제 실행은 체인 구조다.

Proxy
  보안 Advice
    트랜잭션 Advice
      타이밍 Advice
        Target Method
      타이밍 Advice 종료
    트랜잭션 커밋 또는 롤백
  보안 Advice 종료

순서가 중요한 정책은 명확히 관리해야 한다. Spring에서는 @Order 또는 Ordered를 통해 Aspect 우선순위를 지정할 수 있다. 다만 순서에 과하게 의존하는 Aspect가 많아지면 설계가 불안정해진다.

실전 팁

  • AOP가 안 먹는다고 느끼면 Pointcut보다 먼저 Proxy 경로를 확인한다. Spring Bean인지, 외부 호출인지, 주입된 객체가 Proxy인지가 1차다.
  • private 메서드에 Advice를 붙이려는 설계는 다시 생각한다. Spring AOP는 외부에서 들어오는 Bean 메서드 경계에 강하다.
  • self-invocation을 우회하려고 AopContext.currentProxy()를 기본 해결책으로 삼지 않는다. Bean을 나누거나 책임 경계를 다시 잡는 편이 장기적으로 낫다.
  • 테스트에서 new OrderService()로 만든 객체는 AOP 대상이 아니다. AOP 동작 검증은 Spring Context를 통해 Proxy가 만들어진 상태에서 한다.
  • CGLIB이 기본이라고 해서 구현 클래스에 무작정 의존하지 않는다. 가능하면 서비스 소비자는 인터페이스나 공개 계약에 의존하게 한다.

확인 질문

확인 질문

  • Spring AOP가 원본 코드를 바꾸지 않고 Advice를 실행하는 핵심 방법은 무엇인가?
    • Target 앞에 Proxy를 세우고 호출이 Proxy를 통과하게 만드는 것이다.
  • self-invocation에서 Advice가 실행되지 않는 이유는 무엇인가?
    • Target 내부의 this 호출은 Proxy를 거치지 않기 때문이다.
  • Spring Boot의 기본 AOP Proxy 방식은 무엇인가?
    • 기본 자동 설정은 CGLIB Proxy를 사용한다.
  • AopContext.currentProxy()를 조심해야 하는 이유는 무엇인가?
    • 비즈니스 클래스가 Spring AOP의 존재를 직접 알게 되어 결합도가 높아지기 때문이다.

참고 문서