Spring AOP 프록시 심화 가이드

Spring AOP란

Spring AOP(Aspect-Oriented Programming)는 로깅, 트랜잭션, 보안, 캐싱 등 횡단 관심사(Cross-Cutting Concerns)를 비즈니스 로직에서 분리하는 프로그래밍 패러다임이다. 핵심 로직을 수정하지 않고 부가 기능을 선언적으로 적용할 수 있어, 코드 중복을 제거하고 유지보수성을 크게 높인다.

Spring의 @Transactional, @Cacheable, @Async, @Retryable 모두 AOP 기반으로 동작한다. AOP의 원리를 이해하면 이 어노테이션들의 동작 방식과 제약(self-invocation 문제 등)을 정확히 파악할 수 있다.

핵심 용어 정리

용어 설명 예시
Aspect 횡단 관심사를 모듈화한 클래스 LoggingAspect, RetryAspect
Join Point Advice가 적용될 수 있는 지점 메서드 실행 시점
Advice Join Point에서 실행할 코드 @Before, @After, @Around
Pointcut Advice를 적용할 Join Point 선택 표현식 execution(* com.app.service.*.*(..))
Proxy AOP가 적용된 대리 객체 JDK Dynamic Proxy, CGLIB

Advice 타입 5가지

@Aspect
@Component
@Slf4j
public class LoggingAspect {

    // 1. @Before — 메서드 실행 전
    @Before("execution(* com.app.service.*.*(..))")
    public void logBefore(JoinPoint jp) {
        log.info("▶ {}.{}() 호출",
            jp.getTarget().getClass().getSimpleName(),
            jp.getSignature().getName());
    }

    // 2. @AfterReturning — 정상 반환 후
    @AfterReturning(
        pointcut = "execution(* com.app.service.*.*(..))",
        returning = "result")
    public void logAfterReturning(JoinPoint jp, Object result) {
        log.info("◀ {}.{}() 반환: {}",
            jp.getTarget().getClass().getSimpleName(),
            jp.getSignature().getName(),
            result);
    }

    // 3. @AfterThrowing — 예외 발생 시
    @AfterThrowing(
        pointcut = "execution(* com.app.service.*.*(..))",
        throwing = "ex")
    public void logAfterThrowing(JoinPoint jp, Exception ex) {
        log.error("✖ {}.{}() 예외: {}",
            jp.getTarget().getClass().getSimpleName(),
            jp.getSignature().getName(),
            ex.getMessage());
    }

    // 4. @After — 정상/예외 무관하게 항상 실행 (finally)
    @After("execution(* com.app.service.*.*(..))")
    public void logAfter(JoinPoint jp) {
        log.debug("● {}.{}() 완료",
            jp.getTarget().getClass().getSimpleName(),
            jp.getSignature().getName());
    }

    // 5. @Around — 가장 강력, 실행 전후 모두 제어
    @Around("execution(* com.app.service.*.*(..))")
    public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();  // 원본 메서드 실행
            return result;
        } finally {
            long duration = System.currentTimeMillis() - start;
            log.info("⏱ {}.{}() → {}ms",
                pjp.getTarget().getClass().getSimpleName(),
                pjp.getSignature().getName(),
                duration);
        }
    }
}

Pointcut 표현식 마스터

Pointcut은 AOP의 핵심이다. 어떤 메서드에 Advice를 적용할지 정밀하게 제어한다.

@Aspect
@Component
public class PointcutDefinitions {

    // execution: 메서드 실행 매칭 (가장 많이 사용)
    // 패턴: execution(접근제어자? 반환타입 패키지.클래스.메서드(파라미터) 예외?)
    @Pointcut("execution(public * com.app.service.*Service.*(..))")
    public void allServiceMethods() {}

    // 반환타입 지정
    @Pointcut("execution(java.util.List com.app.repository.*.*(..))")
    public void repositoryListMethods() {}

    // 파라미터 패턴
    @Pointcut("execution(* *..OrderService.create(com.app.dto.CreateOrderDto, ..))")
    public void orderCreate() {}

    // @annotation: 특정 어노테이션이 붙은 메서드
    @Pointcut("@annotation(com.app.annotation.Loggable)")
    public void loggableMethods() {}

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

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

    // bean: 빈 이름으로 매칭 (Spring 전용)
    @Pointcut("bean(*Service)")
    public void allServiceNamedBeans() {}

    // 조합: AND(&&), OR(||), NOT(!)
    @Pointcut("allServiceMethods() && !loggableMethods()")
    public void serviceMethodsWithoutLogging() {}

    @Pointcut("execution(* com.app..*(..)) && @annotation(Transactional)")
    public void transactionalMethods() {}
}

실전 패턴 1: 커스텀 어노테이션 + 실행 시간 측정

// 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
    String value() default "";        // 메트릭 이름
    String[] tags() default {};       // 추가 태그
    boolean histogram() default false;
}

// Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
public class TimedAspect {
    private final MeterRegistry meterRegistry;

    @Around("@annotation(timed)")
    public Object measureTime(ProceedingJoinPoint pjp, Timed timed)
            throws Throwable {
        String metricName = timed.value().isEmpty()
            ? pjp.getSignature().toShortString()
            : timed.value();

        Timer.Sample sample = Timer.start(meterRegistry);
        String status = "success";

        try {
            return pjp.proceed();
        } catch (Exception e) {
            status = "error";
            throw e;
        } finally {
            sample.stop(Timer.builder(metricName)
                .tag("class", pjp.getTarget().getClass().getSimpleName())
                .tag("method", pjp.getSignature().getName())
                .tag("status", status)
                .register(meterRegistry));
        }
    }
}

// 사용
@Service
public class OrderService {
    @Timed("order.create")
    public Order createOrder(CreateOrderDto dto) {
        // 비즈니스 로직 — AOP가 자동으로 실행 시간 측정
        return orderRepository.save(toEntity(dto));
    }
}

실전 패턴 2: 자동 재시도 Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
    int maxAttempts() default 3;
    long backoffMs() default 1000;
    double multiplier() default 2.0;
    Class<? extends Throwable>[] retryOn()
        default { RuntimeException.class };
}

@Aspect
@Component
@Slf4j
public class RetryAspect {

    @Around("@annotation(retryable)")
    public Object retry(ProceedingJoinPoint pjp, Retryable retryable)
            throws Throwable {
        int maxAttempts = retryable.maxAttempts();
        long backoff = retryable.backoffMs();
        Throwable lastException = null;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return pjp.proceed();
            } catch (Throwable e) {
                lastException = e;

                // 재시도 대상 예외인지 확인
                boolean shouldRetry = Arrays.stream(retryable.retryOn())
                    .anyMatch(cls -> cls.isAssignableFrom(e.getClass()));

                if (!shouldRetry || attempt == maxAttempts) {
                    throw e;
                }

                log.warn("재시도 {}/{}: {}.{}() — {}",
                    attempt, maxAttempts,
                    pjp.getTarget().getClass().getSimpleName(),
                    pjp.getSignature().getName(),
                    e.getMessage());

                Thread.sleep(backoff);
                backoff = (long) (backoff * retryable.multiplier());
            }
        }
        throw lastException;
    }
}

// 사용: DB 데드락, 외부 API 타임아웃 등에 적용
@Retryable(maxAttempts = 3, backoffMs = 500,
    retryOn = { DeadlockLoserDataAccessException.class })
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
    // 데드락 발생 시 자동 재시도
}

실전 패턴 3: 감사 로그 Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();                  // "CREATE", "UPDATE", "DELETE"
    String resource();                // "ORDER", "USER"
}

@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {
    private final AuditLogRepository auditLogRepository;
    private final SecurityContextHolder securityContext;

    @AfterReturning(
        pointcut = "@annotation(auditable)",
        returning = "result")
    public void audit(JoinPoint jp, Auditable auditable, Object result) {
        String userId = securityContext.getCurrentUserId();
        Object[] args = jp.getArgs();

        AuditLog log = AuditLog.builder()
            .userId(userId)
            .action(auditable.action())
            .resource(auditable.resource())
            .resourceId(extractId(result))
            .params(serializeArgs(args))
            .timestamp(Instant.now())
            .build();

        auditLogRepository.save(log);
    }

    private String extractId(Object result) {
        if (result instanceof BaseEntity entity) {
            return entity.getId().toString();
        }
        return "unknown";
    }
}

// 사용
@Auditable(action = "CREATE", resource = "ORDER")
public Order createOrder(CreateOrderDto dto) {
    return orderRepository.save(toEntity(dto));
}

Self-Invocation 문제와 해결

Spring AOP는 프록시 기반으로 동작하므로, 같은 클래스 내에서 메서드를 직접 호출하면 프록시를 거치지 않아 AOP가 적용되지 않는다. 이것이 가장 흔한 AOP 함정이다.

@Service
public class OrderService {

    @Timed("order.create")
    public Order createOrder(CreateOrderDto dto) {
        // ...
        this.sendNotification(order);  // ❌ AOP 미적용! (self-invocation)
        return order;
    }

    @Async
    public void sendNotification(Order order) {
        // @Async가 동작하지 않음 — 프록시를 거치지 않았기 때문
    }
}

// ✅ 해결법 1: 별도 서비스로 분리
@Service
@RequiredArgsConstructor
public class OrderService {
    private final NotificationService notificationService;

    @Timed("order.create")
    public Order createOrder(CreateOrderDto dto) {
        Order order = orderRepository.save(toEntity(dto));
        notificationService.send(order);  // ✅ 프록시 경유
        return order;
    }
}

// ✅ 해결법 2: self-injection (덜 권장)
@Service
public class OrderService {
    @Lazy @Autowired
    private OrderService self;

    public Order createOrder(CreateOrderDto dto) {
        Order order = orderRepository.save(toEntity(dto));
        self.sendNotification(order);  // ✅ 프록시 경유
        return order;
    }
}

Spring Cache 추상화 실전 글에서 다룬 @Cacheable도 동일한 self-invocation 제약을 갖는다. AOP 기반 어노테이션은 반드시 외부 호출로 실행해야 한다.

Aspect 실행 순서 제어: @Order

// 숫자가 작을수록 먼저 실행 (외부 → 내부)
@Aspect @Order(1)  // 가장 바깥
public class SecurityAspect { ... }

@Aspect @Order(2)
public class RetryAspect { ... }

@Aspect @Order(3)  // 가장 안쪽 (메서드에 가장 가까움)
public class LoggingAspect { ... }

// 실행 순서:
// Security.before → Retry.before → Logging.before
//   → 메서드 실행 →
// Logging.after → Retry.after → Security.after

CGLIB vs JDK Dynamic Proxy

항목 JDK Dynamic Proxy CGLIB (Spring Boot 기본)
대상 인터페이스 기반 클래스 기반 (서브클래싱)
인터페이스 필요 필수 불필요
final 클래스/메서드 제한 없음 프록시 불가
성능 리플렉션 기반 바이트코드 생성 (더 빠름)

테스트 전략

@SpringBootTest
class RetryAspectTest {

    @Autowired
    private PaymentService paymentService;

    @MockBean
    private PaymentGateway paymentGateway;

    @Test
    void 일시적_실패_시_재시도_후_성공() {
        // 첫 두 번 실패, 세 번째 성공
        when(paymentGateway.charge(any()))
            .thenThrow(new TimeoutException("timeout"))
            .thenThrow(new TimeoutException("timeout"))
            .thenReturn(PaymentResult.success());

        PaymentResult result = paymentService.processPayment(dto);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway, times(3)).charge(any());
    }

    @Test
    void 최대_재시도_초과_시_예외_전파() {
        when(paymentGateway.charge(any()))
            .thenThrow(new TimeoutException("timeout"));

        assertThatThrownBy(() -> paymentService.processPayment(dto))
            .isInstanceOf(TimeoutException.class);

        verify(paymentGateway, times(3)).charge(any());
    }
}

// AOP 프록시가 적용되었는지 확인
@Test
void AOP_프록시가_적용됨() {
    assertThat(AopUtils.isAopProxy(paymentService)).isTrue();
    assertThat(AopUtils.isCglibProxy(paymentService)).isTrue();
}

정리: AOP 설계 체크리스트

  • 횡단 관심사만 AOP로: 로깅, 재시도, 감사, 메트릭 등. 비즈니스 로직은 AOP에 넣지 않는다
  • @Around 신중하게: proceed() 누락 시 원본 메서드가 실행되지 않는다
  • Self-invocation 주의: 같은 클래스 내 호출은 프록시를 거치지 않음 → 서비스 분리로 해결
  • @Order로 순서 제어: 보안 → 재시도 → 로깅 순서가 일반적
  • final 메서드 금지: CGLIB 프록시는 final을 오버라이드할 수 없다
  • 커스텀 어노테이션 활용: @annotation Pointcut으로 명시적 적용 → 가독성 향상

AOP는 Resilience4j 서킷브레이커나 @Transactional 등 Spring 생태계 전반의 기반 기술이다. 프록시 동작 원리를 이해하면 Spring의 “마법”이 더 이상 마법이 아니게 된다.

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