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 설계의 핵심입니다.