이전 편에서 AOP의 개념과 핵심 용어를 정리했다. 이번 편에서는 실제로 Advice와 Pointcut을 어떻게 작성하고 조합하는지 알아본다.
5가지 Advice 타입
Spring AOP는 메서드 실행 흐름의 어느 시점에 개입하느냐에 따라 5가지 Advice를 제공한다.
@Before
Target 메서드가 실행되기 전에 동작한다. 파라미터 검증이나 로깅에 주로 쓴다.
@Before("execution(* com.example.service.*.*(..))")
public void logBefore(JoinPoint joinPoint) {
log.info("[호출] {}", joinPoint.getSignature().toShortString());
}
@Before는 메서드 실행을 막을 수 없다. 예외를 던지면 메서드 실행이 중단되지만, 정상 흐름에서는 Advice 실행 후 반드시 Target 메서드가 호출된다.
@AfterReturning
Target 메서드가 정상적으로 반환된 후에 동작한다. 반환값을 확인하거나 후처리할 때 쓴다.
@AfterReturning(pointcut = "execution(* com.example.service.*.*(..))", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
log.info("[반환] {} → {}", joinPoint.getSignature().toShortString(), result);
}
returning 속성에 지정한 이름과 파라미터 이름이 일치해야 한다. 반환값의 타입이 맞지 않으면 해당 Advice는 실행되지 않는다. 예를 들어 파라미터를 String result로 선언하면 반환 타입이 String인 메서드에만 적용된다.
@AfterThrowing
Target 메서드가 예외를 던졌을 때 동작한다. 예외 로깅이나 알림에 활용한다.
@AfterThrowing(pointcut = "execution(* com.example.service.*.*(..))", throwing = "ex")
public void logException(JoinPoint joinPoint, Exception ex) {
log.error("[예외] {} - {}", joinPoint.getSignature().toShortString(), ex.getMessage());
}
@AfterThrowing은 예외를 잡아서 삼키는 게 아니다. Advice 실행 후 예외는 그대로 전파된다. 예외를 처리하고 싶다면 @Around를 써야 한다.
@After
Target 메서드의 실행 결과와 관계없이 항상 동작한다. 정상 반환이든 예외든 무조건 실행된다. Java의 finally 블록과 같은 개념이다.
@After("execution(* com.example.service.*.*(..))")
public void logAfter(JoinPoint joinPoint) {
log.info("[완료] {}", joinPoint.getSignature().toShortString());
}
리소스 정리처럼 반드시 실행되어야 하는 로직에 적합하다.
@Around
Target 메서드의 실행 전후 모두에 개입할 수 있는 가장 강력한 Advice다. 메서드 호출 자체를 제어할 수 있어서, 실행 여부를 결정하거나 반환값을 변경하는 것도 가능하다.
@Around("execution(* com.example.service.*.*(..))")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - start;
log.info("[실행 시간] {} - {}ms", joinPoint.getSignature().toShortString(), elapsed);
return result;
}
@Around만 ProceedingJoinPoint를 파라미터로 받는다. 나머지 Advice는 JoinPoint를 받는다. proceed()를 호출해야 원본 메서드가 실행되고, 호출하지 않으면 원본 메서드가 아예 실행되지 않는다.
@Around는 강력한 만큼 실수 여지도 크다. proceed() 호출을 빠뜨리거나, 반환값을 return하지 않거나, 예외 처리를 잘못하면 원본 메서드의 동작이 완전히 달라진다. 단순 로깅 목적이라면 @Before나 @AfterReturning이 더 안전하다.
Advice 실행 순서
여러 Advice가 같은 JoinPoint에 적용될 때, 실행 순서는 다음과 같다.
@Around (proceed 이전)
→ @Before
→ Target 메서드 실행
→ @AfterReturning (정상) 또는 @AfterThrowing (예외)
→ @After
@Around (proceed 이후)
이 순서를 감싸는 구조로 그려보면, @Around가 전체를 껍질처럼 감싸고 있다는 것이 보인다.
@Around의 바깥 상자가 proceed() 전후를 모두 감싸고, 그 안쪽에서 @Before → Target → @AfterReturning/@AfterThrowing → @After 순으로 흐른다. @Around가 가장 강력한 이유가 이 구조에서 드러난다. 나머지 4개 Advice가 하는 일을 혼자서 다 할 수 있기 때문이다.
Pointcut 표현식
Pointcut은 "어떤 메서드에 Advice를 적용할 것인가"를 정의하는 필터다. Spring AOP에서 가장 많이 쓰는 표현식들을 알아보자.
execution
메서드 실행을 매칭하는 가장 기본적인 Pointcut이다. 문법은 다음과 같다.
execution(접근제어자? 반환타입 패키지.클래스.메서드(파라미터))
?가 붙은 항목은 생략 가능하다. 각 자리가 무엇을 가리키는지 분해하면 이렇다.
각 와일드카드(*)의 위치가 곧 필터링 대상을 결정한다. 몇 가지 예시를 보자.
// com.example.service 패키지의 모든 클래스, 모든 메서드
execution(* com.example.service.*.*(..))
// 반환 타입이 void인 메서드만
execution(void com.example.service.*.*(..))
// 메서드 이름이 find로 시작하는 메서드
execution(* com.example.service.*.find*(..))
// 파라미터가 String 하나인 메서드
execution(* com.example.service.*.*(String))
// 첫 번째 파라미터가 Long이고, 나머지는 상관없는 메서드
execution(* com.example.service.*.*(Long, ..))
// 하위 패키지까지 포함
execution(* com.example..*.*(..))
..의 의미가 위치에 따라 다르다. 패키지 경로에서는 "하위 패키지 포함", 파라미터 목록에서는 "0개 이상의 아무 파라미터"를 의미한다.
@annotation
특정 어노테이션이 붙은 메서드를 매칭한다. 커스텀 어노테이션과 함께 쓰면 강력하다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {
}
@Around("@annotation(com.example.annotation.LogExecutionTime)")
public Object logTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
log.info("[{}] {}ms", joinPoint.getSignature().toShortString(),
System.currentTimeMillis() - start);
return result;
}
이제 시간 측정이 필요한 메서드에만 @LogExecutionTime을 붙이면 된다.
@LogExecutionTime
public List<Product> findPopularProducts() {
// ...
}
execution은 패키지나 클래스 단위로 광범위하게 적용할 때, @annotation은 개별 메서드를 선택적으로 지정할 때 쓴다.
4가지 Pointcut 지시자가 각각 어느 수준에서 필터링하는지 비교하면 선택이 쉬워진다.
위에서 아래로 갈수록 필터링이 세밀해진다. bean과 within은 대상 범위를 넓게 잡고, execution은 메서드 단위로, @annotation은 개별 메서드를 콕 집어서 선택한다.
within
특정 타입(클래스) 내의 모든 메서드를 매칭한다.
// OrderService의 모든 메서드
within(com.example.service.OrderService)
// service 패키지의 모든 클래스
within(com.example.service.*)
// service 패키지와 하위 패키지의 모든 클래스
within(com.example.service..*)
execution과 비슷해 보이지만, within은 메서드 시그니처를 신경 쓰지 않고 클래스 단위로만 필터링한다.
bean
스프링 빈 이름으로 매칭한다. Spring AOP에서만 사용 가능한 Pointcut이다.
// 빈 이름이 orderService인 빈의 모든 메서드
bean(orderService)
// 빈 이름이 Service로 끝나는 모든 빈
bean(*Service)
Pointcut 조합
논리 연산자로 Pointcut을 조합할 수 있다.
// AND - 두 조건을 모두 만족
@Before("execution(* com.example.service.*.*(..)) && @annotation(LogExecutionTime)")
// OR - 둘 중 하나만 만족
@Before("execution(* com.example.service.*.*(..)) || execution(* com.example.api.*.*(..))")
// NOT - 조건을 만족하지 않는 것
@Before("execution(* com.example.service.*.*(..)) && !execution(* com.example.service.*.get*(..))")
마지막 예시는 "service 패키지의 모든 메서드 중에서 get으로 시작하는 메서드는 제외"라는 뜻이다.
Pointcut 분리와 재사용
Advice마다 Pointcut 표현식을 직접 쓰면 중복이 생기고, 변경할 때 여러 군데를 고쳐야 한다. @Pointcut으로 별도 메서드에 분리하면 재사용할 수 있다.
@Aspect
@Component
public class LoggingAspect {
@Pointcut("execution(* com.example.service.*.*(..))")
private void serviceLayer() {}
@Pointcut("execution(* com.example.api.*.*(..))")
private void apiLayer() {}
@Before("serviceLayer()")
public void logServiceCall(JoinPoint joinPoint) {
log.info("[Service] {}", joinPoint.getSignature().toShortString());
}
@Before("apiLayer()")
public void logApiCall(JoinPoint joinPoint) {
log.info("[API] {}", joinPoint.getSignature().toShortString());
}
@AfterThrowing(pointcut = "serviceLayer() || apiLayer()", throwing = "ex")
public void logError(JoinPoint joinPoint, Exception ex) {
log.error("[에러] {} - {}", joinPoint.getSignature().toShortString(), ex.getMessage());
}
}
@Pointcut 메서드의 본문은 비어 있다. 메서드 이름이 Pointcut의 식별자 역할을 하는 것이다. 다른 Aspect에서도 public으로 선언하면 참조할 수 있다.
실전 활용 예시
커스텀 어노테이션 기반 로깅
가장 실용적인 패턴이다. 어노테이션을 만들고, Aspect에서 해당 어노테이션이 붙은 메서드를 가로챈다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
String value() default "";
}
@Aspect
@Component
public class LoggingAspect {
private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);
@Around("@annotation(loggable)")
public Object logMethod(ProceedingJoinPoint joinPoint, Loggable loggable) throws Throwable {
String label = loggable.value().isEmpty()
? joinPoint.getSignature().toShortString()
: loggable.value();
log.info("[시작] {}", label);
Object result = joinPoint.proceed();
log.info("[완료] {}", label);
return result;
}
}
@annotation(loggable)에서 소문자 loggable은 Advice 메서드의 파라미터 이름과 매칭된다. 이렇게 하면 어노테이션 인스턴스를 직접 받아서 value() 같은 속성에 접근할 수 있다.
@Loggable("인기 상품 조회")
public List<Product> findPopularProducts() {
// ...
}
메서드 실행 시간 측정과 슬로우 쿼리 감지
@Aspect
@Component
public class PerformanceAspect {
private static final Logger log = LoggerFactory.getLogger(PerformanceAspect.class);
private static final long SLOW_THRESHOLD_MS = 1000;
@Around("execution(* com.example.service.*.*(..))")
public Object detectSlowMethod(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - start;
if (elapsed > SLOW_THRESHOLD_MS) {
log.warn("[SLOW] {} - {}ms", joinPoint.getSignature().toShortString(), elapsed);
}
return result;
}
}
1초 이상 걸리는 메서드만 경고 로그를 남긴다. 운영 환경에서 성능 병목을 찾는 데 유용하다.
자주 하는 실수
Pointcut 표현식에 오타가 있으면 애플리케이션 시작 시점에 에러가 발생한다. 패키지 경로나 메서드 패턴을 정확히 확인하자.
```java
// 잘못된 예 - 패키지 끝에 .이 하나 더
execution( com.example.service...*(..)) // 하위 패키지 포함 (정상)
execution( com.example.service....*(..)) // 문법 에러
```
[!WARNING] 너무 넓은 Pointcut
execution(* *..*.*(..))처럼 모든 메서드에 Advice를 적용하면, 프레임워크 내부 메서드까지 잡혀서 예상치 못한 동작이나 성능 저하가 생긴다. Pointcut은 필요한 범위만 정확히 지정하자.
[!DANGER] @Around에서 proceed() 결과 미반환
1편에서도 언급했지만, @Around에서 proceed()의 결과를 return하지 않는 실수가 반복적으로 발생한다. 반환 타입을 void로 선언하지 않도록 주의하자.