Spring AOP: 5가지 Advice 타입

Spring AOP란? 관점 지향 프로그래밍의 핵심 개념

Spring 애플리케이션에서 “모든 서비스 메서드의 실행 시간을 측정하고 싶다”, “특정 어노테이션이 붙은 메서드에 자동으로 권한 검사를 넣고 싶다”, “예외 발생 시 자동으로 Slack 알림을 보내고 싶다” — 이런 횡단 관심사(cross-cutting concerns)를 비즈니스 로직과 분리하는 것이 AOP(Aspect-Oriented Programming)입니다.

Spring AOP는 @Transactional, @Async, @Cacheable 같은 Spring의 핵심 기능이 내부적으로 사용하는 메커니즘입니다. 이 글에서는 AOP의 5가지 Advice 타입부터 Pointcut 표현식 완전 정복, 커스텀 어노테이션 기반 Aspect 설계, 실행 순서(@Order) 제어, 그리고 @Transactional의 프록시 함정과 동일한 self-invocation 문제까지 운영 수준에서 완전히 다룹니다.

AOP 핵심 용어 정리: Aspect, Advice, Pointcut, JoinPoint

용어 설명 비유
Aspect 횡단 관심사를 모듈화한 클래스 CCTV 시스템 전체
Advice Aspect가 실행하는 실제 동작 CCTV가 녹화하는 행위
Pointcut Advice가 적용될 지점을 선별하는 표현식 CCTV를 설치할 위치 선정
JoinPoint Advice가 실행되는 실제 지점 (메서드 호출) CCTV에 찍히는 순간
Target Aspect가 적용되는 대상 객체 CCTV에 찍히는 사람
Weaving Aspect를 Target에 적용하는 과정 CCTV 설치 작업

5가지 Advice 타입: 메서드 실행 전후 개입

@Aspect
@Component
public class LoggingAspect {

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

    // 2. @AfterReturning: 메서드 정상 반환 후
    @AfterReturning(
        pointcut = "execution(* com.example.service.*.*(..))",
        returning = "result"
    )
    public void logAfterReturning(JoinPoint joinPoint, Object result) {
        log.info("← {}.{}() 반환: {}",
            joinPoint.getTarget().getClass().getSimpleName(),
            joinPoint.getSignature().getName(),
            result);
    }

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

    // 4. @After: 정상/예외 무관 항상 실행 (finally)
    @After("execution(* com.example.service.*.*(..))")
    public void logAfter(JoinPoint joinPoint) {
        log.debug("◆ {}.{}() 완료",
            joinPoint.getTarget().getClass().getSimpleName(),
            joinPoint.getSignature().getName());
    }

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

Advice 실행 순서

// 정상 실행 시:
@Around (before proceed) → @Before → 메서드 실행 → @AfterReturning → @After → @Around (after proceed)

// 예외 발생 시:
@Around (before proceed) → @Before → 메서드 실행 → @AfterThrowing → @After → @Around (catch)

@Around vs 다른 Advice: 언제 무엇을 쓸까

Advice 메서드 실행 제어 반환값 변경 사용 시점
@Before 로깅, 권한 검사 (실행 전)
@AfterReturning ❌* 결과 로깅, 감사
@AfterThrowing 예외 로깅, 알림
@After 리소스 정리 (finally)
@Around ✅ (proceed 호출 여부) 시간 측정, 캐싱, 재시도, 트랜잭션

원칙: 가능하면 가장 구체적인 Advice를 사용하세요. @Around는 강력하지만, proceed() 호출을 잊으면 원본 메서드가 실행되지 않는 위험이 있습니다.

Pointcut 표현식 완전 정복

Pointcut은 “어떤 메서드에 Advice를 적용할지”를 결정하는 표현식입니다. Spring AOP에서 가장 중요한 부분입니다.

execution 표현식: 메서드 시그니처 매칭

// 문법: execution(접근제어자? 반환타입 패키지.클래스.메서드(파라미터) throws?)

// 모든 public 메서드
execution(public * *(..))

// service 패키지의 모든 메서드
execution(* com.example.service.*.*(..))

// service 패키지와 하위 패키지 포함
execution(* com.example.service..*.*(..))
//                              ^^ .. = 하위 패키지 포함

// OrderService의 모든 메서드
execution(* com.example.service.OrderService.*(..))

// create로 시작하는 메서드
execution(* com.example.service.*.create*(..))

// 반환 타입이 List인 메서드
execution(java.util.List com.example.service.*.*(..))

// 파라미터가 없는 메서드
execution(* com.example.service.*.*(  ))

// 첫 번째 파라미터가 String인 메서드
execution(* com.example.service.*.*(String, ..))

// 정확히 2개 파라미터를 받는 메서드
execution(* com.example.service.*.*(*, *))

@annotation: 커스텀 어노테이션 매칭

// @LogExecutionTime이 붙은 메서드에만 적용
@Around("@annotation(com.example.annotation.LogExecutionTime)")
public Object logTime(ProceedingJoinPoint joinPoint) throws Throwable {
    // ...
}

// 어노테이션 값 접근
@Around("@annotation(rateLimited)")
public Object handleRateLimit(ProceedingJoinPoint joinPoint, RateLimited rateLimited) 
        throws Throwable {
    int limit = rateLimited.value();  // 어노테이션 속성 접근
    String key = rateLimited.key();
    // ...
}

within: 특정 클래스/패키지 내 모든 메서드

// OrderService의 모든 메서드
within(com.example.service.OrderService)

// service 패키지의 모든 클래스
within(com.example.service.*)

// @RestController가 붙은 클래스의 모든 메서드
@within(org.springframework.web.bind.annotation.RestController)

Pointcut 조합: &&, ||, !

@Aspect
@Component
public class AuditAspect {

    // 재사용 가능한 Pointcut 정의
    @Pointcut("execution(* com.example.service..*.*(..))")
    public void serviceLayer() {}

    @Pointcut("@annotation(com.example.annotation.Auditable)")
    public void auditableMethod() {}

    @Pointcut("execution(* com.example..*Controller.*(..))")
    public void controllerLayer() {}

    // 조합: service 레이어이면서 @Auditable이 붙은 메서드
    @Before("serviceLayer() && auditableMethod()")
    public void auditServiceCall(JoinPoint joinPoint) {
        // ...
    }

    // 조합: service 또는 controller 레이어
    @Around("serviceLayer() || controllerLayer()")
    public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
        // ...
    }

    // 부정: service 레이어에서 get*으로 시작하지 않는 메서드
    @Before("serviceLayer() && !execution(* com.example.service..*.get*(..))")
    public void logModification(JoinPoint joinPoint) {
        // 조회가 아닌 변경 작업만 로깅
    }
}

실무 패턴 1: 커스텀 어노테이션 기반 실행 시간 측정

// 1. 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {
    String value() default "";  // 설명
    long warnThresholdMs() default 1000;  // 경고 임계치
}

// 2. Aspect 구현
@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {

    @Around("@annotation(annotation)")
    public Object measureExecutionTime(
            ProceedingJoinPoint joinPoint, 
            LogExecutionTime annotation) throws Throwable {
        
        String methodName = joinPoint.getSignature().toShortString();
        String description = annotation.value().isEmpty() 
            ? methodName : annotation.value();
        
        long start = System.nanoTime();
        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = (System.nanoTime() - start) / 1_000_000;
            
            if (elapsed > annotation.warnThresholdMs()) {
                log.warn("🐢 SLOW: {} took {}ms (threshold: {}ms)",
                    description, elapsed, annotation.warnThresholdMs());
            } else {
                log.info("⏱ {} took {}ms", description, elapsed);
            }
        }
    }
}

// 3. 사용
@Service
public class OrderService {
    
    @LogExecutionTime(value = "주문 생성", warnThresholdMs = 500)
    public Order createOrder(OrderRequest request) {
        // 500ms 초과 시 WARN 레벨 로깅
        return orderRepository.save(new Order(request));
    }
}

실무 패턴 2: 자동 재시도(Retry) Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
    int maxAttempts() default 3;
    long delayMs() default 100;
    double multiplier() default 2.0;  // 지수 백오프
    Class<? extends Exception>[] retryOn() default {RuntimeException.class};
}

@Aspect
@Component
@Slf4j
public class RetryAspect {

    @Around("@annotation(retryable)")
    public Object retry(ProceedingJoinPoint joinPoint, Retryable retryable) 
            throws Throwable {
        
        int maxAttempts = retryable.maxAttempts();
        long delay = retryable.delayMs();
        Throwable lastException = null;
        
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return joinPoint.proceed();
            } catch (Throwable e) {
                lastException = e;
                
                // 재시도 대상 예외인지 확인
                boolean shouldRetry = false;
                for (Class<? extends Exception> retryOn : retryable.retryOn()) {
                    if (retryOn.isInstance(e)) {
                        shouldRetry = true;
                        break;
                    }
                }
                
                if (!shouldRetry || attempt == maxAttempts) {
                    throw e;
                }
                
                log.warn("⚠ {} 재시도 {}/{} ({}ms 후): {}",
                    joinPoint.getSignature().toShortString(),
                    attempt, maxAttempts, delay, e.getMessage());
                
                Thread.sleep(delay);
                delay = (long) (delay * retryable.multiplier());
            }
        }
        
        throw lastException;
    }
}

// 사용
@Retryable(maxAttempts = 3, delayMs = 200, retryOn = {TransientDataAccessException.class})
public ExternalResponse callExternalApi(String payload) {
    return restTemplate.postForObject(apiUrl, payload, ExternalResponse.class);
}

실무 패턴 3: Rate Limiting Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
    int requests() default 10;      // 허용 횟수
    int periodSeconds() default 60; // 기간 (초)
    String key() default "";        // 키 (SpEL 지원)
}

@Aspect
@Component
public class RateLimitAspect {

    private final StringRedisTemplate redis;
    
    @Around("@annotation(rateLimited)")
    public Object checkRateLimit(ProceedingJoinPoint joinPoint, RateLimited rateLimited) 
            throws Throwable {
        
        String key = resolveKey(joinPoint, rateLimited);
        String redisKey = "rate:" + key;
        
        Long count = redis.opsForValue().increment(redisKey);
        if (count == 1) {
            redis.expire(redisKey, Duration.ofSeconds(rateLimited.periodSeconds()));
        }
        
        if (count > rateLimited.requests()) {
            throw new TooManyRequestsException(
                String.format("Rate limit exceeded: %d/%d per %ds",
                    count, rateLimited.requests(), rateLimited.periodSeconds()));
        }
        
        return joinPoint.proceed();
    }
    
    private String resolveKey(ProceedingJoinPoint joinPoint, RateLimited rateLimited) {
        if (rateLimited.key().isEmpty()) {
            return joinPoint.getSignature().toShortString();
        }
        // SpEL 파싱으로 동적 키 생성 가능
        return rateLimited.key();
    }
}

// 사용
@RateLimited(requests = 5, periodSeconds = 60, key = "order-create")
public Order createOrder(OrderRequest request) { ... }

실무 패턴 4: 분산 락(Distributed Lock) Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();               // 락 키 (SpEL 지원)
    long waitTimeMs() default 5000;   // 락 대기 시간
    long leaseTimeMs() default 10000; // 락 유지 시간
}

@Aspect
@Component
public class DistributedLockAspect {

    private final RedissonClient redisson;
    private final SpelExpressionParser parser = new SpelExpressionParser();

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) 
            throws Throwable {
        
        String lockKey = "lock:" + resolveSpEL(joinPoint, distributedLock.key());
        RLock lock = redisson.getLock(lockKey);
        
        boolean acquired = lock.tryLock(
            distributedLock.waitTimeMs(),
            distributedLock.leaseTimeMs(),
            TimeUnit.MILLISECONDS
        );
        
        if (!acquired) {
            throw new LockAcquisitionException("락 획득 실패: " + lockKey);
        }
        
        try {
            return joinPoint.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    private String resolveSpEL(ProceedingJoinPoint joinPoint, String expression) {
        MethodSignature sig = (MethodSignature) joinPoint.getSignature();
        StandardEvaluationContext context = new StandardEvaluationContext();
        String[] paramNames = sig.getParameterNames();
        Object[] args = joinPoint.getArgs();
        for (int i = 0; i < paramNames.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }
        return parser.parseExpression(expression).getValue(context, String.class);
    }
}

// 사용: 상품 ID별 분산 락
@DistributedLock(key = "#productId", waitTimeMs = 3000, leaseTimeMs = 5000)
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.decreaseStock(quantity);
}

실무 패턴 5: 감사 로그(Audit Log) Aspect

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();  // "CREATE_ORDER", "DELETE_USER" 등
}

@Aspect
@Component
public class AuditAspect {

    private final AuditLogRepository auditLogRepository;
    private final ObjectMapper objectMapper;

    @AfterReturning(
        pointcut = "@annotation(auditable)",
        returning = "result"
    )
    public void audit(JoinPoint joinPoint, Auditable auditable, Object result) {
        // 현재 인증된 사용자 정보
        String username = SecurityContextHolder.getContext()
            .getAuthentication().getName();

        AuditLog log = AuditLog.builder()
            .action(auditable.action())
            .username(username)
            .method(joinPoint.getSignature().toShortString())
            .args(serializeArgs(joinPoint.getArgs()))
            .result(result != null ? result.toString() : null)
            .timestamp(Instant.now())
            .build();

        auditLogRepository.save(log);
    }

    @AfterThrowing(
        pointcut = "@annotation(auditable)",
        throwing = "ex"
    )
    public void auditFailure(JoinPoint joinPoint, Auditable auditable, Exception ex) {
        String username = SecurityContextHolder.getContext()
            .getAuthentication().getName();

        AuditLog log = AuditLog.builder()
            .action(auditable.action() + "_FAILED")
            .username(username)
            .method(joinPoint.getSignature().toShortString())
            .errorMessage(ex.getMessage())
            .timestamp(Instant.now())
            .build();

        auditLogRepository.save(log);
    }
}

// 사용
@Auditable(action = "CREATE_ORDER")
public Order createOrder(OrderRequest request) { ... }

@Auditable(action = "DELETE_USER")
public void deleteUser(Long userId) { ... }

Aspect 실행 순서: @Order로 제어

여러 Aspect가 같은 메서드에 적용될 때 실행 순서를 @Order로 제어합니다:

@Aspect
@Component
@Order(1)  // 가장 먼저 (바깥쪽)
public class LoggingAspect { ... }

@Aspect
@Component
@Order(2)  // 두 번째
public class RetryAspect { ... }

@Aspect
@Component
@Order(3)  // 세 번째 (안쪽)
public class TransactionAspect { ... }

// 실행 흐름 (양파 구조):
// Logging(before) → Retry(before) → Transaction(before)
//   → 메서드 실행
// Transaction(after) → Retry(after) → Logging(after)

설계 원칙:

  • 로깅: @Order(1) — 가장 바깥에서 전체 시간 측정
  • 재시도: @Order(2) — 트랜잭션 바깥에서 재시도해야 새 TX 시작
  • 트랜잭션: @Order(3) — 가장 안쪽에서 DB 작업 묶기

⚠️ 함정: @Order를 지정하지 않으면 순서가 보장되지 않습니다(undefined). 여러 Aspect를 사용할 때는 반드시 @Order를 명시하세요.

JoinPoint와 ProceedingJoinPoint: 메서드 정보 접근

@Around("@annotation(logExecutionTime)")
public Object around(ProceedingJoinPoint joinPoint, LogExecutionTime logExecutionTime) 
        throws Throwable {
    
    // 메서드 시그니처
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    String methodName = method.getName();
    Class<?> returnType = method.getReturnType();
    
    // 파라미터 정보
    String[] paramNames = signature.getParameterNames();  // ["orderId", "quantity"]
    Object[] args = joinPoint.getArgs();                   // [123L, 5]
    Class<?>[] paramTypes = signature.getParameterTypes(); // [Long.class, int.class]
    
    // 타겟 객체
    Object target = joinPoint.getTarget();        // 원본 객체 (프록시 아님)
    Class<?> targetClass = target.getClass();
    
    // 어노테이션 접근
    LogExecutionTime annotation = method.getAnnotation(LogExecutionTime.class);
    
    // 파라미터 변경 후 실행 (주의: 원본 메서드의 파라미터가 변경됨)
    Object[] modifiedArgs = Arrays.copyOf(args, args.length);
    modifiedArgs[0] = 999L;  // 첫 번째 파라미터 변경
    
    return joinPoint.proceed(modifiedArgs);  // 변경된 파라미터로 실행
}

Self-Invocation 문제: @Transactional과 동일한 함정

Spring AOP는 프록시 기반이므로, 같은 클래스 내부에서 호출하면 Aspect가 적용되지 않습니다:

@Service
public class OrderService {

    // ❌ processOrder()에서 내부 호출 → @LogExecutionTime 무시됨!
    public void processOrder(OrderRequest request) {
        validate(request);
        createOrder(request);  // this.createOrder() → 프록시 우회
    }

    @LogExecutionTime("주문 생성")
    public void createOrder(OrderRequest request) {
        // @LogExecutionTime이 적용되지 않음!
    }
}

// ✅ 해결: 클래스 분리
@Service
public class OrderFacade {
    private final OrderService orderService;
    
    public void processOrder(OrderRequest request) {
        validate(request);
        orderService.createOrder(request); // 프록시를 통한 호출 → Aspect 적용!
    }
}

이 문제는 Spring @Transactional의 self-invocation과 완전히 동일한 원인입니다. @Cacheable, @Async 등 모든 프록시 기반 기능에 해당합니다.

Spring AOP vs AspectJ: 언제 무엇을 선택하는가

특성 Spring AOP AspectJ (CTW/LTW)
Weaving 방식 런타임 프록시 컴파일/로드 타임 바이트코드 조작
JoinPoint 메서드 실행만 필드 접근, 생성자, static 메서드 등
Self-invocation ❌ 적용 안 됨 ✅ 적용됨
private 메서드 ❌ 적용 안 됨 ✅ 적용됨
성능 프록시 오버헤드 있음 바이트코드 직접 수정 → 오버헤드 없음
설정 복잡도 간단 (Spring Boot 자동 설정) 복잡 (컴파일러/에이전트 설정 필요)

실무 권장: 99%의 경우 Spring AOP로 충분합니다. private 메서드나 self-invocation이 필요한 극소수의 경우에만 AspectJ를 검토하세요.

테스트: Aspect 동작 검증

@SpringBootTest
class RetryAspectTest {

    @Autowired
    private ExternalApiService apiService; // Aspect 적용된 빈

    @MockBean
    private RestTemplate restTemplate;

    @Test
    void shouldRetryOnTransientFailure() {
        // 처음 2번 실패, 3번째 성공
        when(restTemplate.postForObject(any(), any(), any()))
            .thenThrow(new TransientDataAccessException("timeout") {})
            .thenThrow(new TransientDataAccessException("timeout") {})
            .thenReturn(new ExternalResponse("success"));

        ExternalResponse result = apiService.callExternalApi("test");

        assertThat(result.getStatus()).isEqualTo("success");
        verify(restTemplate, times(3)).postForObject(any(), any(), any());
    }

    @Test
    void shouldThrowAfterMaxRetries() {
        when(restTemplate.postForObject(any(), any(), any()))
            .thenThrow(new TransientDataAccessException("timeout") {});

        assertThatThrownBy(() -> apiService.callExternalApi("test"))
            .isInstanceOf(TransientDataAccessException.class);

        verify(restTemplate, times(3)).postForObject(any(), any(), any());
    }
}

정리: Spring AOP 설계 체크리스트

항목 체크
가장 구체적인 Advice 타입 사용 (@Around보다 @Before/@AfterReturning 우선)
@Around에서 proceed() 호출 누락 방지
Pointcut 재사용 (@Pointcut 메서드로 분리)
커스텀 어노테이션으로 선언적 적용
여러 Aspect 사용 시 @Order 명시
Self-invocation 금지 (같은 클래스 내부 호출 주의)
private/final 메서드에 Aspect 미적용 인지
Aspect 내 예외 처리 (Aspect 실패가 비즈니스에 영향 주지 않도록)
Pointcut 범위 최소화 (불필요한 메서드에 적용되지 않도록)
통합 테스트로 Aspect 동작 검증

Spring AOP는 @Transactional, @Cacheable, @Async 같은 Spring 핵심 기능의 기반 기술이면서, 동시에 커스텀 어노테이션으로 로깅, 재시도, Rate Limiting, 분산 락, 감사 로그 등 거의 모든 횡단 관심사를 선언적으로 구현할 수 있는 강력한 도구입니다. 핵심은 Pointcut 표현식을 정확하게 작성하고, 프록시 기반의 한계(self-invocation, private 메서드)를 항상 인식하는 것입니다.

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