Spring AOP 프록시 동작 원리

Spring AOP 프록시란?

Spring AOP는 프록시 패턴으로 동작한다. @Transactional, @Cacheable, @Async 같은 어노테이션이 붙은 빈은 원본 객체가 아닌 프록시 객체가 스프링 컨테이너에 등록된다. 이 프록시가 메서드 호출을 가로채 부가 기능(트랜잭션, 캐시, 비동기 실행)을 수행한 뒤 원본 메서드를 호출한다.

프록시 메커니즘을 이해하지 못하면 “@Transactional이 왜 안 먹히지?” 같은 문제에 빠지게 된다. 이 글에서는 프록시 생성 방식, self-invocation 함정, 커스텀 Aspect 작성까지 심화 정리한다.

JDK Dynamic Proxy vs CGLIB

Spring은 두 가지 프록시 생성 방식을 제공한다.

구분 JDK Dynamic Proxy CGLIB Proxy
방식 인터페이스 기반 클래스 상속 기반
조건 인터페이스 구현 필요 인터페이스 불필요
final 클래스 가능 ❌ 불가
final 메서드 가능 ❌ 프록시 적용 안 됨
Spring Boot 기본 ✅ 기본값
# Spring Boot는 CGLIB이 기본
# application.yml에서 변경 가능 (권장하지 않음)
spring:
  aop:
    proxy-target-class: true   # CGLIB (기본)
    # proxy-target-class: false  # JDK Dynamic Proxy (인터페이스 필요)

// CGLIB 프록시 확인
@Service
public class UserService {
    // ...
}

@Component
public class ProxyChecker implements ApplicationRunner {
    @Autowired UserService userService;

    @Override
    public void run(ApplicationArguments args) {
        System.out.println(userService.getClass().getName());
        // com.example.UserService$$SpringCGLIB$$0  ← CGLIB 프록시!
        
        System.out.println(AopUtils.isAopProxy(userService));      // true
        System.out.println(AopUtils.isCglibProxy(userService));     // true
        System.out.println(AopUtils.isJdkDynamicProxy(userService)); // false
    }
}

Self-Invocation 함정

Spring AOP의 가장 흔한 실수다. 같은 클래스 내부에서 자기 자신의 메서드를 호출하면 프록시를 거치지 않는다.

@Service
public class OrderService {

    // ❌ self-invocation: @Transactional이 동작하지 않음!
    public void processOrder(Long orderId) {
        // 내부 호출 → 프록시를 거치지 않음 → 트랜잭션 없음
        this.saveOrder(orderId);
    }

    @Transactional
    public void saveOrder(Long orderId) {
        // 이 메서드가 직접 호출되면 트랜잭션 적용
        // 하지만 processOrder()에서 호출하면 적용 안 됨!
        orderRepository.save(new Order(orderId));
    }
}
// 프록시 동작 원리 (의사 코드)
class OrderService$$Proxy extends OrderService {
    private OrderService target;  // 원본 객체
    private TransactionInterceptor txInterceptor;

    @Override
    public void saveOrder(Long orderId) {
        // 프록시가 가로챔 → 트랜잭션 시작
        txInterceptor.begin();
        target.saveOrder(orderId);  // 원본 호출
        txInterceptor.commit();
    }

    @Override
    public void processOrder(Long orderId) {
        // 프록시가 호출하지만...
        target.processOrder(orderId);
        // target 내부의 this.saveOrder()는 프록시가 아닌
        // target 자신을 호출 → 트랜잭션 미적용!
    }
}

해결 방법

// 해결 1: 별도 서비스로 분리 (권장)
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderSaveService saveService;

    public void processOrder(Long orderId) {
        saveService.saveOrder(orderId);  // 프록시를 통한 외부 호출
    }
}

@Service
public class OrderSaveService {
    @Transactional
    public void saveOrder(Long orderId) {
        // 트랜잭션 정상 적용
    }
}

// 해결 2: 자기 자신을 주입 (ObjectProvider)
@Service
public class OrderService {
    @Lazy @Autowired
    private OrderService self;  // 프록시 객체 주입

    public void processOrder(Long orderId) {
        self.saveOrder(orderId);  // 프록시를 통해 호출 → 트랜잭션 적용
    }

    @Transactional
    public void saveOrder(Long orderId) { ... }
}

// 해결 3: ApplicationContext에서 직접 가져오기
@Service
public class OrderService implements ApplicationContextAware {
    private ApplicationContext context;

    public void processOrder(Long orderId) {
        context.getBean(OrderService.class).saveOrder(orderId);
    }

    @Transactional
    public void saveOrder(Long orderId) { ... }
}

해결법 1(서비스 분리)이 가장 깔끔하다. 트랜잭션 전파 관련 심화 내용은 Spring Transaction 전파 심화 글을 참고하자.

커스텀 Aspect 작성

// 실행 시간 측정 Aspect
@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {

    // Pointcut: @Timed 어노테이션이 붙은 메서드
    @Around("@annotation(timed)")
    public Object measureTime(ProceedingJoinPoint joinPoint, Timed timed) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();
        long start = System.nanoTime();

        try {
            Object result = joinPoint.proceed();
            long elapsed = (System.nanoTime() - start) / 1_000_000;
            log.info("⏱ {} completed in {}ms", methodName, elapsed);
            return result;
        } catch (Throwable e) {
            long elapsed = (System.nanoTime() - start) / 1_000_000;
            log.error("⏱ {} failed in {}ms: {}", methodName, elapsed, e.getMessage());
            throw e;
        }
    }
}

// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {}

// 사용
@Service
public class ReportService {
    @Timed
    public Report generateMonthlyReport(int year, int month) {
        // 이 메서드 실행 시간이 자동으로 로깅됨
    }
}

Advice 타입과 실행 순서

@Aspect
@Component
public class AuditAspect {

    // @Before: 메서드 실행 전
    @Before("execution(* com.example.service.*.*(..))")
    public void beforeAdvice(JoinPoint jp) {
        log.info("Before: {}", jp.getSignature().getName());
    }

    // @AfterReturning: 정상 반환 후
    @AfterReturning(pointcut = "execution(* com.example.service.*.*(..))", 
                     returning = "result")
    public void afterReturning(JoinPoint jp, Object result) {
        log.info("Returned: {} → {}", jp.getSignature().getName(), result);
    }

    // @AfterThrowing: 예외 발생 시
    @AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))", 
                    throwing = "ex")
    public void afterThrowing(JoinPoint jp, Exception ex) {
        log.error("Exception in {}: {}", jp.getSignature().getName(), ex.getMessage());
    }

    // @After: 항상 실행 (finally)
    @After("execution(* com.example.service.*.*(..))")
    public void afterAdvice(JoinPoint jp) {
        log.info("After (finally): {}", jp.getSignature().getName());
    }

    // @Around: 전체 제어 (가장 강력)
    @Around("execution(* com.example.service.*.*(..))")
    public Object aroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
        // Before 로직
        Object result = pjp.proceed();  // 원본 실행
        // After 로직
        return result;
    }
}
// 실행 순서 (Spring 5.2.7+):
// @Around (before proceed)
//   @Before
//     원본 메서드 실행
//   @AfterReturning 또는 @AfterThrowing
//   @After
// @Around (after proceed)

Pointcut 표현식 심화

@Aspect
@Component
public class PointcutExamples {

    // 1. execution: 메서드 시그니처 매칭
    @Pointcut("execution(public * com.example.service.*Service.*(..))")
    public void allServiceMethods() {}

    // 2. within: 특정 패키지/클래스 내 모든 메서드
    @Pointcut("within(com.example.controller..*)")
    public void allControllerMethods() {}

    // 3. @annotation: 특정 어노테이션이 붙은 메서드
    @Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
    public void transactionalMethods() {}

    // 4. @within: 특정 어노테이션이 붙은 클래스의 모든 메서드
    @Pointcut("@within(org.springframework.stereotype.Service)")
    public void allServiceBeans() {}

    // 5. args: 파라미터 타입 매칭
    @Pointcut("execution(* com.example.service.*.*(..)) && args(id,..)")
    public void methodsWithIdParam(Long id) {}

    // 6. 조합: AND, OR, NOT
    @Pointcut("allServiceMethods() && !transactionalMethods()")
    public void serviceWithoutTransaction() {}

    // 7. bean: 빈 이름 매칭 (Spring AOP 전용)
    @Pointcut("bean(*Service)")
    public void allServiceBeans2() {}
}

실전 패턴: 감사 로그 Aspect

// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();
    String resource() default "";
}

@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {

    private final AuditLogRepository auditLogRepository;
    private final SecurityContextHolder securityContext;

    @Around("@annotation(auditable)")
    public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
        String userId = securityContext.getCurrentUserId();
        String args = Arrays.toString(pjp.getArgs());

        try {
            Object result = pjp.proceed();

            auditLogRepository.save(AuditLog.builder()
                .userId(userId)
                .action(auditable.action())
                .resource(auditable.resource())
                .params(args)
                .status("SUCCESS")
                .timestamp(Instant.now())
                .build());

            return result;
        } catch (Throwable e) {
            auditLogRepository.save(AuditLog.builder()
                .userId(userId)
                .action(auditable.action())
                .resource(auditable.resource())
                .params(args)
                .status("FAILED")
                .errorMessage(e.getMessage())
                .timestamp(Instant.now())
                .build());

            throw e;
        }
    }
}

// 사용
@Service
public class UserService {
    @Auditable(action = "DELETE_USER", resource = "user")
    public void deleteUser(Long userId) {
        // 삭제 로직 → 자동으로 감사 로그 기록
    }
}

주의사항 정리

// 1. private 메서드에는 AOP 적용 불가
// CGLIB은 상속 기반이므로 private 오버라이드 불가
@Transactional
private void saveInternal() { }  // ❌ 트랜잭션 미적용

// 2. final 클래스/메서드에 CGLIB 프록시 불가
@Service
public final class FinalService {  // ❌ 프록시 생성 실패
}

// 3. @PostConstruct에서 AOP 미적용
// 빈 초기화 시점에는 아직 프록시가 완성되지 않을 수 있음
@PostConstruct
@Transactional  // ❌ 동작하지 않을 수 있음
public void init() { }

// 해결: ApplicationReadyEvent 사용
@EventListener(ApplicationReadyEvent.class)
@Transactional  // ✅ 이 시점에는 프록시 완성됨
public void onReady() { }

// 4. Aspect 실행 순서 제어
@Aspect
@Order(1)  // 숫자가 작을수록 먼저 실행
@Component
public class SecurityAspect { }

@Aspect
@Order(2)
@Component
public class LoggingAspect { }

Spring AOP 프록시의 동작 원리를 정확히 알면 @Transactional, @Cacheable, @Async 관련 버그를 사전에 방지할 수 있다. 캐시 관련 심화 내용은 Redis 캐시 전략: Cache-Aside 글을 참고하자.

마무리

핵심 규칙 설명
프록시 = 외부 호출만 self-invocation에서 AOP 미적용
public 메서드만 private/protected는 프록시 불가
final 금지 (CGLIB) final 클래스/메서드는 상속 불가
@Around가 가장 강력 실행 전후·예외·반환값 모두 제어

Spring AOP 프록시는 Spring의 모든 선언적 기능(@Transactional, @Cacheable, @Async, @Retryable)의 기반이다. 프록시의 동작 원리를 이해하면 “왜 안 되지?”라는 질문의 답을 즉시 찾을 수 있고, 커스텀 Aspect로 횡단 관심사를 깔끔하게 분리할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux