Spring

Spring AOP는 어떻게 동작할까: 프록시부터 CGLIB까지

AOP(Aspect Oriented Programming)는 트랜잭션, 로깅, 권한 검사처럼 여러 곳에 반복해서 등장하는 횡단 관심사를 분리해서 한곳에서 관리하는 방식입니다. 횡단 관심사를 분리하면 각 클래스는 비즈니스 로직에만 집중할 수 있습니다.

이 글에서는 Spring AOP가 부가 기능을 어떻게 끼워 넣는지를 프록시 패턴에서 출발해 JDK Dynamic Proxy와 CGLIB까지 차례대로 정리하고, 프록시 방식이 가진 한계와 AspectJ와의 차이를 살펴봅니다.

동작 방식: 프록시가 호출을 가로챕니다

Spring AOP는 프록시를 기반으로 동작합니다. 프록시가 클라이언트의 호출을 먼저 받아 부가 기능을 실행하고, 그다음에 진짜 객체를 호출합니다.

프록시는 이름 그대로 대리인입니다. 클라이언트에게서 호출을 위임받아 부가 기능을 실행하고, 비즈니스 로직은 실제 객체에 맡깁니다. 이때 클라이언트는 프록시만 알고 있으며, 실제 객체의 존재는 알지 못합니다.

java
// 1. 공통 인터페이스
public interface OrderService {
    void order();
}

// 2. 진짜 객체: 핵심 로직만
public class OrderServiceImpl implements OrderService {
    public void order() {
        // 주문 로직
    }
}

// 3. 프록시: 같은 인터페이스를 구현하고, 진짜 객체를 품고 있음
public class OrderServiceTimeProxy implements OrderService {
    private final OrderService target;   // 진짜 객체

    public OrderServiceTimeProxy(OrderService target) {
        this.target = target;
    }

    public void order() {
        long start = System.currentTimeMillis();       // 부가 기능 (앞)
        target.order();                                 // 진짜 객체에 위임
        log.info("took {}ms", System.currentTimeMillis() - start);   // 부가 기능 (뒤)
    }
}

// 4. 클라이언트: 프록시를 주입받지만 OrderService로만 알고 있음
OrderService service = new OrderServiceTimeProxy(new OrderServiceImpl());
service.order();

이렇게 하면 원래 코드를 수정하지 않고도 기능을 추가할 수 있습니다. 하지만 부가 기능을 적용할 클래스마다 프록시 클래스를 직접 만들어야 한다면, 프록시 클래스가 계속 늘어나고 부가 기능 코드도 여전히 중복됩니다. 이 문제를 해결하기 위해 프록시 객체를 런타임에 자동으로 만들어 주는 JDK Dynamic Proxy와 CGLIB이 등장했습니다.

프록시 패턴과 데코레이터 패턴

두 패턴은 구현 방식이 거의 같고, 목적만 다릅니다.

패턴목적
프록시 패턴접근을 제어합니다
데코레이터 패턴기능을 추가합니다

프록시 패턴은 진짜 객체와 같은 인터페이스를 가진 대리 객체를 두고, 클라이언트의 호출을 대리 객체가 먼저 받아 부가 기능을 수행한 뒤 진짜 객체에 위임하는 패턴입니다. AOP 맥락에서는 두 패턴을 굳이 구분하지 않고 프록시라고 부른다고 생각하면 됩니다.

JDK Dynamic Proxy

Java 표준 기능으로, 인터페이스만 넘겨주면 그 인터페이스를 구현한 프록시 객체를 런타임에 자동으로 만들어 줍니다. 개발자는 부가 기능 로직 하나만 작성하면 됩니다.

구성 요소역할
InvocationHandler부가 기능 로직을 작성하는 곳입니다. 프록시에 들어온 모든 메서드 호출이 여기로 모입니다
Proxy.newProxyInstance()인터페이스를 보고 프록시 객체를 런타임에 생성합니다

먼저 부가 기능을 InvocationHandler에 작성합니다.

java
public class TimeInvocationHandler implements InvocationHandler {
    private final Object target;   // 진짜 객체 (타입이 Object → 어떤 서비스든 가능)

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        long start = System.currentTimeMillis();                   // 부가 기능 (앞)
        Object result = method.invoke(target, args);                // 리플렉션으로 진짜 메서드 호출
        log.info("{} took {}ms", method.getName(), System.currentTimeMillis() - start);  // 부가 기능 (뒤)
        return result;
    }
}

그다음 Proxy.newProxyInstance()로 프록시 객체를 만듭니다.

java
OrderService proxy = (OrderService) Proxy.newProxyInstance(
        OrderService.class.getClassLoader(),        // 클래스 로더
        new Class[]{OrderService.class},            // 구현할 인터페이스 ← 반드시 인터페이스
        new TimeInvocationHandler(new OrderServiceImpl())           // 부가 기능
);

proxy.order();   // → TimeInvocationHandler.invoke() → 시간 측정 → target.order()

다른 서비스에도 같은 InvocationHandler를 그대로 재사용할 수 있습니다.

java
MemberService memberProxy = (MemberService) Proxy.newProxyInstance(
        MemberService.class.getClassLoader(),
        new Class[]{MemberService.class},
        new TimeInvocationHandler(new MemberServiceImpl())   // 같은 Handler 재사용
);

부가 기능은 InvocationHandler 하나에만 작성하고, 프록시 객체는 JVM이 런타임에 자동으로 생성합니다. 따라서 서비스마다 프록시 클래스를 만들지 않아도 됩니다.

한계

  • 프록시가 인터페이스를 구현하는 방식이므로, 인터페이스가 반드시 있어야 합니다.
  • 프록시는 구현 클래스를 상속하지 않으므로, 구현 클래스 타입으로는 주입받을 수 없습니다.
  • 인터페이스에 선언된 메서드만 가로챌 수 있습니다.
  • 진짜 메서드를 리플렉션으로 호출하므로, 호출할 때마다 리플렉션 비용이 발생합니다.

CGLIB

바이트코드를 조작하는 라이브러리입니다. 대상 클래스를 상속한 자식 클래스를 런타임에 만들어서 프록시로 사용하므로, 인터페이스가 없어도 됩니다.

CGLIBJDK Dynamic Proxy역할
MethodInterceptorInvocationHandler부가 기능 로직을 작성합니다. 모든 메서드 호출이 여기로 모입니다
EnhancerProxy.newProxyInstance()프록시 객체를 생성합니다

이번에는 인터페이스가 없는 구체 클래스에 부가 기능을 적용해 봅니다.

java
// 인터페이스 없는 구체 클래스
public class OrderService {
    public void order() {
        // 주문 로직
    }
}

먼저 부가 기능을 MethodInterceptor에 작성합니다.

java
import org.springframework.cglib.proxy.MethodInterceptor;   // Spring에 내장된 CGLIB

public class TimeMethodInterceptor implements MethodInterceptor {
    private final Object target;

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

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
        long start = System.currentTimeMillis();                    // 부가 기능 (앞)
        Object result = methodProxy.invoke(target, args);            // 진짜 메서드 호출
        log.info("{} took {}ms", method.getName(), System.currentTimeMillis() - start);  // 부가 기능 (뒤)
        return result;
    }
}

methodProxy.invoke()는 리플렉션을 쓰지 않고, CGLIB이 생성한 코드로 진짜 메서드를 직접 호출하기 때문에 리플렉션보다 빠릅니다. 다만 최근 JVM은 리플렉션을 충분히 최적화하고 있어서, 실제로는 차이가 거의 나지 않습니다.

그다음 Enhancer로 프록시 객체를 만듭니다.

java
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OrderService.class);                        // 이 클래스를 상속해라 ← 인터페이스 아님!
enhancer.setCallback(new TimeMethodInterceptor(new OrderService())); // 부가 기능
OrderService proxy = (OrderService) enhancer.create();             // 프록시 생성

proxy.order();                                          // → intercept() → 시간 측정 → target.order()
System.out.println(proxy.getClass());                   // OrderService$$SpringCGLIB$$0 (Spring 6 기준)

이때 런타임에 생성되는 클래스는 개념적으로 다음과 같습니다. OrderService를 상속하고, 메서드를 오버라이드해서 호출을 MethodInterceptor로 넘깁니다.

java
public class OrderService$$SpringCGLIB$$0 extends OrderService {   // 상속!
    private MethodInterceptor interceptor;

    @Override
    public void order() {                                           // 오버라이드!
        interceptor.intercept(this, order 메서드 정보, args, methodProxy);
    }
}

제약

CGLIB은 상속과 오버라이드로 프록시를 만들기 때문에, 상속하거나 오버라이드할 수 없는 대상에는 부가 기능을 적용할 수 없습니다.

대상결과
final 클래스상속할 수 없으므로 프록시 생성에 실패합니다
final 메서드오버라이드할 수 없으므로 부가 기능이 적용되지 않습니다
private 메서드오버라이드할 수 없으므로 부가 기능이 적용되지 않습니다
static 메서드오버라이드라는 개념이 없으므로 부가 기능이 적용되지 않습니다

특히 final 메서드와 private 메서드는 예외가 발생하지 않고 조용히 무시됩니다. 그래서 이런 메서드에 @Transactional을 붙여도 트랜잭션이 적용되지 않는다는 사실을 알아차리기 어려우므로 주의해야 합니다.

JDK Dynamic Proxy와 CGLIB 비교

JDK Dynamic ProxyCGLIB
방식인터페이스 구현클래스 상속
인터페이스 필요 여부필요불필요
부가 기능 작성InvocationHandlerMethodInterceptor
프록시 생성Proxy.newProxyInstance()Enhancer
진짜 메서드 호출리플렉션 (method.invoke)생성된 코드 (methodProxy.invoke)
주입 타입인터페이스 타입만 가능구체 클래스 타입도 가능
제약인터페이스에 선언된 메서드만 가로챔final 클래스 불가, final·private 메서드 적용 안 됨
제공Java 표준 (java.lang.reflect.Proxy)외부 라이브러리 (Spring에 내장)

Spring Boot 2.0부터는 인터페이스가 있든 없든 CGLIB이 기본값입니다. (spring.aop.proxy-target-class=true) JDK Dynamic Proxy를 사용하면 구현 클래스 타입으로 주입받을 때 에러가 발생하는 문제가 있어서, 어느 경우에나 일관되게 동작하는 CGLIB을 기본값으로 바꿨습니다.

프록시 방식의 한계

내부 호출(Self-Invocation)에는 부가 기능이 동작하지 않습니다

같은 클래스 안에서 다른 메서드를 호출하면 프록시를 거치지 않기 때문에, 호출된 메서드에 부가 기능이 적용되지 않습니다.

java
@Service
public class OrderService {

    public void order() {
        // ...
        validate();   // 내부 호출 → 부가 기능 적용 안 됨
    }

    @LogExecutionTime   // 실행 시간을 측정하는 AOP가 적용된 메서드
    public void validate() {
        // ...
    }
}
  • 부가 기능은 프록시에만 들어 있습니다.
  • 외부에서는 프록시를 주입받아 호출하므로 부가 기능이 실행됩니다.
  • 내부 호출인 validate()는 실제로는 this.validate()입니다. 여기서 this는 프록시가 아니라 실제 객체 자신이므로, 호출이 프록시를 거치지 않습니다.

이 문제는 부가 기능이 필요한 메서드를 별도의 빈으로 분리해서 해결합니다. 그러면 내부 호출이 다른 빈을 호출하는 외부 호출로 바뀌므로, 호출이 프록시를 거치게 됩니다.

final, private 메서드에는 적용되지 않습니다

앞에서 CGLIB의 제약으로 살펴본 것처럼, CGLIB은 상속과 오버라이드로 프록시를 만들기 때문에 final 메서드와 private 메서드에는 부가 기능을 적용할 수 없습니다. 게다가 예외 없이 조용히 무시되므로, @Transactional을 붙인 메서드가 이 조건에 해당하지 않는지 확인해야 합니다.

AspectJ: 프록시 없는 AOP

AspectJ는 프록시를 사용하지 않고, 대상 클래스의 바이트코드를 직접 수정해서 부가 기능을 끼워 넣는 AOP 프레임워크입니다.

Spring AOP : Client → [프록시] → 진짜 객체      (객체가 두 개, 프록시가 호출을 가로챔)
AspectJ    : Client → 진짜 객체                 (부가 기능 코드가 이미 클래스 안에 들어 있음)

프록시를 거치지 않기 때문에, 앞에서 살펴본 프록시 방식의 한계(내부 호출, private·final 메서드)가 없습니다.

Spring AOP와 AspectJ 비교

Spring AOPAspectJ
방식런타임에 프록시 생성바이트코드 직접 수정 (Weaving)
적용 대상Spring 빈만모든 Java 객체 (new로 만든 객체 포함)
적용 지점메서드 실행만메서드, 생성자, 필드 접근, static 초기화 등
내부 호출적용 안 됨적용됨
private, final 메서드적용 안 됨적용됨
설정쉬움 (Spring이 처리)어려움 (별도 컴파일러나 Java Agent 필요)

Weaving 시점

AspectJ는 부가 기능을 바이트코드에 끼워 넣는 작업(Weaving)을 다음 세 시점 중 하나에서 수행합니다.

시점방법
컴파일 타임AspectJ 컴파일러(ajc)가 .java를 컴파일하면서 부가 기능을 끼워 넣습니다
포스트 컴파일이미 컴파일된 .class나 jar에 부가 기능을 끼워 넣습니다
로드 타임 (LTW)JVM이 클래스를 로드할 때 Java Agent가 부가 기능을 끼워 넣습니다

Spring AOP도 @Aspect를 쓰는데?

java
@Aspect                                        // AspectJ 어노테이션
@Component
public class TimeAspect {
    @Around("execution(* com.shop..*(..))")    // AspectJ 포인트컷 문법
    public Object measure(ProceedingJoinPoint jp) throws Throwable { ... }
}

Spring AOP는 AspectJ의 어노테이션과 포인트컷 문법만 빌려 쓰고, 실제로는 프록시로 동작합니다. 따라서 @Aspect를 사용한다고 해서 AspectJ의 Weaving이 적용되는 것은 아닙니다. @Aspect 문법은 다음 글에서 자세히 정리합니다.

그럼 AspectJ가 더 빠를까?

AspectJ는 프록시를 거치지 않으므로 이론상으로는 더 빠릅니다. 하지만 프록시 호출 비용은 ns 단위이므로, ms 단위로 걸리는 DB 쿼리나 네트워크 I/O에 비하면 무시해도 되는 수준입니다. 오히려 로드 타임 Weaving은 애플리케이션 시작 시간을, 컴파일 타임 Weaving은 빌드 시간을 늘립니다.

AspectJ를 선택하는 이유는 성능이 아니라 기능입니다. 내부 호출, private 메서드, Spring 빈이 아닌 객체처럼 프록시로는 부가 기능을 적용할 수 없는 지점에 AOP를 적용해야 할 때 AspectJ를 사용합니다.

정리

구분핵심
동작 방식프록시가 호출을 가로채 부가 기능을 실행하고, 진짜 객체에 위임합니다
JDK Dynamic Proxy인터페이스를 구현해서 프록시를 만듭니다. 인터페이스가 반드시 필요합니다
CGLIB클래스를 상속해서 프록시를 만듭니다. Spring Boot 2.0부터 기본값입니다
프록시 방식의 한계내부 호출과 final·private 메서드에는 부가 기능이 적용되지 않습니다
AspectJ바이트코드를 직접 수정하므로 프록시 방식의 한계가 없지만, 설정이 어렵습니다
  • 트랜잭션이나 로깅 같은 대부분의 요구사항은 public 메서드 단위로 적용해도 충분하므로, Spring AOP를 기본으로 사용합니다.
  • AspectJ는 프록시로 해결되지 않는 경우에만 선택합니다.
  • @Transactional도 @EnableTransactionManagement(mode = AdviceMode.ASPECTJ)로 AspectJ 모드를 사용할 수 있습니다. 이 모드에서는 내부 호출 문제가 사라지지만, spring-aspects 의존성과 Weaving 설정이 추가로 필요해서 실무에서는 드물게 사용합니다.