Spring AOP란
Spring AOP(Aspect-Oriented Programming)는 로깅, 트랜잭션, 보안, 캐싱 등 횡단 관심사(Cross-Cutting Concerns)를 비즈니스 로직에서 분리하는 프로그래밍 패러다임이다. 핵심 로직을 수정하지 않고 부가 기능을 선언적으로 적용할 수 있어, 코드 중복을 제거하고 유지보수성을 크게 높인다.
Spring의 @Transactional, @Cacheable, @Async, @Retryable 모두 AOP 기반으로 동작한다. AOP의 원리를 이해하면 이 어노테이션들의 동작 방식과 제약(self-invocation 문제 등)을 정확히 파악할 수 있다.
핵심 용어 정리
| 용어 | 설명 | 예시 |
|---|---|---|
| Aspect | 횡단 관심사를 모듈화한 클래스 | LoggingAspect, RetryAspect |
| Join Point | Advice가 적용될 수 있는 지점 | 메서드 실행 시점 |
| Advice | Join Point에서 실행할 코드 | @Before, @After, @Around |
| Pointcut | Advice를 적용할 Join Point 선택 표현식 | execution(* com.app.service.*.*(..)) |
| Proxy | AOP가 적용된 대리 객체 | JDK Dynamic Proxy, CGLIB |
Advice 타입 5가지
@Aspect
@Component
@Slf4j
public class LoggingAspect {
// 1. @Before — 메서드 실행 전
@Before("execution(* com.app.service.*.*(..))")
public void logBefore(JoinPoint jp) {
log.info("▶ {}.{}() 호출",
jp.getTarget().getClass().getSimpleName(),
jp.getSignature().getName());
}
// 2. @AfterReturning — 정상 반환 후
@AfterReturning(
pointcut = "execution(* com.app.service.*.*(..))",
returning = "result")
public void logAfterReturning(JoinPoint jp, Object result) {
log.info("◀ {}.{}() 반환: {}",
jp.getTarget().getClass().getSimpleName(),
jp.getSignature().getName(),
result);
}
// 3. @AfterThrowing — 예외 발생 시
@AfterThrowing(
pointcut = "execution(* com.app.service.*.*(..))",
throwing = "ex")
public void logAfterThrowing(JoinPoint jp, Exception ex) {
log.error("✖ {}.{}() 예외: {}",
jp.getTarget().getClass().getSimpleName(),
jp.getSignature().getName(),
ex.getMessage());
}
// 4. @After — 정상/예외 무관하게 항상 실행 (finally)
@After("execution(* com.app.service.*.*(..))")
public void logAfter(JoinPoint jp) {
log.debug("● {}.{}() 완료",
jp.getTarget().getClass().getSimpleName(),
jp.getSignature().getName());
}
// 5. @Around — 가장 강력, 실행 전후 모두 제어
@Around("execution(* com.app.service.*.*(..))")
public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed(); // 원본 메서드 실행
return result;
} finally {
long duration = System.currentTimeMillis() - start;
log.info("⏱ {}.{}() → {}ms",
pjp.getTarget().getClass().getSimpleName(),
pjp.getSignature().getName(),
duration);
}
}
}
Pointcut 표현식 마스터
Pointcut은 AOP의 핵심이다. 어떤 메서드에 Advice를 적용할지 정밀하게 제어한다.
@Aspect
@Component
public class PointcutDefinitions {
// execution: 메서드 실행 매칭 (가장 많이 사용)
// 패턴: execution(접근제어자? 반환타입 패키지.클래스.메서드(파라미터) 예외?)
@Pointcut("execution(public * com.app.service.*Service.*(..))")
public void allServiceMethods() {}
// 반환타입 지정
@Pointcut("execution(java.util.List com.app.repository.*.*(..))")
public void repositoryListMethods() {}
// 파라미터 패턴
@Pointcut("execution(* *..OrderService.create(com.app.dto.CreateOrderDto, ..))")
public void orderCreate() {}
// @annotation: 특정 어노테이션이 붙은 메서드
@Pointcut("@annotation(com.app.annotation.Loggable)")
public void loggableMethods() {}
// @within: 특정 어노테이션이 붙은 클래스의 모든 메서드
@Pointcut("@within(org.springframework.stereotype.Service)")
public void allServiceBeans() {}
// within: 특정 패키지/클래스 내 모든 메서드
@Pointcut("within(com.app.controller..*)")
public void allControllerMethods() {}
// bean: 빈 이름으로 매칭 (Spring 전용)
@Pointcut("bean(*Service)")
public void allServiceNamedBeans() {}
// 조합: AND(&&), OR(||), NOT(!)
@Pointcut("allServiceMethods() && !loggableMethods()")
public void serviceMethodsWithoutLogging() {}
@Pointcut("execution(* com.app..*(..)) && @annotation(Transactional)")
public void transactionalMethods() {}
}
실전 패턴 1: 커스텀 어노테이션 + 실행 시간 측정
// 커스텀 어노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
String value() default ""; // 메트릭 이름
String[] tags() default {}; // 추가 태그
boolean histogram() default false;
}
// Aspect 구현
@Aspect
@Component
@RequiredArgsConstructor
public class TimedAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(timed)")
public Object measureTime(ProceedingJoinPoint pjp, Timed timed)
throws Throwable {
String metricName = timed.value().isEmpty()
? pjp.getSignature().toShortString()
: timed.value();
Timer.Sample sample = Timer.start(meterRegistry);
String status = "success";
try {
return pjp.proceed();
} catch (Exception e) {
status = "error";
throw e;
} finally {
sample.stop(Timer.builder(metricName)
.tag("class", pjp.getTarget().getClass().getSimpleName())
.tag("method", pjp.getSignature().getName())
.tag("status", status)
.register(meterRegistry));
}
}
}
// 사용
@Service
public class OrderService {
@Timed("order.create")
public Order createOrder(CreateOrderDto dto) {
// 비즈니스 로직 — AOP가 자동으로 실행 시간 측정
return orderRepository.save(toEntity(dto));
}
}
실전 패턴 2: 자동 재시도 Aspect
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
int maxAttempts() default 3;
long backoffMs() default 1000;
double multiplier() default 2.0;
Class<? extends Throwable>[] retryOn()
default { RuntimeException.class };
}
@Aspect
@Component
@Slf4j
public class RetryAspect {
@Around("@annotation(retryable)")
public Object retry(ProceedingJoinPoint pjp, Retryable retryable)
throws Throwable {
int maxAttempts = retryable.maxAttempts();
long backoff = retryable.backoffMs();
Throwable lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return pjp.proceed();
} catch (Throwable e) {
lastException = e;
// 재시도 대상 예외인지 확인
boolean shouldRetry = Arrays.stream(retryable.retryOn())
.anyMatch(cls -> cls.isAssignableFrom(e.getClass()));
if (!shouldRetry || attempt == maxAttempts) {
throw e;
}
log.warn("재시도 {}/{}: {}.{}() — {}",
attempt, maxAttempts,
pjp.getTarget().getClass().getSimpleName(),
pjp.getSignature().getName(),
e.getMessage());
Thread.sleep(backoff);
backoff = (long) (backoff * retryable.multiplier());
}
}
throw lastException;
}
}
// 사용: DB 데드락, 외부 API 타임아웃 등에 적용
@Retryable(maxAttempts = 3, backoffMs = 500,
retryOn = { DeadlockLoserDataAccessException.class })
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
// 데드락 발생 시 자동 재시도
}
실전 패턴 3: 감사 로그 Aspect
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String action(); // "CREATE", "UPDATE", "DELETE"
String resource(); // "ORDER", "USER"
}
@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {
private final AuditLogRepository auditLogRepository;
private final SecurityContextHolder securityContext;
@AfterReturning(
pointcut = "@annotation(auditable)",
returning = "result")
public void audit(JoinPoint jp, Auditable auditable, Object result) {
String userId = securityContext.getCurrentUserId();
Object[] args = jp.getArgs();
AuditLog log = AuditLog.builder()
.userId(userId)
.action(auditable.action())
.resource(auditable.resource())
.resourceId(extractId(result))
.params(serializeArgs(args))
.timestamp(Instant.now())
.build();
auditLogRepository.save(log);
}
private String extractId(Object result) {
if (result instanceof BaseEntity entity) {
return entity.getId().toString();
}
return "unknown";
}
}
// 사용
@Auditable(action = "CREATE", resource = "ORDER")
public Order createOrder(CreateOrderDto dto) {
return orderRepository.save(toEntity(dto));
}
Self-Invocation 문제와 해결
Spring AOP는 프록시 기반으로 동작하므로, 같은 클래스 내에서 메서드를 직접 호출하면 프록시를 거치지 않아 AOP가 적용되지 않는다. 이것이 가장 흔한 AOP 함정이다.
@Service
public class OrderService {
@Timed("order.create")
public Order createOrder(CreateOrderDto dto) {
// ...
this.sendNotification(order); // ❌ AOP 미적용! (self-invocation)
return order;
}
@Async
public void sendNotification(Order order) {
// @Async가 동작하지 않음 — 프록시를 거치지 않았기 때문
}
}
// ✅ 해결법 1: 별도 서비스로 분리
@Service
@RequiredArgsConstructor
public class OrderService {
private final NotificationService notificationService;
@Timed("order.create")
public Order createOrder(CreateOrderDto dto) {
Order order = orderRepository.save(toEntity(dto));
notificationService.send(order); // ✅ 프록시 경유
return order;
}
}
// ✅ 해결법 2: self-injection (덜 권장)
@Service
public class OrderService {
@Lazy @Autowired
private OrderService self;
public Order createOrder(CreateOrderDto dto) {
Order order = orderRepository.save(toEntity(dto));
self.sendNotification(order); // ✅ 프록시 경유
return order;
}
}
Spring Cache 추상화 실전 글에서 다룬 @Cacheable도 동일한 self-invocation 제약을 갖는다. AOP 기반 어노테이션은 반드시 외부 호출로 실행해야 한다.
Aspect 실행 순서 제어: @Order
// 숫자가 작을수록 먼저 실행 (외부 → 내부)
@Aspect @Order(1) // 가장 바깥
public class SecurityAspect { ... }
@Aspect @Order(2)
public class RetryAspect { ... }
@Aspect @Order(3) // 가장 안쪽 (메서드에 가장 가까움)
public class LoggingAspect { ... }
// 실행 순서:
// Security.before → Retry.before → Logging.before
// → 메서드 실행 →
// Logging.after → Retry.after → Security.after
CGLIB vs JDK Dynamic Proxy
| 항목 | JDK Dynamic Proxy | CGLIB (Spring Boot 기본) |
|---|---|---|
| 대상 | 인터페이스 기반 | 클래스 기반 (서브클래싱) |
| 인터페이스 필요 | 필수 | 불필요 |
| final 클래스/메서드 | 제한 없음 | 프록시 불가 |
| 성능 | 리플렉션 기반 | 바이트코드 생성 (더 빠름) |
테스트 전략
@SpringBootTest
class RetryAspectTest {
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentGateway paymentGateway;
@Test
void 일시적_실패_시_재시도_후_성공() {
// 첫 두 번 실패, 세 번째 성공
when(paymentGateway.charge(any()))
.thenThrow(new TimeoutException("timeout"))
.thenThrow(new TimeoutException("timeout"))
.thenReturn(PaymentResult.success());
PaymentResult result = paymentService.processPayment(dto);
assertThat(result.isSuccess()).isTrue();
verify(paymentGateway, times(3)).charge(any());
}
@Test
void 최대_재시도_초과_시_예외_전파() {
when(paymentGateway.charge(any()))
.thenThrow(new TimeoutException("timeout"));
assertThatThrownBy(() -> paymentService.processPayment(dto))
.isInstanceOf(TimeoutException.class);
verify(paymentGateway, times(3)).charge(any());
}
}
// AOP 프록시가 적용되었는지 확인
@Test
void AOP_프록시가_적용됨() {
assertThat(AopUtils.isAopProxy(paymentService)).isTrue();
assertThat(AopUtils.isCglibProxy(paymentService)).isTrue();
}
정리: AOP 설계 체크리스트
- 횡단 관심사만 AOP로: 로깅, 재시도, 감사, 메트릭 등. 비즈니스 로직은 AOP에 넣지 않는다
- @Around 신중하게: proceed() 누락 시 원본 메서드가 실행되지 않는다
- Self-invocation 주의: 같은 클래스 내 호출은 프록시를 거치지 않음 → 서비스 분리로 해결
- @Order로 순서 제어: 보안 → 재시도 → 로깅 순서가 일반적
- final 메서드 금지: CGLIB 프록시는 final을 오버라이드할 수 없다
- 커스텀 어노테이션 활용: @annotation Pointcut으로 명시적 적용 → 가독성 향상
AOP는 Resilience4j 서킷브레이커나 @Transactional 등 Spring 생태계 전반의 기반 기술이다. 프록시 동작 원리를 이해하면 Spring의 “마법”이 더 이상 마법이 아니게 된다.