[Spring] AOP와 프록시(JDK Dynamic Proxy, CGLIB, self-invocation)

핵심 로직과 부가 기능이 뒤섞일 때

비즈니스 메서드 앞뒤로 트랜잭션을 열고 닫고, 로그를 남기고, 권한을 검사하고, 캐시를 조회하고, 재시도를 거는 코드를 매번 작성할 수 있다. 메서드 하나에 부가 기능 코드가 핵심 코드보다 많이 차지하게 되고, 같은 패턴이 수십 군데에 작성되고, 부가 기능을 한 줄 바꾸려면 수십 곳을 동시에 고쳐야 한다.

AOP(Aspect-Oriented Programming)는 이 횡단 관심사(cross-cutting concern)를 핵심 로직과 분리하는 프로그래밍 패러다임이다. 어떤 지점(Pointcut)에 어떤 부가 기능(Advice)을 끼우겠다고 선언만 하면, 실제로는 그 지점에 코드를 직접 삽입하지 않고도 부가 기능이 실행되도록 만든다.

스프링은 이 AOP 를 프록시 기반으로 구현한다. 즉 어떤 빈에 부가 기능을 끼우고 싶다면 원본 객체와 같은 모양을 가진 대리 객체를 만들어 호출자가 그쪽을 거치게 한다. 프록시는 호출을 가로채 부가 기능을 실행하고 원본을 호출한다.

@Transactional, @Async, @Cacheable, @Retryable, @Validated, @Secured, 직접 만든 @Aspect 까지 전부 프록시가 끼어드는 지점이다. 그 프록시가 어떻게 만들어지고, 어떤 이유로 JDK Dynamic Proxy 와 CGLIB 이 갈라지는지, 그리고 왜 같은 클래스 안에서 this.method() 를 호출하면 트랜잭션이 동작하지 않는지를 정리한다.

요청 파이프라인과 메서드 인터셉트

  • 요청 파이프라인 축(Filter, Interceptor): 컨트롤러에 닿기 전후의 HTTP 흐름을 가로챈다.
  • 메서드 실행 축(Proxy, AOP): 빈 메서드 호출 전후를 가로챈다.
항목FilterInterceptorAOP
관리 주체서블릿 컨테이너 (Tomcat)스프링 컨테이너스프링 컨테이너
실행 시점DispatcherServlet 이전/이후Controller 이전/이후메서드 실행 전후
사용 객체ServletRequest/ResponseHttpServletRequest/ResponseJoinPoint, ProceedingJoinPoint
적합한 용도인코딩, XSS, CORS인증, 인가, 로깅트랜잭션, 로깅, 권한, 캐시

HTTP 요청 전후, 메서드 실행 전후를 다루어 인터셉트를 수행한다. 같은 로깅이라도 HTTP 단에서 잡는 것과 서비스 메서드 단에서 잡는 것은 의도가 다르다.

AOP(메서드 인터셉트의 추상화)

@Aspect
@Component
class LoggingAspect {
 
    @Around("execution(* com.example..*Service.*(..))")
    fun log(joinPoint: ProceedingJoinPoint): Any? {
        println("before ${joinPoint.signature.name}")
        val result = joinPoint.proceed()   // 원본 호출
        println("after ${joinPoint.signature.name}")
        return result
    }
}

@Aspect는 부가 기능(Advice)과 적용 지점(Pointcut)을 한 클래스에 묶어 선언한다. 스프링은 시작할 때 @Aspect가 붙은 빈을 모아, Pointcut(어디에) + Advice(무엇을)를 한 쌍으로 묶은 어드바이저를 만들어 둔다. 이후 빈을 하나씩 생성할 때마다, 그 빈이 어떤 어드바이저의 Pointcut조건(@Around에 명시된 지점)에 걸리는지 확인한다. 해당되는 빈이 있으면 그 빈을 프록시로 감싼다.

  • @Transactional: 트랜잭션 시작, 커밋, 롤백을 프록시가 감싼다.
  • @Async: 비동기 실행을 프록시가 별도 스레드에서 수행한다.
  • @Cacheable@CacheEvict: 캐시 조회, 저장을 프록시가 가로챈다.
  • @Validated: 메서드 파라미터 검증을 프록시가 수행한다.
  • @Retryable: 실패 시 재시도 로직을 프록시가 감싼다.
  • 사용자 정의 @Aspect: 횡단 관심사를 프록시로 위빙한다.

프록시(같은 모양의 대리 객체)

프록시 패턴은 원본(target)과 같은 인터페이스 또는 같은 클래스를 가진 대리 객체를 만들어, 호출자가 항상 대리 객체를 거치게 만드는 패턴이다.

@Transactional이 프록시가 되는 과정

@Transactional이 붙은 메서드가 실제로 어떻게 프록시로 감싸지는지 개념적으로 풀어보면 이해가 쉽다. 다음과 같은 원본 코드가 있다.

@Service
class UserService(
    private val userRepository: UserRepository,
) {
    @Transactional
    fun register(command: RegisterCommand): User {
        val user = User.create(command)
        return userRepository.save(user)
    }
}

스프링은 이 빈을 상속한 자식 클래스를 런타임에 만들고, register()를 오버라이드해 앞뒤를 트랜잭션 코드로 감싼다. 실제로는 바이트코드로 생성되지만, 하는 일을 코드로 옮기면 다음과 같다.

// CGLIB이 런타임에 만드는 프록시(개념적으로 풀어쓴 모습)
class UserService$$SpringCGLIB : UserService() {   // 원본을 상속
 
    private lateinit var txManager: PlatformTransactionManager
 
    override fun register(command: RegisterCommand): User {
        val tx = txManager.getTransaction(...)     // ① 트랜잭션 시작(BEGIN)
        try {
            val result = super.register(command)   // ② 원본 메서드 호출
            txManager.commit(tx)                   // ③ 성공 → commit
            return result
        } catch (e: RuntimeException) {
            txManager.rollback(tx)                 // ④ 예외 → rollback
            throw e
        }
    }
}
  • super.register()가 원본 로직이고, 그 앞뒤(BEGIN, commit/rollback)가 프록시가 끼워 넣은 부가 기능이다.
  • @Autowired로 주입되는 것도 원본 UserService가 아니라 이 프록시 객체다. 그래서 다른 빈이 userService.register(...)를 호출하면 오버라이드된 트랜잭션으로 감싼 메서드가 실행된다.
  • 프록시를 거쳐야만 부가 기능이 동작한다는 점이, 뒤에서 다룰 self-invocation 제약(같은 클래스 안 this.register()는 프록시를 거치지 않아 트랜잭션이 걸리지 않음)의 원인이기도 하다.

스프링이 이 대리 객체를 런타임에 만들어내는 방식은 두 가지다. 자바 표준 라이브러리가 제공하는 JDK Dynamic Proxy(인터페이스 기반)와 바이트코드 생성 라이브러리인 CGLIB(상속 기반)이다.

JDK Dynamic Proxy(인터페이스를 형제처럼 구현한다)

java.lang.reflect.Proxy 가 제공하는 방식이다. 인터페이스를 기준으로 런타임에 그 인터페이스를 구현한 프록시 클래스를 동적으로 만든다.

interface UserService {
    fun getUser(id: Long): String
}
 
class UserServiceImpl : UserService {
    override fun getUser(id: Long) = "user-$id"
}
 
class LoggingHandler(private val target: Any) : InvocationHandler {
    override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? {
        println("before ${method.name}")
        val result = method.invoke(target, *(args ?: emptyArray()))  // 리플렉션으로 원본 호출
        println("after ${method.name}")
        return result
    }
}
 
val proxy = Proxy.newProxyInstance(
    UserService::class.java.classLoader,
    arrayOf(UserService::class.java),
    LoggingHandler(UserServiceImpl())
) as UserService
  • 프록시가 가진 모든 메서드 호출은 InvocationHandler.invoke()를 호출한다.
  • 실제 원본 호출은 Method.invoke(), 리플렉션이다.
  • 인터페이스가 반드시 있어야 한다. 인터페이스 없는 클래스는 프록시를 만들 수 없다.

CGLIB(클래스를 자식처럼 상속한다)

CGLIB은 클래스를 상속해서 프록시를 만든다. 런타임에 원본 클래스를 상속한 자식 클래스를 생성하고, 메서드를 오버라이드해 가로챈다.

class UserService {
    fun getUser(id: Long) = "user-$id"
}
 
val enhancer = Enhancer()
enhancer.setSuperclass(UserService::class.java)   // 원본을 상속
enhancer.setCallback(MethodInterceptor { obj, method, args, proxy ->
    println("before ${method.name}")
    val result = proxy.invokeSuper(obj, args)       // 부모(원본) 메서드 호출
    println("after ${method.name}")
    result
})
val proxy = enhancer.create() as UserService
  • 상속 기반이므로 final 클래스는 프록시로 만들 수 없다. final 메서드와 private 메서드도 오버라이드 대상이 아니므로 가로채지 못한다.

두 방식 비교

항목JDK Dynamic ProxyCGLIB
기준인터페이스클래스
생성 원리인터페이스 구현 (형제)클래스 상속 (자식)
인터페이스 필수OX
final 클래스가능 (인터페이스만 있으면)불가 (상속 불가)
final / private 메서드가능 (인터페이스의 메서드만 호출 대상)불가 (오버라이드 불가)
호출 방식리플렉션 (Method.invoke)인덱스 디스패치 (FastClass)
구체 클래스로 캐스팅불가 (인터페이스로만)가능
의존성자바 표준spring-core 에 포함

프록시 호출 시퀀스

sequenceDiagram
    autonumber
    participant Client as 호출자<br/>(다른 빈)
    participant Proxy as 프록시 객체<br/>(Spring이 주입한 것)
    participant Advisor as Advisor 체인<br/>(TransactionInterceptor 등)
    participant Target as 원본 객체<br/>(UserService)

    Client->>Proxy: userService.getUser(1)
    Proxy->>Advisor: invoke(MethodInvocation)
    Advisor->>Advisor: 부가 기능 전(before)<br/>예: 트랜잭션 시작
    Advisor->>Target: proceed() : 실제 메서드 호출
    Target-->>Advisor: 결과 반환
    Advisor->>Advisor: 부가 기능 후(after)<br/>예: commit/rollback
    Advisor-->>Proxy: 최종 결과
    Proxy-->>Client: 최종 결과

호출자가 본 객체는 시종일관 프록시다. 원본 객체는 프록시 안에 숨겨져 있고, 호출자는 그 존재를 모른다.

어떤 프록시를 쓸지(proxy-target-class)

스프링 부트는 다음 설정이 기본값이다.

spring:
  aop:
    proxy-target-class: true   # Spring Boot 기본값
  • true: 항상 CGLIB 을 쓴다. (Spring Boot 2.0 이상 기본값)
  • false: 인터페이스가 있으면 JDK Dynamic Proxy, 없으면 CGLIB.
// JDK Dynamic Proxy 일 때
@Autowired lateinit var service: UserService       // 인터페이스: OK
@Autowired lateinit var service: UserServiceImpl   // 구체 클래스: 예외
 
// CGLIB 일 때
@Autowired lateinit var service: UserServiceImpl   // 자식이므로 OK

JDK Dynamic Proxy로 만들어진 프록시는 UserServiceImpl을 상속하지 않은 형제 객체이다. 따라서 구체 클래스 타입으로 받으려고 하면 BeanNotOfRequiredTypeException이 발생한다. 반면 CGLIB 프록시는 구체 클래스의 자식이므로 인터페이스 타입으로도 구체 클래스 타입으로도 주입할 수 있다.

프록시 기반의 가장 큰 제약(self-invocation)

스프링 AOP는 빈이 외부에서 호출될 때만 동작한다. 같은 클래스 내부에서 메서드를 직접 호출하면 프록시를 거치지 않으므로 부가 기능이 동작하지 않는다.

다른 빈이 orderService.order() 를 부르면 그 손에 쥐고 있는 객체는 프록시이므로 proxy.order() 가 호출되고, 프록시가 트랜잭션을 열고 원본을 호출한다. 그러나 같은 클래스 내부에서 this.order() 로 호출하면 this 는 프록시가 아니라 원본 객체다.

@Service
class OrderService {
    @Transactional
    fun order() { /* ... */ }
 
    fun process() {
        order()   // = this.order() : 프록시를 거치지 않음 : @Transactional 미적용
    }
}

방법 1: 다른 빈으로 분리

@Service
class OrderFacadeService(
    private val orderTxService: OrderTxService,
) {
    fun process() {
        orderTxService.order()   // 다른 빈의 프록시를 통과: @Transactional 적용
    }
}
 
@Service
class OrderTxService {
    @Transactional
    fun order() { /* ... */ }
}

가장 깔끔한 해결책이고, 책임 분리 관점에서도 자연스럽다. 트랜잭션이 필요한 로직과 그것을 조립하는 로직이 서로 다른 빈에 들어가도록 구조를 잡는다.

방법 2: 자기 자신의 프록시를 받아오기

@Service
class OrderService(
    private val applicationContext: ApplicationContext,
) {
    @Transactional
    fun order() { /* ... */ }
 
    fun process() {
        val self = applicationContext.getBean(OrderService::class.java)
        self.order()   // 컨테이너에서 가져온 프록시를 통과
    }
}

ApplicationContext 에서 자기 자신을 다시 꺼내 호출한다. 자기참조이므로,가능하면 방법 1을 택하는 것이 좋다.

@Aspect 와 BeanPostProcessor(추상화 수준의 차이)

지금까지의 AOP는 메서드 호출을 프록시로 가로채는 방식이다. 그런데 스프링에는 더 낮은 수준에서 빈을 가로채 부가 기능을 더할 수 있는 확장점인 BeanPostProcessor가 있다.

@Aspect (Spring AOP)BeanPostProcessor
언제 동작메서드가 호출될 때마다 (런타임, 반복)빈이 생성, 초기화될 때 한 번 (기동 시점)
무엇을 다루나메서드 호출(JoinPoint)빈 인스턴스 그 자체
추상화 수준고수준, 선언적 (포인트컷 매칭)저수준, 프로그래밍적 (모든 빈을 직접 훑음)
전형적 용도로깅, 트랜잭션, 캐시, 재시도, 권한빈 검사, 검증, 등록, 교체, 커스텀 주입
관계BeanPostProcessor 위에 만들어진 추상화컨테이너 확장점 그 자체

Spring AOP 자체가 AnnotationAwareAspectJAutoProxyCreator라는 BeanPostProcessor로 동작한다. 빈이 생성된 직후에 그 빈이 어드바이저 매칭 대상인지 확인하고, 매칭되면 프록시로 감싸서 컨테이너에 등록한다.

@Aspect 를 쓰는 경우

런타임에 메서드 호출 전후로 부가 기능을 추가할 때 사용한다.

  • 모든 *Service 메서드 실행 시간 측정
  • @AuditLog 가 붙은 메서드 감사 로깅
  • 특정 패키지에 재시도 적용

이 모두 메서드가 불릴 때마다 일어나는 일이라 프록시가 필요하고, @Aspect 가 그 프록시를 알아서 만들어 준다.

BeanPostProcessor 를 쓰는 경우

빈을 만들 때 한 번 부가 기능을 추가할 때 사용한다.

  • 특정 어노테이션을 가진 빈, 메서드를 전부 찾아 레지스트리에 등록한다. @KafkaListener, @EventListener, @Scheduled 가 이 방식이다. 빈을 프록시로 감싸는 것이 아니라 메서드를 엔드포인트로 등록해 두고, 외부 트리거(메시지, 스케줄)가 오면 직접 호출한다.
  • 기동 시 빈 설정을 검증해, 잘못되면 애플리케이션을 아예 못 뜨게 막는다.
  • 커스텀 어노테이션을 보고 빈 필드에 값을 주입한다. 스프링의 @Autowired 처리기 AutowiredAnnotationBeanPostProcessor가 정확히 이 방식이다.