Spring AOP 프록시 심화

Spring AOP란?

AOP(Aspect-Oriented Programming)는 로깅, 트랜잭션, 보안 등 횡단 관심사(Cross-Cutting Concern)를 비즈니스 로직과 분리하는 프로그래밍 패러다임입니다. Spring AOP는 프록시 기반으로 동작하며, @Aspect 어노테이션으로 선언적으로 구현할 수 있습니다.

Spring의 @Transactional, @Cacheable, @Async 등이 모두 AOP로 구현되어 있습니다. 이 글에서는 AOP의 프록시 메커니즘부터 실전 패턴까지 깊이 다룹니다.

프록시 메커니즘: JDK vs CGLIB

Spring AOP는 런타임 프록시를 생성해 메서드 호출을 가로챕니다. 두 가지 방식이 있습니다:

방식 조건 특징
JDK Dynamic Proxy 인터페이스 구현 시 인터페이스 기반, 빠름
CGLIB Proxy 클래스 직접 프록시 서브클래스 생성, final 불가

Spring Boot 2.0부터 기본값이 CGLIB입니다. 설정으로 변경할 수 있습니다:

# application.yml
spring:
  aop:
    proxy-target-class: true  # CGLIB (기본값)
    # false로 하면 인터페이스가 있을 때 JDK Proxy 사용

@Aspect 기본 구조

Aspect는 Advice(무엇을 할 것인가)와 Pointcut(어디에 적용할 것인가)의 조합입니다:

@Aspect
@Component
public class ExecutionTimeAspect {

    private static final Logger log =
        LoggerFactory.getLogger(ExecutionTimeAspect.class);

    @Around("execution(* com.example.service..*(..))")
    public Object measureTime(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            log.info("{}.{} — {}ms",
                pjp.getTarget().getClass().getSimpleName(),
                pjp.getSignature().getName(), ms);
        }
    }
}

@Around은 가장 강력한 Advice로, 메서드 실행 전·후·예외 시점 모두 제어할 수 있습니다. pjp.proceed()를 호출해야 실제 메서드가 실행됩니다.

Advice 종류 5가지

Spring AOP는 5종류의 Advice를 제공합니다:

Advice 실행 시점 용도
@Before 메서드 실행 전 권한 검사, 로깅
@AfterReturning 정상 반환 후 감사 로그, 캐시 갱신
@AfterThrowing 예외 발생 후 에러 알림, 보상 트랜잭션
@After 무조건 (finally) 리소스 정리
@Around 전·후 모두 시간 측정, 재시도, 캐싱

Pointcut 표현식 마스터

Pointcut은 “어떤 메서드에 적용할 것인가”를 정의합니다. 자주 쓰는 패턴들:

// 특정 패키지의 모든 public 메서드
@Pointcut("execution(public * com.example.service..*(..))")
public void serviceLayer() {}

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

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

// 조합
@Around("serviceLayer() && !loggableMethods()")
public Object combined(ProceedingJoinPoint pjp) throws Throwable {
    return pjp.proceed();
}

&&, ||, !로 Pointcut을 조합할 수 있습니다. 재사용을 위해 @Pointcut으로 분리하는 것이 좋습니다.

커스텀 어노테이션 + AOP 실전

가장 실용적인 패턴입니다. 커스텀 어노테이션으로 적용 대상을 명시적으로 지정합니다:

// 1. 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int value() default 10;       // 초당 허용 횟수
    String key() default "";      // 제한 키
}

// 2. Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {

    private final RedisTemplate<String, String> redis;

    @Around("@annotation(rateLimit)")
    public Object enforce(
            ProceedingJoinPoint pjp,
            RateLimit rateLimit) throws Throwable {

        String key = resolveKey(rateLimit.key(), pjp);
        String redisKey = "rate:" + key;

        Long count = redis.opsForValue().increment(redisKey);
        if (count == 1) {
            redis.expire(redisKey, 1, TimeUnit.SECONDS);
        }

        if (count > rateLimit.value()) {
            throw new TooManyRequestsException(
                "Rate limit exceeded: " + rateLimit.value() + "/s");
        }

        return pjp.proceed();
    }

    private String resolveKey(String key, ProceedingJoinPoint pjp) {
        if (!key.isEmpty()) return key;
        return pjp.getSignature().toShortString();
    }
}

// 3. 사용
@RateLimit(value = 5, key = "login")
public AuthResponse login(LoginRequest req) { ... }

@annotation(rateLimit) 바인딩으로 어노테이션 속성값에 직접 접근할 수 있습니다. 이 패턴은 Spring Retry 재시도 전략에서 다룬 @Retryable과 동일한 원리입니다.

Self-Invocation 문제와 해결

Spring AOP의 가장 흔한 함정입니다. 같은 클래스 내에서 메서드를 호출하면 프록시를 거치지 않아 AOP가 적용되지 않습니다:

@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderDto dto) {
        // ... 주문 생성
        this.sendNotification(dto);  // ❌ AOP 미적용!
    }

    @Async
    public void sendNotification(OrderDto dto) {
        // 프록시를 거치지 않아 @Async가 동작하지 않음
    }
}

해결 방법 3가지:

// 방법 1: 별도 서비스로 분리 (권장)
@Service
@RequiredArgsConstructor
public class OrderService {
    private final NotificationService notificationService;

    @Transactional
    public void createOrder(OrderDto dto) {
        // ...
        notificationService.send(dto);  // ✅ 프록시 경유
    }
}

// 방법 2: Self-injection
@Service
public class OrderService {
    @Lazy @Autowired
    private OrderService self;

    @Transactional
    public void createOrder(OrderDto dto) {
        self.sendNotification(dto);  // ✅ 프록시 경유
    }
}

// 방법 3: AopContext (비추천)
@EnableAspectJAutoProxy(exposeProxy = true)
// ...
((OrderService) AopContext.currentProxy()).sendNotification(dto);

방법 1(서비스 분리)이 가장 깔끔합니다. Self-injection은 순환 참조를 유발할 수 있어 @Lazy가 필수입니다.

감사 로그 Aspect

엔티티 변경 이력을 자동으로 기록하는 실전 패턴입니다. Spring JPA Auditing과 함께 사용하면 강력합니다:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();           // CREATE, UPDATE, DELETE
    String entityType();       // "Order", "User" 등
}

@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {

    private final AuditLogRepository auditRepo;
    private final SecurityContextService security;
    private final ObjectMapper mapper;

    @AfterReturning(
        pointcut = "@annotation(auditable)",
        returning = "result")
    public void audit(JoinPoint jp, Auditable auditable, Object result) {
        AuditLog log = AuditLog.builder()
            .action(auditable.action())
            .entityType(auditable.entityType())
            .entityId(extractId(result))
            .userId(security.getCurrentUserId())
            .payload(toJson(jp.getArgs()))
            .timestamp(Instant.now())
            .build();

        auditRepo.save(log);
    }

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

    private String toJson(Object[] args) {
        try { return mapper.writeValueAsString(args); }
        catch (Exception e) { return "[]"; }
    }
}

// 사용
@Auditable(action = "CREATE", entityType = "Order")
@Transactional
public Order createOrder(CreateOrderDto dto) { ... }

Aspect 실행 순서 제어

여러 Aspect가 같은 메서드에 적용될 때 @Order로 순서를 지정합니다:

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

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

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

숫자가 작을수록 먼저 실행됩니다. @Around의 경우 before는 Order 순, after는 역순으로 실행됩니다 (스택 구조).

테스트에서 AOP 검증

@SpringBootTest
class RateLimitAspectTest {

    @Autowired
    private TargetService service; // 프록시 주입됨

    @Test
    void shouldBlockAfterExceedingLimit() {
        // 프록시인지 확인
        assertTrue(AopUtils.isAopProxy(service));

        // 제한 횟수만큼 호출
        for (int i = 0; i < 5; i++) {
            service.rateLimitedMethod();
        }

        // 초과 시 예외
        assertThrows(TooManyRequestsException.class,
            () -> service.rateLimitedMethod());
    }
}

AopUtils.isAopProxy()로 프록시 적용 여부를 확인할 수 있습니다. 통합 테스트에서는 실제 프록시가 동작하므로 AOP 로직까지 검증됩니다.

성능 주의사항

  • Pointcut 범위 최소화: execution(* *..*(..))) 같은 광범위 표현식은 모든 빈에 프록시를 생성합니다
  • @Around 남용 금지: 단순 로깅에는 @Before/@After가 충분합니다
  • final 클래스/메서드 불가: CGLIB은 서브클래스를 생성하므로 final이면 프록시 생성 실패
  • private 메서드 불가: Spring AOP는 public 메서드만 가로챌 수 있습니다

마무리

Spring AOP는 횡단 관심사를 비즈니스 로직에서 깔끔하게 분리하는 핵심 메커니즘입니다. 프록시 동작 원리를 이해하면 Self-Invocation 같은 함정을 피할 수 있고, 커스텀 어노테이션 + @Aspect 조합으로 Rate Limiting, 감사 로그, 성능 측정 등 다양한 실전 패턴을 구현할 수 있습니다. Pointcut 표현식을 정확히 작성하고, Aspect 간 실행 순서를 @Order로 명시하는 것이 안정적인 AOP 설계의 핵심입니다.

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