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 메서드)를 항상 인식하는 것입니다.