jongkwan.dev
개발 · Essay №060

Spring AOP와 프록시

Spring AOP가 횡단 관심사를 프록시로 어떻게 붙이는지, 그리고 self-invocation 한계까지 정리합니다.

이종관2026년 3월 9일11 min read
Contents

Spring AOP는 런타임에 대상 객체를 프록시로 감싸 횡단 관심사를 부가 기능으로 붙입니다.

횡단 관심사와 프록시

트랜잭션, 로깅, 보안 검사는 여러 계층의 메서드에 반복해서 등장합니다. 이런 공통 기능을 비즈니스 코드에 직접 넣으면 핵심 로직이 부가 코드에 묻힙니다. 여러 곳에 흩어진 공통 관심사를 횡단 관심사(cross-cutting concerns)라고 부릅니다.

관점 지향 프로그래밍(AOP, Aspect-Oriented Programming)은 이 횡단 관심사를 따로 모듈로 분리합니다. AOP는 객체 지향(OOP)을 대체하지 않고, OOP가 깔끔하게 처리하기 어려운 부분을 보조합니다. Spring AOP는 이 분리를 프록시(proxy)로 구현합니다.

프록시는 원본 객체를 감싸 호출 전후에 부가 기능을 끼워 넣는 대리 객체입니다. 클라이언트가 프록시를 호출하면 프록시가 부가 기능을 실행한 뒤 원본을 호출합니다. 핵심 코드를 건드리지 않고 기능을 더할 수 있습니다.

AOP 용어

AOP를 다루려면 먼저 구성 요소의 이름을 맞춰야 합니다. 아래 용어는 Spring AOP가 AspectJ에서 차용한 것입니다.

용어의미
조인 포인트(Join Point)어드바이스를 적용할 수 있는 지점. Spring AOP는 메서드 실행 시점만 지원
포인트컷(Pointcut)어드바이스를 실제로 적용할 조인 포인트를 고르는 표현식
어드바이스(Advice)적용할 부가 기능. Around, Before, After 등 실행 시점이 다름
애스펙트(Aspect)어드바이스와 포인트컷을 묶은 모듈. @Aspect로 선언
어드바이저(Advisor)어드바이스 하나 + 포인트컷 하나. Spring AOP 전용 용어
위빙(Weaving)포인트컷이 고른 조인 포인트에 어드바이스를 끼워 넣는 것

위빙 시점에 따라 컴파일 타임, 로드 타임, 런타임으로 나뉩니다. Spring AOP는 런타임 위빙을 쓰고, 그 수단이 프록시입니다. 그래서 Spring AOP의 능력과 한계는 프록시의 능력과 한계와 같습니다.

@Aspect를 붙이면 포인트컷과 어드바이스를 선언형으로 묶을 수 있습니다.

java
@Aspect
@Component
public class LogAspect {
 
    @Around("execution(* hello.aop.order..*(..))")
    public Object log(ProceedingJoinPoint joinPoint) throws Throwable {
        // joinPoint.proceed() 전후가 부가 기능을 끼우는 자리
        return joinPoint.proceed();
    }
}

@Around의 문자열이 포인트컷이고, 메서드 본문이 어드바이스입니다. proceed() 호출이 원본 메서드를 실행하는 지점입니다.

두 가지 프록시 기술

대상에 인터페이스가 있느냐에 따라 프록시를 만드는 방식이 갈립니다. Spring은 인터페이스 기반과 구체 클래스 기반 두 가지를 지원합니다.

기술기반조건
JDK 동적 프록시인터페이스인터페이스가 필수. 인터페이스를 구현한 프록시를 생성
CGLIB구체 클래스대상 클래스를 상속해 프록시를 생성

JDK 동적 프록시는 InvocationHandler, CGLIB는 MethodInterceptor로 호출을 가로챕니다. 둘은 가로채는 방식이 달라 따로 다루면 코드가 중복됩니다. Spring은 ProxyFactory로 둘을 한 번에 추상화해 일관되게 다룹니다.

ProxyFactoryproxyTargetClass 옵션이 둘 중 무엇을 쓸지 정합니다. false면 JDK 동적 프록시, true면 CGLIB입니다. 인터페이스가 없으면 옵션과 무관하게 CGLIB만 가능합니다.

Spring Boot는 2.0부터 proxyTargetClass=true를 기본값으로 둡니다(Spring Boot 공식 문서). 관련 설정 키는 spring.aop.proxy-target-class입니다. 인터페이스가 있어도 항상 CGLIB로 구체 클래스 기반 프록시를 만든다는 뜻입니다. JDK 동적 프록시로 되돌리려면 설정을 바꿉니다.

properties
spring.aop.proxy-target-class=false

CGLIB를 기본으로 둔 이유는 구체 클래스 타입으로 주입할 수 있기 때문입니다. JDK 동적 프록시는 인터페이스 기반이라 구체 클래스로 타입 캐스팅이 안 됩니다. CGLIB의 남은 제약은 final 클래스와 final 메서드를 프록시로 만들 수 없다는 점입니다. AOP 대상에 final을 잘 쓰지 않아 실무에서는 크게 걸리지 않습니다.

프록시는 언제 만들어지는가

프록시 생성은 빈 후처리기(BeanPostProcessor)가 담당합니다. spring-boot-starter-aop를 추가하면 AnnotationAwareAspectJAutoProxyCreator라는 자동 프록시 생성기가 빈으로 등록됩니다. 이 빈 후처리기가 객체를 빈 저장소에 등록하기 직전에 끼어듭니다.

빈 후처리기는 등록된 모든 어드바이저를 모아 포인트컷으로 대상 여부를 검사합니다. 객체의 메서드를 하나씩 포인트컷에 맞춰 보고, 하나라도 매칭되면 프록시를 만듭니다. 매칭되지 않으면 원본을 그대로 빈으로 등록합니다. 그래서 컴포넌트 스캔으로 올라온 빈에도 별도 설정 코드 없이 프록시가 붙습니다.

self-invocation 한계

Spring AOP에서 가장 자주 걸리는 함정은 self-invocation(자기 호출)입니다. 프록시 방식이라 AOP는 항상 프록시를 거쳐야 적용됩니다. 대상 객체 내부에서 자기 메서드를 호출하면 이 프록시를 건너뜁니다.

java
@Component
public class CallService {
 
    public void external() {
        internal(); // this.internal() - 프록시가 아니라 원본을 직접 호출
    }
 
    @Transactional
    public void internal() {
        // external()을 통해 들어오면 트랜잭션이 걸리지 않는다
    }
}

external() 안의 internal()은 앞에 참조가 없어 this.internal()로 해석됩니다. 여기서 this는 프록시가 아니라 실제 대상 객체입니다. 그래서 internal()에 붙인 @Transactional 같은 어드바이스가 적용되지 않습니다.

해결 방향은 호출이 프록시를 거치게 만드는 것이며, 보통 세 가지로 정리합니다.

  • 자기 자신 주입: 프록시가 주입된 자기 빈을 받아 그 참조로 internal()을 호출합니다.
  • 지연 조회: ObjectProviderApplicationContext로 프록시를 늦게 꺼내 호출합니다.
  • 구조 변경: internal()을 별도 빈으로 분리해 외부 호출로 바꿉니다.

세 방법 모두 목적은 같지만, 권장되는 방향은 구조 변경입니다. 내부 호출을 외부 빈 호출로 바꾸면 의존 관계가 드러나고 프록시도 자연히 거치게 됩니다. 참고로 코드에 직접 위빙하는 AspectJ를 쓰면 self-invocation 문제가 없지만, 로드 타임 위빙 설정과 JVM 옵션이 필요해 부담이 큽니다.

정리

Spring AOP는 런타임에 프록시로 횡단 관심사를 부가 기능으로 붙입니다. 포인트컷이 대상을 고르고, 어드바이스가 부가 기능을 담으며, 둘을 묶은 어드바이저를 빈 후처리기가 찾아 프록시를 자동 생성합니다. 프록시는 인터페이스 기반 JDK 동적 프록시와 클래스 기반 CGLIB 두 가지이고, Spring Boot는 CGLIB를 기본으로 씁니다. 프록시 방식이라 self-invocation에서 AOP가 빠지는 한계가 있으니, 내부 호출을 구조로 분리해 프록시를 거치게 만드는 편이 안전합니다.