CS/Spring

Spring AOP 심화: AspectJ와의 경계, Pointcut 표현식, Advice 순서

dding-shark 2026. 4. 12. 10:13
728x90

Spring AOP 심화: AspectJ와의 경계, Pointcut 표현식, Advice 순서


들어가며

1편에서 "프록시 기반 AOP는 메서드만 가로챈다"는 한 줄을 던지고 넘어간 적이 있다. 그 한 줄이 7편에서는 열 개의 함정으로 되돌아온다. @Around를 써놓고 메서드가 실행되지 않는다고 당황하는 날, execution(* com.svc.*.find(..))가 왜 com.svc.user.UserService.find를 잡지 못하는지 한참 헤매는 날, 같은 클래스에서 호출한 @Transactional이 왜 무음으로 풀리는지 검색창을 열게 되는 날 — 전부 이 프록시 모델의 그림자다.

Spring AOP는 AOP의 완전한 구현이 아니라, Spring IoC와 통합된 런타임 프록시 엔진이다. 그리고 @AspectJ는 AspectJ의 어노테이션 문법만 차용한 DSL일 뿐, 내부 엔진은 여전히 Spring의 프록시다. 이 경계가 흐릿해지면 Pointcut 표현식을 아무리 정확히 짜도 어드바이스가 걸리지 않고, 걸려도 순서가 꼬이고, 꼬인 순서를 @Order로 풀려다 한 번 더 틀어진다.

이 글은 1편의 "왜 AOP가 필요한가"에서 출발해, **"어떻게 깊이 쓰는가"**까지 내려간다. 실제로 자주 밟는 함정은 다음 다섯 개다.

  • @Around를 썼는데 메서드 자체가 실행되지 않는다proceed() 누락
  • 같은 클래스 안에서 호출한 어드바이스가 미적용된다 — self-invocation
  • execution(* com.svc.*.find(..))는 하위 패키지를 잡지 못한다*..의 차이
  • @Aspect만 붙여서 빈으로 등록되지 않는다@Component가 빠졌다
  • @Order를 메서드에 붙여도 무효다 — 클래스 레벨만 유효

이 글은 경계 → 선언 → Advice → API → Pointcut → 합성 → self-invocation → 순서 → 실무 순으로, Spring Framework 6.x / Java 17+ 기준으로 정리한다.


목차


1) Spring AOP vs AspectJ: 이름은 같지만 엔진은 다르다

@Aspect, @Pointcut, @Around — 이 어노테이션들은 전부 AspectJ 프로젝트에서 왔다. 그런데 스프링 애플리케이션에서 이들을 쓴다고 해서 AspectJ 컴파일러가 돌아가는 건 아니다. Spring AOP는 AspectJ의 어노테이션 문법만 차용하고, 내부 엔진은 런타임 프록시다. 바로 이 경계에서 가장 많은 오해가 쌓인다.

Spring Reference는 목표 자체를 선을 그어 명시한다.

"Spring AOP currently supports only method execution join points (advising the execution of methods on Spring beans). (...) The aim is not to provide the most complete AOP implementation (...) Rather, the aim is to provide a close integration between AOP implementation and Spring IoC, to help solve common problems in enterprise applications."

가장 완벽한 AOP를 제공하려는 게 아니다. Spring IoC와의 긴밀한 통합이 목표라는 뜻이다. 이 문장을 뒤집으면, 스프링이 못 하는 걸 하고 싶으면 AspectJ를 직접 써야 한다는 말이 된다.

1-1) 비교 표: 무엇이 다르고, 무엇이 같은가

항목 Spring AOP AspectJ
Join Point 범위 메서드 실행만 메서드/필드/생성자/static/초기화 전부
Weaving 시점 런타임 (프록시 생성) 컴파일 타임 / 포스트 컴파일 / 로드 타임
대상 객체 Spring 컨테이너에 등록된 빈만 모든 자바 객체
성능 프록시 호출 오버헤드 네이티브 바이트코드 수준
설정 @EnableAspectJAutoProxy 또는 스프링 부트 자동 aspectjrt + 컴파일러/에이전트
어노테이션 문법 @AspectJ 차용 원조

주의할 지점은 두 가지다. 첫째, "@AspectJ 어노테이션을 쓴다"와 "AspectJ를 쓴다"는 전혀 다른 이야기다. Spring AOP에서 @Aspect를 선언해도 여전히 Spring의 프록시로 돌아간다. 둘째, Spring AOP는 빈이 아닌 객체에는 작동하지 않는다. new로 만든 객체는 컨테이너 바깥이므로 프록시가 감싸지지 않는다 — 1편의 결론이 여기서 다시 등장한다.

1-2) 언제 AspectJ로 내려가야 하는가

다음 중 하나라도 해당하면 Spring AOP로는 부족하다.

  • 필드 접근을 가로채야 할 때 (예: 불변성 검증 에스펙트)
  • 생성자 호출 자체에 어드바이스가 필요할 때
  • final 클래스/메서드에 어드바이스를 걸어야 할 때 (CGLIB 서브클래싱 불가)
  • static 메서드, 초기화 블록을 가로채야 할 때
  • 프록시 오버헤드가 성능상 문제가 되는 고빈도 경로

그 외 대부분의 실무 요구 — 트랜잭션, 로깅, 권한 체크, 캐싱, 성능 측정 — 는 Spring AOP로 충분하다. **"쓸 수 있는 가장 약한 도구를 쓰라(use the least powerful form)"**는 Spring Reference의 원칙은 여기서도 유효하다.


2) Aspect 선언: @Aspect + @Component 둘 다 필요

@Aspect는 이 클래스가 어드바이스와 Pointcut의 묶음이라는 사실을 선언한다. 그런데 이게 전부가 아니다. Spring AOP는 컨테이너에 등록된 빈에만 프록시를 감싼다. 즉, @Aspect 클래스 자체도 빈이어야 Spring이 발견해서 오토프록시 프로세서에 건넬 수 있다. 이 한 글자 차이가 함정 4다.

2-1) 잘못된 선언과 올바른 선언

// 피하기: @Aspect만 붙이면 빈 등록이 안 된다
@Aspect
public class LoggingAspect {

    @Before("execution(* com.svc..*(..))")
    public void log(JoinPoint jp) {
        log.info("call: {}", jp.getSignature());
    }
}

이 코드는 컴파일도 되고, 예외도 없고, 로그만 안 찍힌다. 가장 찾기 힘든 종류의 실패다. 이유는 간단하다. @Aspect는 AspectJ의 어노테이션일 뿐 Spring의 컴포넌트 스캔 대상이 아니다. 스프링은 이 클래스를 빈으로 등록하지 않고, 등록되지 않은 클래스의 어드바이스는 프록시 체인에 꿰이지 않는다.

// 선호: @Component로 빈 등록까지 함께
@Aspect
@Component
public class LoggingAspect {

    @Before("execution(* com.svc..*(..))")
    public void log(JoinPoint jp) {
        log.info("call: {}", jp.getSignature());
    }
}

@Component가 붙어 있어야 스프링이 스캔해 빈으로 등록하고, 등록된 빈 중 @Aspect가 있는 것을 오토프록시 프로세서가 찾아서 어드바이스를 연결한다. @Service, @Repository처럼 스테레오타입이 달라도 되고, @Configuration + @Bean으로 수동 등록해도 된다. 핵심은 컨테이너가 이 클래스를 알고 있어야 한다는 것이다.

2-2) @EnableAspectJAutoProxy와 스프링 부트

Spring Framework 표준 설정에서는 @Configuration 클래스에 @EnableAspectJAutoProxy를 붙여 오토프록시 프로세서를 활성화해야 한다. Spring Boot는 spring-boot-starter-aop가 classpath에 있으면 자동으로 활성화하므로, 부트 프로젝트에서는 이 어노테이션을 직접 쓰지 않는다. 단, aspectj-autoproxy를 끄고 싶다면 spring.aop.auto=false로 끌 수 있다.

"@AspectJ support can be enabled with XML- or Java-style configuration. In either case, you must ensure that AspectJ's aspectjweaver.jar library is on the classpath."

AspectJ 어노테이션 파서가 필요하므로 aspectjweaver JAR은 여전히 classpath에 있어야 한다. Spring AOP는 AspectJ의 엔진은 안 쓰지만, 어노테이션 파싱만큼은 AspectJ 라이브러리를 빌려 쓴다. 이름과 엔진이 엇갈리는 지점이 여기에도 있다.


3) Advice 5종: 판단 기준과 무음 실패

Spring AOP가 제공하는 advice는 다섯 가지다. 처음 배울 때는 "@Around가 제일 강력하니까 그걸 쓰면 되지 않나" 싶어지는데, Spring Reference가 정확히 반대되는 조언을 한다.

"use the least powerful form of advice that meets your requirements"

요구사항을 만족하는 가장 약한 형태의 어드바이스를 써야 한다. 이유는 두 가지다. @Around는 실수의 여지가 많고(특히 proceed() 누락), 의도가 코드에서 덜 드러난다. 로그만 찍고 싶다면 @Before로 충분하고, 리턴값을 보고 싶다면 @AfterReturning이 정확하다.

3-1) 5종 요약 표

Advice 시점 리턴값 접근 예외 접근 proceed 필요 대표 용도
@Before 메서드 실행 직전 X X X 인자 로깅, 간단 권한 체크
@AfterReturning 정상 리턴 후 O (returning 바인딩) X X 리턴값 감사
@AfterThrowing 예외 던질 때 X O (throwing 바인딩) X 예외 변환/로깅
@After finally처럼 항상 X X X 리소스 정리
@Around 가장 바깥 래핑 O (proceed 반환) O (try/catch) O 트랜잭션, 성능 측정, 재시도

@Around리턴값을 가로채거나, 인자를 치환하거나, 실행 자체를 건너뛸 수 있는 유일한 advice다. 다른 것들은 전부 "관찰" 수준이다. 그래서 힘이 세고, 그래서 위험하다.

3-2) @Around의 대표 함정: proceed() 누락

함정 1은 이것이다. @Around를 선언했는데 대상 메서드가 실행되지 않는다. 원인은 거의 항상 proceed() 호출을 빠뜨린 것이다.

// 피하기: proceed() 누락 — 메서드 자체가 실행되지 않는다
@Around("execution(* com.svc..*(..))")
public Object measure(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.nanoTime();
    // pjp.proceed() 없음!
    long elapsed = System.nanoTime() - start;
    log.info("elapsed: {} ns", elapsed);
    return null;  // 호출자에겐 null이 돌아간다
}

@Around advice의 몸통은 원본 메서드를 호출할지 말지조차 aspect가 결정한다. proceed()를 부르지 않으면 원본은 실행되지 않고, 호출자는 그 advice가 반환한 값(여기서는 null)을 받는다. 서비스 메서드가 전부 null을 반환하기 시작하는 날의 원인이 이것인 경우가 많다.

// 선호: proceed()로 원본 실행 + 리턴값 되돌려주기
@Around("execution(* com.svc..*(..))")
public Object measure(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.nanoTime();
    try {
        return pjp.proceed();
    } finally {
        long elapsed = System.nanoTime() - start;
        log.info("{} took {} ns", pjp.getSignature(), elapsed);
    }
}

return pjp.proceed()로 원본 메서드의 리턴값을 그대로 흘려보내고, try/finally로 측정을 감싸는 게 안전한 기본형이다. 리턴 타입은 Object로 두고 throws Throwable을 선언한다. proceed()는 체크 예외까지 포함해 무엇이든 던질 수 있기 때문이다.


4) JoinPoint vs ProceedingJoinPoint: API 경계

advice 메서드의 첫 파라미터로 받는 JoinPointProceedingJoinPoint는 이름이 비슷하지만 역할이 다르다. JoinPoint는 읽기 전용, ProceedingJoinPoint는 실행 제어다. 이 구분을 타입으로 강제한다.

4-1) 두 인터페이스의 역할

JoinPoint@Before, @AfterReturning, @AfterThrowing, @After에서 쓰는 조회 전용 API다.

메서드 반환 의미
getArgs() Object[] 메서드 인자 배열
getSignature() Signature 메서드 시그니처 (이름, 반환 타입)
getTarget() Object 프록시 뒤의 실제 대상 객체
getThis() Object 프록시 객체 자체
getKind() String method-execution

ProceedingJoinPointJoinPoint를 상속하며 @Around에서만 사용한다. 추가 메서드는 두 개뿐이지만 결정적이다.

  • proceed() — 원본 메서드를 그대로 실행
  • proceed(Object[] args) — 인자를 치환해서 실행
@Around("execution(* com.svc.UserService.find(String))")
public Object normalizeId(ProceedingJoinPoint pjp) throws Throwable {
    Object[] args = pjp.getArgs();
    if (args[0] instanceof String s) {
        args[0] = s.trim().toLowerCase();
    }
    // 치환한 args로 원본 호출
    return pjp.proceed(args);
}

인자를 정규화하고 원본 메서드에 넘겨주는 전형적인 @Around 패턴이다. proceed()에 아무것도 넘기지 않으면 원래 인자로 실행되고, Object[]를 넘기면 그 배열로 호출된다. 주의할 점은 배열의 요소 순서와 타입이 원본 메서드 시그니처와 정확히 맞아야 한다는 것이다. proceed(args)에서 타입 불일치가 나면 런타임에 ClassCastException이 터진다.

4-2) getThis() vs getTarget(): self-invocation 복선

두 메서드는 이름이 비슷해서 자주 혼동되는데 의미가 다르다.

  • getThis() — 프록시 객체를 돌려준다. 클라이언트가 실제로 호출한 대상
  • getTarget() — 프록시 뒤의 원본 객체를 돌려준다

평소엔 같은 값처럼 느껴지지만, 두 참조는 런타임 타입이 다르다. getThis()는 CGLIB 서브클래스 또는 JDK 동적 프록시 인스턴스이고, getTarget()은 원본 클래스의 인스턴스다. 이 구분은 §7 self-invocation에서 다시 등장한다. "왜 어드바이스가 안 걸리지?" 디버깅의 첫 신호가 getThis() 값을 찍어보는 것이다. 타입이 $$EnhancerBySpringCGLIB로 끝나면 프록시, 아니면 프록시를 우회한 경로다.


5) Pointcut 표현식: execution을 지배하면 절반 끝

Pointcut 표현식(pointcut designator, PCD)은 "어떤 조인 포인트에 advice를 걸 것인가"를 정의하는 문법이다. AspectJ의 PCD는 수십 개지만, Spring AOP가 지원하는 건 10종 남짓이고 실무에서 쓰는 건 그중 절반 정도다.

5-1) Spring AOP PCD 요약

PCD 의미 비고
execution 메서드 실행 매칭 가장 많이 씀
within 특정 타입 내부의 모든 조인 포인트 타입 필터
this 프록시가 주어진 타입인 경우 프록시 타입 기준
target 대상 객체가 주어진 타입인 경우 실제 타입 기준
args 인자가 주어진 타입 시퀀스인 경우 런타임 바인딩 가능
@target 대상 객체 클래스에 어노테이션 있음
@args 인자 클래스에 어노테이션 있음
@within 선언 타입에 어노테이션 있음
@annotation 조인 포인트 자체에 어노테이션 있음 @Transactional
bean 빈 이름 매칭 (*Service) Spring AOP 전용

마지막 bean PCD만 Spring 확장이고, 나머지는 AspectJ 원본이다. bean(userService), bean(*Service) 같은 이름 매칭은 오리지널 AspectJ에는 없다.

5-2) execution 문법 분해

execution은 가장 많이 쓰이고, 가장 많이 틀린다. 문법부터 짚어보자.

execution(modifiers? ret-type declaring-type? name(params) throws?)
  • modifierspublic, private 등. 생략 가능
  • ret-type — 반환 타입. *로 모든 타입
  • declaring-type — 선언 타입(클래스/패키지). 생략 가능
  • name — 메서드 이름 패턴
  • params — 파라미터 리스트. (), (..), (String, ..)
  • throws — 던지는 예외 타입. 거의 안 씀

예를 들어 execution(public String com.svc.UserService.find(String))는 "com.svc.UserServicepublic String find(String) 메서드"를 정확히 매칭한다. 여기서 틀리기 쉬운 지점이 *..의 차이다.

5-3) *..의 차이: 함정 3의 핵심

함정 3은 이것이다. *..는 비슷해 보이지만 길이 의미가 다르다.

  • *한 단위를 대체한다. 패키지 한 세그먼트, 타입 이름 하나, 파라미터 한 개
  • ..0개 이상의 연속된 단위를 대체한다. 패키지 여러 세그먼트, 파라미터 0개 이상

이걸 표로 대조해 보자. 아래 네 쌍은 전부 실제로 헷갈리는 조합이다.

표현식 매칭 비매칭
execution(* com.svc.*.find(..)) com.svc.UserService.find(..) com.svc.user.UserService.find(..) (한 세그먼트 더 있음)
execution(* com.svc..*.find(..)) com.svc.UserService.find(..) + com.svc.user.UserService.find(..) + com.svc.user.admin.UserService.find(..) com.other.UserService.find(..)
execution(* find(*)) find(String), find(Long) (정확히 1개) find(), find(String, Long)
execution(* find(..)) find(), find(String), find(String, Long) 전부

한 줄 요약: "하위 패키지까지 가려면 .., 인자 개수가 가변이면 ..." *는 "하나"다. 첫 번째 행의 비매칭 케이스가 실무에서 가장 많이 밟는 지점이다. com.svc.*com.svc 바로 아래의 직계 타입만 잡고, com.svc.user.UserService는 중간에 user가 한 세그먼트 더 있으므로 탈락한다. 하위 패키지까지 포함하려면 com.svc..*처럼 ..를 써야 한다.

5-4) this vs target: 프록시 기반의 작은 주름

this(T)target(T)JoinPointgetThis()/getTarget()와 같은 경계에서 작동한다. Spring AOP는 프록시 기반이라 대부분 두 결과가 동일하지만, JDK 동적 프록시를 쓸 때 갈라지는 경우가 있다.

  • JDK 동적 프록시 — 프록시는 인터페이스만 구현한다. this(UserService) (인터페이스)는 매칭, this(UserServiceImpl) (구체 클래스)는 비매칭. 반면 target(UserServiceImpl)은 매칭된다.
  • CGLIB 프록시 — 프록시가 대상 클래스를 상속한다. this(UserServiceImpl), target(UserServiceImpl) 둘 다 매칭.

실무에서는 within(...)이나 @annotation(...)으로 충분한 경우가 대부분이고, this/target은 정말 필요한 경우가 아니면 피하는 편이 낫다. 이해 자체가 프록시 모드에 묶여 있어서, 나중에 프록시 모드가 바뀌면 깨진다.


6) Pointcut 재사용과 합성: &&, ||, !, 명명된 Pointcut

Pointcut 표현식을 advice 어노테이션 안에 직접 박아 넣으면 금방 읽기 힘들어진다. @Pointcut으로 이름을 붙여 재사용하는 것이 AspectJ 설계의 기본이다.

6-1) @Pointcut 선언의 관용

@Aspect
@Component
public class ServicePointcuts {

    // 시그니처만 있는 빈 메서드 — 이 메서드는 호출되지 않는다
    @Pointcut("execution(* com.svc..*.*(..))")
    public void serviceLayer() {}

    @Pointcut("execution(* com.svc..*.find*(..)) || execution(* com.svc..*.get*(..))")
    public void readOperation() {}

    @Pointcut("!@annotation(com.svc.NotAudited)")
    public void notAnnotated() {}
}

관용은 세 가지다. 첫째, @Pointcut 메서드의 본문은 비어 있다. 이 메서드는 실제로 호출되는 게 아니라 이름과 시그니처만 AspectJ 파서에 등록된다. 둘째, 반환 타입은 void, 접근 지정자는 관례적으로 public이다. 셋째, 메서드 이름이 곧 Pointcut 이름이 된다.

6-2) 합성: &&, ||, !

이제 이름 붙인 pointcut을 다른 advice에서 조합할 수 있다.

@Aspect
@Component
public class ReadAuditAspect {

    private final ServicePointcuts pc;

    public ReadAuditAspect(ServicePointcuts pc) {
        this.pc = pc;
    }

    // 서비스 계층의 읽기 작업 중, NotAudited가 안 붙은 것
    @AfterReturning(
        pointcut = "com.svc.ServicePointcuts.serviceLayer() "
                 + "&& com.svc.ServicePointcuts.readOperation() "
                 + "&& com.svc.ServicePointcuts.notAnnotated()",
        returning = "result"
    )
    public void audit(JoinPoint jp, Object result) {
        auditLog.record(jp.getSignature().toShortString(), result);
    }
}

같은 aspect 클래스 안에서는 메서드 이름만 쓰면 되지만, **다른 클래스의 pointcut을 참조할 때는 FQN(패키지.클래스.메서드)**을 써야 한다. 합성 연산자는 자바의 논리 연산자와 같다 — &&는 교집합, ||는 합집합, !는 부정이다.

이렇게 분리하면 Pointcut의 의미 단위가 재사용 가능해진다. "서비스 계층" "읽기 작업" "감사 제외"라는 개념을 각각 이름으로 고정해두고, 필요한 곳에서 조립한다. advice 메서드의 pointcut 문자열이 길어지는 건 개념이 여러 개 겹쳐서지, 하나의 표현식이 복잡해서가 아니다.


7) self-invocation: 프록시를 우회하는 조용한 배신

1편에서 @Transactional의 self-invocation 문제를 짧게 언급했다. 7편에서는 왜 프록시가 못 끼어드는지, 그리고 어떻게 풀어야 하는지를 구조로 본다. 1편이 "프록시가 끼어든다"를 그렸다면, 7편은 "프록시가 언제 못 끼어드는가"의 지도다.

7-1) 프록시 호출 경로: JDK vs CGLIB

클라이언트가 빈 메서드를 호출할 때 실제로 흐르는 경로는 이렇다.

07_spring-aop-deep-dive-01

핵심은 단순하다. advice chain은 "프록시를 통해 들어올 때만" 실행된다. 외부 클라이언트가 userService.find(id)를 부르면 참조는 프록시를 가리키고 있으므로 advice가 꿰인다. 그런데 target 내부에서 this.find(id)를 부르면, this원본 객체이지 프록시가 아니다. 프록시를 우회한 호출은 advice를 만나지 않는다.

7-2) 함정 2의 전형: 같은 서비스 내부 호출

// 피하기: 같은 클래스 내부에서 @Transactional 메서드 호출
@Service
public class OrderService {

    public void processBatch(List<Order> orders) {
        for (Order o : orders) {
            this.save(o);  // self-invocation — @Transactional 무효
        }
    }

    @Transactional
    public void save(Order order) {
        repository.save(order);
    }
}

processBatch 안의 this.save(o)는 프록시를 거치지 않는다. 각 주문마다 별도 트랜잭션이 걸리길 기대했는데, 실제로는 save@Transactional이 전혀 동작하지 않는다. 에러는 없고, 행동만 다르다. 최악은 운영 환경에서 데이터 정합성이 깨지고 나서야 원인을 찾게 된다는 점이다.

7-3) 해결 순서: 구조부터, 우회는 최후

해결책은 세 가지이지만 우선순위가 있다.

  1. 빈 분리 (권장) — 책임이 다른 메서드를 다른 빈으로 옮긴다. 배치와 단건 처리는 애초에 관심사가 다르다.
  2. 외부 호출로 리팩터링 — 호출 경로를 바깥으로 끌어올린다. 컨트롤러나 파사드가 반복을 담당한다.
  3. AopContext.currentProxy()최후 수단. @EnableAspectJAutoProxy(exposeProxy = true)로 프록시 노출을 켜야 동작한다.
// 선호: 빈 분리
@Service
public class OrderBatchService {
    private final OrderService orderService;  // 프록시가 주입된다

    public OrderBatchService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void processBatch(List<Order> orders) {
        for (Order o : orders) {
            orderService.save(o);  // 프록시 경유 — @Transactional 정상 동작
        }
    }
}

OrderBatchService가 주입받는 OrderService는 컨테이너가 감싼 프록시 객체다. 그 참조를 통해 save(o)를 부르면 advice chain이 정상적으로 꿰인다. 핵심은 "프록시 참조를 쥔 쪽이 호출해야 한다"는 것이고, 이건 구조로 풀 문제이지 기교로 우회할 문제가 아니다.

AopContext.currentProxy()는 이런 식이다.

// 최후 수단: 프록시를 명시적으로 꺼낸다
public void processBatch(List<Order> orders) {
    OrderService self = (OrderService) AopContext.currentProxy();
    for (Order o : orders) {
        self.save(o);
    }
}

쓰면 동작은 하지만, 강결합이 코드에 드러나고, exposeProxy=true가 필요하고, 테스트 격리가 어려워진다. 리팩터링이 불가능한 레거시에서만 임시로 쓴다.


8) Advice 순서: @Order와 "미정의"의 경계

여러 aspect가 같은 조인 포인트를 잡으면 실행 순서가 의미를 갖기 시작한다. 로깅보다 트랜잭션이 먼저 걸려야 할지, 인증이 로깅보다 먼저 와야 할지 — 이런 요구는 실무에서 금방 나온다. Spring AOP는 @Order(또는 Ordered 인터페이스)로 순서를 제어하지만, 어느 수준에서 어떻게 유효한지가 생각보다 까다롭다.

8-1) @Around 다층 양파: 실행 순서의 직관

07_spring-aop-deep-dive-02

@Order 값이 낮을수록 바깥 래퍼다. 숫자가 낮을수록 우선순위가 높다는 Ordered 인터페이스의 관례 그대로다. 위 시퀀스에서 before는 A → B → C 순으로 쌓이고, 실제 메서드 실행 후의 after는 역순 C → B → A로 풀린다. 양파(onion)를 밖에서 안으로 벗긴 뒤 다시 겉으로 빠져나오는 구조다.

8-2) @Order 메서드 레벨 무효: 함정 5

함정 5는 이것이다. @Order는 aspect 클래스 레벨에서만 유효하고, advice 메서드에 직접 붙여도 무시된다.

// 피하기: @Order를 메서드에 붙여도 무효다
@Aspect
@Component
public class LoggingAspect {

    @Order(1)  // 무효!
    @Before("execution(* com.svc..*(..))")
    public void logBefore(JoinPoint jp) { ... }

    @Order(2)  // 무효!
    @Around("execution(* com.svc..*(..))")
    public Object measure(ProceedingJoinPoint pjp) throws Throwable { ... }
}

같은 aspect 안의 두 advice 사이에는 @Order로 순서를 지정할 방법이 없다. Spring Reference가 이 부분을 명시한다.

"if two pieces of advice defined in the same @Aspect class both need to run at the same join point, the ordering is undefined (since there is no way to retrieve the declaration order via reflection for javac-compiled classes). Consider collapsing such advice methods into one advice method per join point in each @Aspect class or refactor the pieces of advice into separate @Aspect classes that you can order at the aspect level via Ordered or @Order."

정리하면 두 가지다.

  • 같은 @Aspect 클래스 안의 같은 타입 advice끼리는 순서가 미정의다 (선언 순서를 리플렉션으로 못 읽는다)
  • 순서가 필요하면 aspect를 별도 클래스로 분리하고, 각 클래스에 @Order를 붙여라

8-3) 선호: aspect 분리 + 클래스 레벨 @Order

// 선호: 관심사별로 aspect 분리
@Aspect
@Component
@Order(1)  // 가장 바깥
public class LoggingAspect {
    @Around("execution(* com.svc..*(..))")
    public Object log(ProceedingJoinPoint pjp) throws Throwable {
        log.info("enter: {}", pjp.getSignature());
        try {
            return pjp.proceed();
        } finally {
            log.info("exit: {}", pjp.getSignature());
        }
    }
}

@Aspect
@Component
@Order(2)  // 로깅 안쪽, Transaction 바깥
public class MetricsAspect {
    @Around("execution(* com.svc..*(..))")
    public Object measure(ProceedingJoinPoint pjp) throws Throwable {
        Timer.Sample s = Timer.start();
        try {
            return pjp.proceed();
        } finally {
            s.stop(metrics);
        }
    }
}

로깅(Order 1) → 메트릭(Order 2) → 트랜잭션(@Transactional advice) 순으로 양파가 쌓인다. 트랜잭션 advice는 별도로 순서를 지정하지 않으면 Spring이 자체 순서(낮은 우선순위)로 배치한다. 정확한 경계가 필요하면 @EnableTransactionManagement(order = ...)로 트랜잭션 advisor의 순서까지 맞춘다. **"순서가 중요하면 aspect를 쪼개라"**는 원칙이 코드로 그대로 나타난 모습이다.


9) 실무에서 이렇게 읽고 쓴다

체크리스트

  • 선언@Aspect 옆에 @Component가 있는가? 빈으로 등록되지 않은 aspect는 조용히 사라진다
  • advice 선택@Around를 써야 할 이유가 정말 있는가? 관찰만 하려면 @Before/@AfterReturning이 정확하고 안전하다 ("least powerful form")
  • Pointcut 재사용 — 같은 표현식이 두 번 이상 나오면 @Pointcut으로 이름을 붙인다. 그래야 의미 단위가 고정된다
  • self-invocation — 같은 클래스 안에서 this.otherMethod()를 부르고 있지는 않은가? 특히 @Transactional, @Cacheable, @Async
  • 순서 충돌 — 두 advice가 같은 조인 포인트에서 교차하면 aspect 클래스를 분리하고 클래스 레벨 @Order를 준다. 메서드 레벨 @Order는 무효

디버깅 포인트

  • "어드바이스가 안 걸리는 것 같다" — advice 안에서 jp.getThis().getClass().getName()을 로그로 찍어본다. $$EnhancerBySpringCGLIB 같은 접미사가 없으면 프록시를 우회한 호출이고, self-invocation을 의심할 시점이다
  • @Around 리턴 타입 — 반드시 Object로 선언한다. void로 선언하면 원본 메서드가 값을 반환해도 null이 돌아간다
  • Pointcut 과잉 매칭execution(* com..*(..)) 같은 넓은 표현식은 BeanPostProcessor, PropertySourcesPlaceholderConfigurer까지 잡혀 부팅 시 java.lang.IllegalArgumentException: warning no match가 뜨거나, 더 조용히 성능이 죽는다. 범위는 항상 가능한 한 좁게 시작한다
  • final 메서드/클래스 — CGLIB는 상속 기반이라 final에 프록시를 못 건다. Kotlin 기본 클래스, 일부 설정 클래스가 이 함정에 걸린다. 조용히 실패하므로 부트 로그의 경고를 무시하지 않는다
  • 인터페이스 vs 구체 타입 주입 — JDK 동적 프록시는 인터페이스 타입으로 주입받아야 ClassCastException이 안 난다. Spring Boot 기본은 CGLIB이므로 구체 타입 주입도 되지만, 이 설정이 바뀌면 깨진다

10) 한 줄 정리

Spring AOP는 프록시 기반 메서드 어드바이스 엔진이며, @AspectJ는 그 위에 얹은 어노테이션 DSL일 뿐이다. Pointcut 표현식의 *.., 그리고 @Aroundproceed()는 말 없이 실패하는 세 지점이므로 항상 의심해야 한다. self-invocation과 @Order 미정의는 프록시 모델의 그림자다 — 구조로 풀어야지 우회로 풀지 마라.


태그: Spring AOP, AspectJ, Pointcut, @Around, ProceedingJoinPoint, self-invocation, @Order, JDK Dynamic Proxy, CGLIB, @Transactional

728x90