Spring AOP 실전 심화 가이드

Spring AOP란?

Spring AOP(Aspect-Oriented Programming)는 횡단 관심사(cross-cutting concern)를 비즈니스 로직에서 분리하여 모듈화하는 프로그래밍 패러다임입니다. 로깅, 트랜잭션, 보안, 캐싱 등 여러 레이어에 걸쳐 반복되는 코드를 Aspect로 추출하면, 핵심 로직의 가독성과 유지보수성이 비약적으로 향상됩니다.

Spring Framework는 프록시 기반 AOP를 기본 전략으로 채택하며, JDK Dynamic Proxy와 CGLIB 두 가지 메커니즘을 상황에 따라 자동 선택합니다. 이 글에서는 AOP의 핵심 개념부터 실전 패턴, 그리고 운영 시 주의사항까지 깊이 있게 다루겠습니다.

핵심 용어 정리

용어 설명
Aspect 횡단 관심사를 모듈화한 단위 (@Aspect 클래스)
Join Point Advice가 적용될 수 있는 지점 (메서드 실행 시점)
Pointcut Join Point를 선별하는 표현식
Advice 실제 실행될 부가 로직 (Before, After, Around 등)
Weaving Aspect를 대상 객체에 적용하는 과정
Target Aspect가 적용되는 원본 객체

프록시 메커니즘: JDK vs CGLIB

Spring AOP의 동작 원리를 이해하려면 프록시 생성 전략을 반드시 알아야 합니다.

// 인터페이스가 있으면 → JDK Dynamic Proxy (기본)
public interface OrderService {
    Order createOrder(OrderRequest request);
}

// 인터페이스가 없으면 → CGLIB (서브클래스 프록시)
@Service
public class PaymentService {
    public PaymentResult process(PaymentRequest request) { ... }
}

// Spring Boot 2.x부터 기본값: proxyTargetClass=true (CGLIB 우선)
// application.yml에서 변경 가능
spring:
  aop:
    proxy-target-class: false  # JDK Proxy 강제

CGLIB는 final 클래스/메서드에 프록시를 생성할 수 없고, JDK Proxy는 인터페이스 기반으로만 동작합니다. Spring Boot 2.x 이후 CGLIB가 기본이므로, 대부분의 경우 인터페이스 없이도 AOP가 적용됩니다.

Advice 타입별 실전 사용법

@Before — 사전 검증/로깅

@Aspect
@Component
@Order(1)
public class AuthorizationAspect {

    @Before("@annotation(requireRole)")
    public void checkRole(JoinPoint jp, RequireRole requireRole) {
        String currentRole = SecurityContextHolder.getContext()
            .getAuthentication().getAuthorities().toString();
        
        if (!currentRole.contains(requireRole.value())) {
            throw new AccessDeniedException(
                "Required role: " + requireRole.value());
        }
        log.info("Authorization passed for {}", 
            jp.getSignature().getName());
    }
}

@AfterReturning — 결과 후처리

@Aspect
@Component
public class AuditAspect {

    @AfterReturning(
        pointcut = "execution(* com.app.service.*Service.create*(..))",
        returning = "result"
    )
    public void auditCreation(JoinPoint jp, Object result) {
        AuditLog audit = AuditLog.builder()
            .action("CREATE")
            .entity(result.getClass().getSimpleName())
            .method(jp.getSignature().toShortString())
            .timestamp(Instant.now())
            .build();
        auditRepository.save(audit);
    }
}

@Around — 가장 강력한 Advice

@Aspect
@Component
public class PerformanceAspect {

    @Around("@annotation(Monitored)")
    public Object measureExecutionTime(ProceedingJoinPoint pjp) 
            throws Throwable {
        String method = pjp.getSignature().toShortString();
        StopWatch sw = new StopWatch();
        
        try {
            sw.start();
            Object result = pjp.proceed();  // 원본 메서드 실행
            sw.stop();
            
            log.info("[PERF] {} completed in {}ms", 
                method, sw.getTotalTimeMillis());
            
            if (sw.getTotalTimeMillis() > 3000) {
                meterRegistry.counter("slow_method", 
                    "method", method).increment();
            }
            return result;
            
        } catch (Exception e) {
            sw.stop();
            log.error("[PERF] {} failed after {}ms: {}", 
                method, sw.getTotalTimeMillis(), e.getMessage());
            throw e;
        }
    }
}

Pointcut 표현식 마스터하기

Pointcut 표현식은 AOP의 핵심입니다. 정확한 대상 선별이 성능과 안정성을 좌우합니다.

@Aspect
@Component
public class PointcutDefinitions {

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

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

    // 3. @annotation: 커스텀 어노테이션 기반
    @Pointcut("@annotation(com.app.annotation.Cacheable)")
    public void cacheableMethods() {}

    // 4. @within: 클래스 레벨 어노테이션
    @Pointcut("@within(org.springframework.stereotype.Service)")
    public void allServiceBeans() {}

    // 5. 조합: AND, OR, NOT
    @Pointcut("allServiceMethods() && !execution(* *.get*(..))")
    public void writeMethods() {}

    // 6. args: 파라미터 타입 매칭
    @Pointcut("execution(* *.*(..)) && args(request,..)")
    public void methodsWithRequest(HttpServletRequest request) {}
}

실전 패턴: 분산 락 AOP

MSA 환경에서 자주 사용되는 분산 트랜잭션 처리에 AOP를 활용한 분산 락 패턴입니다.

// 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    String key();
    long waitTime() default 5L;
    long leaseTime() default 10L;
    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

// AOP Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;
    private final ExpressionParser parser = new SpelExpressionParser();

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint pjp, 
                       DistributedLock distributedLock) throws Throwable {
        
        // SpEL로 동적 키 생성
        String lockKey = resolveKey(distributedLock.key(), pjp);
        RLock rLock = redissonClient.getLock("lock:" + lockKey);

        boolean acquired = rLock.tryLock(
            distributedLock.waitTime(),
            distributedLock.leaseTime(),
            distributedLock.timeUnit()
        );

        if (!acquired) {
            throw new LockAcquisitionException(
                "Failed to acquire lock: " + lockKey);
        }

        try {
            return pjp.proceed();
        } finally {
            if (rLock.isHeldByCurrentThread()) {
                rLock.unlock();
            }
        }
    }

    private String resolveKey(String expression, 
                               ProceedingJoinPoint pjp) {
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        EvaluationContext ctx = new StandardEvaluationContext();
        String[] paramNames = sig.getParameterNames();
        Object[] args = pjp.getArgs();
        
        for (int i = 0; i < paramNames.length; i++) {
            ctx.setVariable(paramNames[i], args[i]);
        }
        return parser.parseExpression(expression)
                      .getValue(ctx, String.class);
    }
}

// 사용 예시
@Service
public class StockService {

    @DistributedLock(key = "#productId", leaseTime = 30)
    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        Stock stock = stockRepository.findByProductId(productId)
            .orElseThrow();
        stock.decrease(quantity);
    }
}

Self-Invocation 문제와 해결

Spring AOP 사용 시 가장 흔한 실수는 self-invocation(자기 호출) 문제입니다. 같은 클래스 내에서 메서드를 호출하면 프록시를 거치지 않아 AOP가 적용되지 않습니다.

@Service
public class OrderService {

    // ❌ 내부 호출 — AOP 미적용!
    public void processOrder(Order order) {
        validateOrder(order);   // this.validateOrder() → 프록시 우회
    }

    @Validated
    public void validateOrder(Order order) { ... }

    // ✅ 해결 1: AopContext 사용
    public void processOrderFixed(Order order) {
        ((OrderService) AopContext.currentProxy())
            .validateOrder(order);
    }

    // ✅ 해결 2: 자기 주입 (권장)
    @Lazy @Autowired
    private OrderService self;

    public void processOrderBetter(Order order) {
        self.validateOrder(order);  // 프록시 경유
    }

    // ✅ 해결 3: 별도 클래스로 분리 (가장 권장)
    // OrderValidator 클래스로 validateOrder 이동
}

@Order와 Aspect 실행 순서

여러 Aspect가 동일 Join Point에 적용될 때 @Order실행 순서를 제어합니다.

// 실행 순서: 낮은 숫자 → 높은 숫자 (Before 기준)
// 반환 순서: 높은 숫자 → 낮은 숫자 (After 기준)

@Aspect @Order(1)  // 가장 먼저 실행
public class SecurityAspect { ... }

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

@Aspect @Order(3)  // 세 번째
public class LoggingAspect { ... }

// 실행 흐름 (양파 껍질 모델):
// Security.before → Transaction.before → Logging.before
//   → 실제 메서드 실행
// Logging.after → Transaction.after → Security.after

운영 시 주의사항

항목 주의사항 대응
성능 Pointcut 범위가 너무 넓으면 불필요한 프록시 생성 @annotation 기반으로 범위 최소화
디버깅 스택트레이스에 프록시 레이어 추가 spring.aop.proxy-target-class 로그 활용
테스트 @SpringBootTest 없이 Aspect 단독 테스트 불가 통합 테스트 또는 ProxyFactory 수동 생성
final 메서드 CGLIB에서 final 메서드 AOP 미적용 final 제거 또는 인터페이스 도입
예외 처리 @Around에서 예외 삼킴 위험 반드시 throw e로 재전파

마무리

Spring AOP는 단순한 로깅 도구가 아니라, 분산 락, 감사 로그, 성능 모니터링, 권한 검증 등 엔터프라이즈 애플리케이션의 핵심 인프라입니다. 프록시 메커니즘을 정확히 이해하고, self-invocation 함정을 피하며, Pointcut 범위를 최소화하는 것이 안정적인 AOP 운영의 핵심입니다. @Around Advice와 커스텀 어노테이션 조합은 특히 강력하므로, 반복되는 횡단 관심사가 보이면 적극적으로 AOP 도입을 고려해 보세요.

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