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 도입을 고려해 보세요.