Spring AOP로 감사 로그를 남기려고 커스텀 애노테이션을 만들었습니다.
서비스의 계약을 인터페이스에 모아 두고 있어서 애노테이션도 자연스럽게 인터페이스 메서드에 붙였습니다. 애플리케이션은 문제없이 실행됐습니다. 그런데 정작 Advice는 한 번도 호출되지 않았습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audit {
String value() default "";
}
런타임에 AOP가 애노테이션을 읽어야 하므로 @Retention(RetentionPolicy.RUNTIME)도 필요합니다.
public interface OrderService {
@Audit
void order();
}
@Service
public class OrderServiceImpl implements OrderService {
@Override
public void order() {
// 주문 처리
}
}
Aspect는 특별할 것 없는 @annotation 포인트컷이었습니다.
@Aspect
@Component
public class AuditAspect {
@Around("@annotation(audit)")
public Object audit(
ProceedingJoinPoint joinPoint,
Audit audit
) throws Throwable {
// 감사 로그 기록
return joinPoint.proceed();
}
}
코드만 보면 OrderService.order()에 @Audit이 있으니 당연히 잡힐 것 같습니다. 하지만 Spring AOP에서 커스텀 @annotation 포인트컷을 쓸 때는 이 Advice가 실행되지 않는 것이 일반적입니다.
왜 그런지 하나씩 살펴보겠습니다.
@annotation이 실제로 확인하는 것
Spring 공식 문서는 @annotation을 “실행되는 메서드에 해당 애노테이션이 있는 조인 포인트”를 고르는 표현으로 설명합니다.
여기서 중요한 말은 실행되는 메서드입니다.
호출자는 OrderService 타입으로 메서드를 호출하더라도 실제 비즈니스 코드는 OrderServiceImpl.order()에서 실행됩니다. 그런데 이 구현 메서드에는 @Audit이 없습니다.
OrderService.order() -> @Audit 있음
OrderServiceImpl.order() -> @Audit 없음
Java에서 오버라이드한 메서드는 부모나 인터페이스 메서드에 붙은 애노테이션을 자동으로 복사하지 않습니다. 메서드 이름과 매개변수가 같아도 리플렉션 관점에서는 서로 다른 Method입니다.
실제 흐름을 그리면 이렇습니다.
인터페이스에 애노테이션이 없는 것은 아닙니다. Spring AOP가 @annotation을 매칭하는 순간에 그 애노테이션을 인터페이스 계층까지 거슬러 올라가 찾아주지 않는다는 것이 문제입니다.
Spring Framework에는 이 동작을 개선해 달라는 이슈 #22311이 2019년에 등록됐습니다. 2026년 9월 현재도 열린 상태이며 7.x 마일스톤에 들어 있습니다.
JDK 프록시로 바꾸면 해결될까?
Spring AOP는 대상이 인터페이스를 구현하면 JDK 동적 프록시를 사용할 수 있고, 클래스 기반 프록시가 필요하면 CGLIB를 사용합니다. 프록시 방식이 다르니 JDK 프록시에서는 인터페이스 애노테이션도 잡힐 것 같습니다. 하지만 이 문제를 프록시 종류만 바꿔 해결하려 해서는 안 됩니다.
| 구분 | 프록시가 노출하는 형태 |
@annotation에서 기대할 결과 |
|---|---|---|
| JDK 동적 프록시 | 인터페이스 기반 | 인터페이스 메서드의 애노테이션이 구현 메서드로 상속되지는 않음 |
| CGLIB 프록시 | 구현 클래스를 상속한 프록시 | 구현 메서드에 애노테이션이 없으면 매칭되지 않음 |
프록시는 호출을 가로채는 방법을 정할 뿐 Java의 메서드 애노테이션 상속 규칙을 바꾸지 않습니다. spring.aop.proxy-target-class 값을 바꾸는 것은 이 문제의 확실한 해결책이 아닙니다.
@Inherited도 답은 아닙니다
커스텀 애노테이션에 @Inherited를 추가하면 되지 않을까 생각할 수 있습니다.
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audit {
}
하지만 @Inherited는 클래스 선언에 붙은 애노테이션을 하위 클래스에서 조회할 때만 영향을 줍니다. 메서드에 붙은 애노테이션에는 적용되지 않으며 구현한 인터페이스에 붙은 애노테이션을 클래스가 물려받게 만들지도 않습니다.
위 코드는 문제를 해결하지 못합니다.
가장 단순하고 안전한 방법
애노테이션의 위치를 바꿀 수 있다면 구현체 메서드에 붙이는 것이 가장 안전합니다.
@Service
public class OrderServiceImpl implements OrderService {
@Override
@Audit
public void order() {
// 주문 처리
}
}
이제 실행 대상인 OrderServiceImpl.order()에 @Audit이 있으므로 기존 포인트컷이 그대로 동작합니다.
@Around("@annotation(audit)")
public Object audit(
ProceedingJoinPoint joinPoint,
Audit audit
) throws Throwable {
// audit의 속성도 바로 사용할 수 있다.
return joinPoint.proceed();
}
중복처럼 보일 수 있지만 AOP 적용 여부가 코드에 분명하게 드러나고 프록시 방식이나 호출 타입에 덜 민감합니다. 구현체를 직접 관리할 수 있는 프로젝트라면 이 방식을 먼저 선택하는 편이 좋습니다.
애노테이션을 인터페이스에 유지해야 한다면
인터페이스가 외부 모듈에 공개된 계약이거나 구현체가 여러 개라서 정책을 인터페이스에 두어야 할 수도 있습니다.
이때는 @annotation만으로 Advice 진입 여부를 결정하지 않습니다. 먼저 execution으로 대상 범위를 잡은 뒤 Advice 안에서 인터페이스 계층까지 애노테이션을 조회합니다.
import java.lang.reflect.Method;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class AuditAspect {
@Around("execution(* com.example.order.OrderService.*(..))")
public Object audit(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature =
(MethodSignature) joinPoint.getSignature();
Method invokedMethod = signature.getMethod();
Class<?> targetClass = joinPoint.getTarget().getClass();
Method targetMethod = AopUtils.getMostSpecificMethod(
invokedMethod,
targetClass
);
Audit audit = AnnotationUtils.findAnnotation(
targetMethod,
Audit.class
);
if (audit == null) {
return joinPoint.proceed();
}
// 감사 로그 기록
return joinPoint.proceed();
}
}
코드에서 가장 신경 써야 할 부분은 메서드를 찾는 순서입니다.
signature.getMethod()는 호출 지점에서 보이는 메서드를 가져옵니다. JDK 프록시와 CGLIB 프록시에서는 이 값이 다르게 보일 수 있습니다.
AopUtils.getMostSpecificMethod(...)는 실제 대상 클래스에서 실행될 가장 구체적인 메서드를 찾습니다.
AnnotationUtils.findAnnotation(...)은 해당 메서드에 애노테이션이 없으면 상위 클래스와 인터페이스의 같은 메서드까지 탐색합니다. 단순한 method.getAnnotation(Audit.class)보다 이 경우에 잘 맞습니다. 제네릭 때문에 생긴 bridge method도 함께 처리합니다.
@Audit이 다른 애노테이션의 meta-annotation으로 쓰이거나 @AliasFor를 이용한 조합 애노테이션까지 지원해야 한다면 AnnotatedElementUtils.findMergedAnnotation(...)도 검토할 수 있습니다.
Audit audit = AnnotatedElementUtils.findMergedAnnotation(
targetMethod,
Audit.class
);
Spring이 제공하는 조회 API를 사용하면 직접 targetClass.getInterfaces()를 순회하는 코드보다 상속 계층, bridge method, meta-annotation을 빠뜨릴 가능성이 작습니다.
포인트컷 범위는 꼭 좁혀야 합니다
수동 조회 방식은 Advice에 들어온 뒤 애노테이션 유무를 판단합니다. 이 때문에 모든 Bean의 모든 메서드를 대상으로 아래처럼 잡는 것은 피하는 편이 좋습니다.
@Around("execution(* *(..))")
패키지, 인터페이스, Bean 이름처럼 애노테이션이 존재할 만한 범위로 먼저 좁혀야 합니다.
@Around("execution(* com.example..service.*.*(..))")
또는 서비스 인터페이스의 위치가 일정하다면 다음처럼 계약 자체를 기준으로 잡을 수 있습니다.
@Around("execution(* com.example..service.*Service.*(..))")
Spring 공식 문서도 execution을 Spring AOP에서 사용하는 주된 포인트컷 지정자로 설명합니다. 가능하면 타입이나 패키지 범위까지 함께 제한할 것도 권합니다.
같은 메서드를 여러 인터페이스가 선언한다면
조금 드문 경우지만 구현 클래스가 같은 시그니처의 메서드를 가진 인터페이스 여러 개를 구현할 수 있습니다.
public interface OrderService {
@Audit("ORDER")
void execute();
}
public interface PaymentService {
@Audit("PAYMENT")
void execute();
}
이 상태에서 한 구현체가 두 인터페이스를 모두 구현하면 어느 @Audit을 우선할지 애매해집니다. 계층 탐색 API는 첫 번째로 발견한 애노테이션을 반환하므로 발견 순서에 업무 의미를 맡기면 안 됩니다.
이런 구조라면 구현 메서드에 최종 정책을 명시하거나 인터페이스마다 메서드 이름을 구분해 충돌을 없애는 편이 낫습니다.
AOP가 동작하지 않을 때 같이 볼 것
애노테이션 위치를 고쳤는데도 Advice가 호출되지 않는다면 다음 조건을 확인해야 합니다.
- 커스텀 애노테이션에
@Retention(RetentionPolicy.RUNTIME)이 선언돼 있는지 - 구현체가
@Service,@Component,@Bean등으로 등록된 Spring Bean인지 - 객체를
new로 직접 만들지 않고 Spring이 주입한 프록시를 통해 호출하는지 - 같은 클래스 안에서
this.order()처럼 내부 호출하고 있지 않은지 - JDK 프록시를 쓴다면 호출 메서드가 인터페이스에 공개돼 있는지
- CGLIB 프록시를 쓴다면 메서드나 클래스가
final또는private이 아닌지
Spring AOP는 프록시 기반입니다. 외부 호출은 프록시를 지나가지만 같은 객체 안의 this 호출은 프록시를 거치지 않습니다. 그래서 self-invocation은 애노테이션 위치가 올바르더라도 Advice가 실행되지 않습니다.
이 글에서 다룬 내용은 커스텀 애노테이션과 AspectJ 표현식의 @annotation 조합을 기준으로 합니다. @Transactional, @Async, @Cacheable처럼 Spring이 별도의 인프라와 탐색 규칙을 제공하는 애노테이션의 동작을 그대로 일반화해서는 안 됩니다.
테스트로 동작을 고정해 두기
AOP는 애플리케이션이 정상적으로 실행되면서 Advice만 빠지는 경우가 많습니다. 설정이나 프록시 방식이 달라져도 놓치지 않도록 호출 횟수를 테스트해 두는 것이 좋습니다.
@SpringBootTest
class AuditAspectTest {
@Autowired
private OrderService orderService;
@Autowired
private AuditRecorder auditRecorder;
@Test
void interfaceMethodAnnotationIsDetected() {
orderService.order();
assertThat(auditRecorder.count()).isEqualTo(1);
}
}
테스트에서는 구현체를 직접 생성하지 말고 Spring Context가 주입한 Bean을 호출해야 실제 프록시 동작까지 확인할 수 있습니다.
정리
인터페이스 메서드에만 붙인 애노테이션은 구현 메서드로 상속되지 않습니다. 그래서 커스텀 @annotation 포인트컷은 구현체 실행 시 매칭되지 않을 수 있습니다.
구현체를 수정할 수 있다면 실행되는 구현 메서드에 애노테이션을 붙이는 방법이 가장 단순합니다.
애노테이션을 인터페이스에 유지해야 한다면 execution으로 Advice 적용 범위를 정합니다. 그런 다음 AopUtils.getMostSpecificMethod(...)와 AnnotationUtils.findAnnotation(...)으로 메서드 계층을 직접 조회합니다.
그리고 프록시 종류를 바꾸기 전에 세 가지를 먼저 확인하면 됩니다.
어느 Method가 실제로 실행되는가?
애노테이션은 어느 Method에 선언돼 있는가?
호출이 Spring Proxy를 지나가는가?
이 세 가지가 보이면 “애노테이션은 있는데 왜 AOP가 안 잡히지?”라는 문제도 훨씬 빠르게 찾을 수 있습니다.