본문 바로가기

AOP 핵심 용어

Spring AOP의 핵심 용어를 호출 체인에 배치하고 AspectJ와 프록시 AOP의 범위를 구분합니다.

AOP 용어를 정의만 외우면 스택 트레이스에서 무슨 일이 일어났는지 설명하지 못합니다. WeeklyReportService.weekly(long) 호출 하나에 각 개념을 배치해 보면 관계가 분명해집니다.

대상(target)은 업무 객체, 프록시(proxy)는 클라이언트가 가진 대리 객체, 조인 포인트(join point)는 메서드 실행, 포인트컷(pointcut)은 선택 규칙, 어드바이스(advice)는 실행될 부가 기능입니다. 어드바이저(advisor)는 포인트컷과 어드바이스를 하나로 묶은 항목이고, 애스펙트(aspect)는 이런 정책을 정의해 모아 둔 구성이며, 위빙(weaving)은 그 정책을 실제 실행 흐름에 연결하는 과정입니다.

AOP TERMS · ONE PROXY CALL

프록시 참조와 대상 사이에 선택된 Advisor가 연결된다

조인 지점은 지원되는 메서드 실행이고 Pointcut은 적용 조건, Advice는 동작입니다. Advisor는 조건과 동작을 연결하며 Aspect는 이 정책들의 정의를 모읍니다.

프록시 영역 안에 선택된 어드바이저가 연결된다영역은 호출자가 받는 프록시이며 Advisor는 그 내부의 조건과 동작이다. 두 화살표는 Client 호출과 proceed의 Target 위임이다. Join point는 업무 메서드 실행이지 추가 객체가 아니다. Aspect 정의에서 Advisor를 만드는 설정과 직접 원본/self 호출의 우회는 아래 보조 설명에 구별한다.Proxy · 노출 참조Clientweekly() 호출선택된 AdvisorPointcut + AdviceTarget업무 메서드 실행
프록시 영역 안에 선택된 어드바이저가 연결된다영역은 호출자가 받는 프록시이며 Advisor는 그 내부의 조건과 동작이다. 두 화살표는 Client 호출과 proceed의 Target 위임이다. Join point는 업무 메서드 실행이지 추가 객체가 아니다. Aspect 정의에서 Advisor를 만드는 설정과 직접 원본/self 호출의 우회는 아래 보조 설명에 구별한다.Proxy · 노출 참조Clientweekly() 호출선택된 AdvisorPointcut + AdviceTarget업무 메서드 실행
클라이언트가 프록시 안의 어드바이저 체인을 거쳐 대상으로 진행한다클라이언트가 컨테이너가 노출한 프록시 참조를 호출한다. 프록시 내부에 선택된 advisor의 advice가 연결되고 대상 메서드로 진행한다. 조인 지점은 별도 객체가 아니라 지원되는 메서드 실행이다.Proxy · 최종 노출 참조Clientweekly() 호출선택된 AdvisorPointcut + AdviceTarget업무 메서드 실행Join point: 프록시를 통한 지원 메서드 실행
  1. Client → Proxy 안의 Advisor → Target

    클라이언트가 최종 프록시 참조를 호출합니다. Pointcut에 맞는 Advice가 연결되어 대상 전후를 감싸며, 지원 메서드 실행 자체가 join point입니다.

설정 시점 · Aspect → Advisor

@Aspect 메타데이터를 Advisor로 변환하고 대상에 적합한 체인을 프록시에 연결합니다. 호출별 시작·결과는 지역 변수에 둡니다.

프록시와 위빙의 범위

직접 new·원본 참조·this 내부 호출은 프록시 체인을 통과하지 않습니다. 생성자·필드·내부 호출의 더 넓은 조인 지점은 AspectJ 위빙을 별도로 평가합니다.

Around는 proceed 0/1/N회와 인자·반환 계약을 소유합니다. 애노테이션 존재에 더해 실제 프록시·advisor·대상 호출 수를 확인합니다.

그림의 큰 영역은 호출자가 받는 프록시이고, 그 안에 포인트컷과 어드바이스를 묶은 어드바이저가 있습니다. 두 화살표는 클라이언트의 weekly() 호출과, 어드바이스가 proceed로 대상에 위임하는 흐름입니다. 조인 포인트는 그 위에 추가된 객체가 아니라 대상의 업무 메서드 실행 자체입니다. 아래 칸들은 설정 시점에 @Aspect 메타데이터가 어드바이저로 바뀌어 프록시에 연결된다는 점과, 직접 new한 객체나 원본 참조, this 내부 호출은 이 체인을 거치지 않는다는 점을 구별합니다.


조인 포인트와 포인트컷

Spring의 프록시 AOP가 지원하는 핵심 조인 포인트는 Spring 빈의 메서드 실행입니다. 포인트컷 표현식은 그중 패키지, 타입, 메서드, 애노테이션, 인자 조건에 맞는 부분 집합을 고르고, 어드바이스는 선택된 호출의 앞이나 뒤, 또는 주변(around)에서 동작합니다.

AspectJ 언어 자체는 생성자 호출, 필드 접근, 예외 처리기 같은 더 넓은 조인 포인트 모델을 가집니다. @Aspect 애노테이션을 쓴다고 해서 Spring 프록시가 이 모든 조인 포인트를 지원하게 되지는 않습니다. 다음 코드는 네 후보 가운데 프록시가 다룰 수 있는 것을 셉니다.

src/main/java/board/aopmodel/JoinPointInventory.java
package board.aopmodel;

import java.util.List;

public final class JoinPointInventory {
    record Candidate(String description, boolean springProxySupported) { }

    public static void main(String[] args) {
        var candidates = List.of(
                new Candidate("Spring bean public method execution", true),
                new Candidate("same target self invocation", false),
                new Candidate("entity field read", false),
                new Candidate("constructor call", false));
        long supported = candidates.stream()
                .filter(Candidate::springProxySupported)
                .count();
        System.out.printf("candidates=%d proxyJoinPoints=%d%n",
                candidates.size(), supported);
    }
}

Spring AOP는 프록시를 거쳐 들어오는 빈 메서드 실행만 가로채므로 네 후보 중 하나만 남습니다. Spring 문서에 따르면 JDK 동적 프록시는 public 인터페이스 메서드만, CGLIB 프록시는 public과 protected 메서드(필요하면 패키지 수준 메서드)도 가로챌 수 있지만, 프록시를 거치는 호출은 public 메서드로 설계하는 것이 기본이라 이 예제는 public 메서드 실행 하나만 표시했습니다. 목록의 springProxySupported 값은 Spring에 물어본 결과가 아니라 이 예제가 직접 적어 둔 표시이며, count()는 그 표시가 true인 후보만 셉니다. 자기 호출, 필드 읽기, 생성자 호출은 외부에서 프록시를 거쳐 들어오는 메서드 실행이 아니므로 false로 적었습니다.

JoinPointInventory 실행
candidates=4 proxyJoinPoints=1

지원하지 않는 조인 포인트가 꼭 필요하다면, 설계를 바꿔 그 동작을 공개 메서드 호출로 드러내거나, 전체 AspectJ 위빙의 빌드·디버깅 비용을 평가합니다. 이 차이를 모르면 다음과 같은 실패가 생깁니다.

용어 혼동으로 발생하는 실패 신호
expected join point = entity field read
Spring proxy supported join point = bean method execution
matched advisors = 0
target field changed = true
audit event emitted = false
result = UNSUPPORTED_JOIN_POINT_ASSUMPTION

엔티티의 필드 읽기를 감사하려 한 이 경우, 포인트컷 문법에는 오류가 없어도 프록시가 제공하지 않는 조인 포인트라서 어드바이스는 0회 실행됩니다. 필드는 바뀌었는데 감사 이벤트는 나가지 않습니다. 용어의 정의를 런타임에서 지원하는 범위와 연결해 두지 않으면 이런 감사 누락이 조용히 생깁니다.


대상과 프록시의 동일성

빈 후처리기가 대상을 감싸 프록시 참조를 컨테이너에 돌려주고, 다른 빈은 그 프록시를 주입받아야 어드바이스가 실행됩니다. 대상을 직접 보관한 코드, new로 만든 객체, 자기 호출은 체인 밖에 있습니다.

JDK 프록시라면 노출된 타입은 인터페이스이고, 클래스 프록시라면 대상의 하위 타입입니다. 애플리케이션은 런타임에 생성된 클래스 이름에 기대지 말고, 포트 인터페이스나 공개된 대상 타입으로 호출합니다. AopUtils.getTargetClass는 진단할 때만 씁니다.

프록시에 여러 애스펙트가 걸리면 인터셉터 체인은 어드바이저 순서에 따라 중첩됩니다. 가장 바깥의 어드바이스가 처음 시작하고 마지막에 끝납니다. 트랜잭션과 재시도의 순서가 물리 트랜잭션의 수를 바꾸는 것도 이 중첩 때문입니다.

호출 하나를 따라가 보면 다음과 같습니다. 클라이언트가 컨테이너에서 받은 프록시로 weekly를 호출하고, 선택된 어드바이저의 포인트컷이 이 실행과 맞으면 어드바이스가 먼저 들어갑니다. 어드바이스의 proceed가 원래 대상의 weekly를 실행한 뒤, 반환값이나 예외가 체인을 거슬러 올라옵니다. 이 과정에서 프록시와 대상은 서로 다른 참조입니다.


애스펙트와 어드바이저

@Aspect 클래스에는 이름을 붙인 포인트컷과 여러 어드바이스 메서드를 둘 수 있습니다. Spring은 이 메타데이터를 어드바이저 형태로 바꿔 자동 프록시 생성기에 넘깁니다. 하나의 애스펙트가 너무 많은 정책을 떠안으면 클래스만 나눴을 뿐 결합은 그대로 남습니다.

src/main/java/board/aopmodel/OperationAspect.java
package board.aopmodel;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

@Aspect
public final class OperationAspect {
    @Pointcut("execution(public * board.application..*(..))")
    public void applicationOperation() { }

    @Around("applicationOperation()")
    public Object observe(ProceedingJoinPoint joinPoint) throws Throwable {
        String operation = joinPoint.getSignature().toShortString();
        long started = System.nanoTime();
        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = System.nanoTime() - started;
            System.out.printf("operation=%s elapsedPositive=%s%n",
                    operation, elapsed >= 0);
        }
    }
}

applicationOperation()은 board.application 패키지와 그 하위 패키지의 모든 공개 메서드 실행을 고르고, observe는 그 실행을 감싸 proceed()를 한 번 호출한 뒤 경과 시간을 기록합니다. 애스펙트 객체는 대개 싱글톤이므로 호출별 상태를 필드에 두지 않습니다. 이 예처럼 조인 포인트와 지역 변수에 보관하고, 공유하는 의존성은 스레드 안전하게 만듭니다.

이 파일은 org.aspectj 패키지의 애노테이션을 쓰므로 AspectJ 라이브러리가 클래스 경로에 있어야 컴파일됩니다. Java 25와 Spring Boot의 누적 프로젝트에는 이 라이브러리가 없으므로, build.gradle의 dependencies에 implementation 'org.aspectj:aspectjweaver'를 추가합니다. 버전은 Boot BOM이 관리하며, 준비 과정은 다음 글 @Aspect 구성에서 다시 정리합니다.


위빙

Spring AOP에서는 런타임에 컨테이너가 프록시를 만들어 어드바이저를 연결합니다. 컴파일 시점 위빙(compile-time weaving)은 컴파일러가 바이트코드에 애스펙트 동작을 넣고, 적재 시점 위빙(load-time weaving)은 클래스 로더 단계에서 바이트코드를 바꿉니다. 방식마다 지원하는 조인 포인트, 시작 방법, 도구 구성, 디버깅 방법이 다릅니다.

프록시 방식은 Spring 빈의 메서드로 범위가 제한되지만, 빌드가 단순해서 트랜잭션이나 보안 같은 인프라에 널리 쓰입니다. 위빙은 도메인 객체와 자기 호출까지 다룰 수 있지만, IDE, 테스트, 에이전트, 네이티브 이미지와의 관계를 따로 관리해야 합니다. 한 프로젝트에서 두 방식을 섞는다면 같은 애스펙트가 두 번 적용되지 않는지 검토하고, 고른 이유와 적용 범위를 아키텍처 문서에 남깁니다.

더 깊이: @Aspect를 써도 AspectJ 위버는 동작하지 않습니다

org.aspectj 패키지의 애노테이션을 쓰니 AspectJ가 위빙한다고 생각하기 쉽지만, Spring 레퍼런스는 그렇지 않다고 설명합니다. Spring은 AspectJ 5의 애노테이션을 같은 뜻으로 해석하고, 포인트컷 식을 파싱하고 맞춰 보는 데에만 AspectJ가 제공하는 라이브러리를 씁니다. 실행 시점의 AOP는 여전히 순수한 Spring AOP이며, AspectJ 컴파일러나 위버에는 의존하지 않습니다. 그래서 @Aspect를 붙였다고 지원하는 조인 포인트가 늘지 않습니다.

이 차이는 포인트컷 식을 쓸 때 바로 드러나기도 합니다. 포인트컷 문서에 따르면 Spring AOP가 받는 지시자는 execution, within, this, target, args, 애노테이션을 보는 @target, @args, @within, @annotation, 그리고 Spring만의 bean입니다. 필드 읽기를 뜻하는 get, 필드 쓰기의 set, 호출 지점의 call, 생성자 초기화의 initialization, 예외 처리기의 handler, 흐름을 따지는 cflow, 애노테이션 형태인 @this와 @withincode 같은 AspectJ 지시자를 Spring AOP가 해석하는 식에 쓰면 IllegalArgumentException이 납니다. 위의 감사 누락처럼 조용히 0회가 되는 경우는 execution처럼 지원하는 지시자로 컨테이너 밖의 객체나 자기 호출을 노렸을 때이고, 지원하지 않는 지시자를 직접 쓴 경우에는 오히려 시작 단계에서 실패로 알 수 있습니다.


JoinPoint에서 읽는 정보

어드바이스는 JoinPoint에서 시그니처, 대상, 프록시, 인자를 읽을 수 있습니다. 인자 원문을 로그로 남기면 비밀번호나 게시글 본문이 노출될 수 있으므로, 타입과 허용된 ID만 꺼냅니다. 컴파일러나 도구가 만든 합성 메서드, 브리지 메서드가 선택된다면 포인트컷을 공개 애플리케이션 타입으로 좁힙니다.

ProceedingJoinPoint는 주변 어드바이스에서만 다음 단계로 진행하는 기능을 제공합니다. proceed를 호출하지 않으면 대상이 실행되지 않고, 다른 인자 배열로 진행하면 대상이 받는 입력이 바뀝니다. 캐시나 인가처럼 그것이 목적인 경우가 아니라면, 원래 인자로 정확히 한 번 진행합니다.


프록시 체인 확인

컨텍스트 테스트에서는 대상 클래스, 프록시 여부, 어드바이저 수, 포인트컷과 맞는 메서드, 어드바이스 카운터, 대상 카운터를 확인합니다. 용어로 그린 그림과 런타임에서 센 횟수가 서로 맞아야 합니다. 애노테이션이 붙어 있는지만 검사하면, 자동 프록시가 활성화되지 않은 구성을 놓칩니다.


정리

  • 대상은 업무 객체, 프록시는 클라이언트가 가진 대리 객체이며, 조인 포인트는 프록시를 거친 메서드 실행입니다.
  • 포인트컷은 조인 포인트를 고르는 규칙, 어드바이스는 실행할 동작, 어드바이저는 둘을 묶은 항목, 애스펙트는 정책을 모은 구성입니다.
  • Spring 프록시 AOP는 빈 메서드 실행만 지원하므로 필드 접근, 생성자, 자기 호출에는 어드바이스가 붙지 않습니다.
  • 위빙 방식마다 지원 범위와 도구 비용이 다르므로, 고른 방식과 범위를 문서와 테스트로 남깁니다.

연습 문제

1. 용어를 호출 하나에 대응시키기

WeeklyReportService.weekly(long) 호출에 대상, 프록시, 조인 포인트, 포인트컷, 어드바이스, 어드바이저, 애스펙트, 위빙을 각각 한 문장으로 대응시키세요. new WeeklyReportService()로 만든 객체를 호출할 때와 자기 호출일 때 어느 항목이 사라지는지도 표시하세요.

해설 보기

Spring 컨텍스트가 만든 프록시를 호출할 때만 어드바이저 체인이 연결됩니다. 대상을 직접 생성하면 포인트컷 메타데이터가 있어도 자동 프록시 생성기가 그 객체를 볼 기회가 없으므로, 프록시와 어드바이스 실행이 사라집니다. 자기 호출에서도 대상 참조로 바로 이어지므로 같은 항목이 빠집니다. 세 진입 경로를 다음처럼 값으로 표현해 볼 수 있습니다.

package board.aopmodel;

public final class CallPath {
    public enum Entry { SPRING_PROXY, DIRECT_TARGET, SELF_INVOCATION }

    public static boolean adviceRuns(Entry entry) {
        return entry == Entry.SPRING_PROXY;
    }

    public static void main(String[] args) {
        for (Entry entry : Entry.values()) {
            System.out.println(entry + " advice=" + adviceRuns(entry));
        }
    }
}

실행하면 SPRING_PROXY advice=true, DIRECT_TARGET advice=false, SELF_INVOCATION advice=false가 출력됩니다. 전체 AspectJ 위빙을 선택하면 이 결과가 달라질 수 있으므로, Spring 프록시 AOP라는 전제를 테스트 이름에 넣습니다.

2. 자기 호출과 어드바이스 진입 수

프록시를 거치는 공개 메서드 outer와 inner가 같은 대상 안에 있고, 두 메서드 모두 포인트컷과 맞습니다. outer의 본문은 this.inner()를 정확히 한 번 호출합니다. 주변 어드바이스는 진입할 때마다 proceed를 한 번 하고 실패하지 않습니다. 외부에서 proxy.outer()를 한 번 부르면 업무 메서드 실행과 어드바이스 진입은 각각 몇 번인가요? 외부에서 proxy.inner()를 따로 한 번 더 부른 경우까지 나눠 세세요. 전체 AspectJ 위빙은 쓰지 않습니다.

해설 보기

첫 호출에서 업무 메서드는 outer 1회와 inner 1회가 실행되지만, 어드바이스는 외부에서 들어온 proxy.outer 진입 1회뿐입니다. this.inner()는 같은 대상 참조에서 이어지므로 새로운 프록시 진입을 만들지 않습니다.

외부에서 proxy.inner()를 한 번 더 부르면 그 호출에는 어드바이스가 한 번 더 붙어, 전체는 업무 실행 3회와 어드바이스 진입 2회가 됩니다. 포인트컷과 맞는다는 조건과, 실제로 프록시 경로를 지났다는 조건을 함께 확인해야 합니다.

3. 애스펙트, 어드바이저, 대상 호출의 개수

하나의 @Aspect 클래스에 @Before 어드바이스 하나와 @Around 어드바이스 하나가 있고, 두 어드바이스의 포인트컷 모두 weekly 실행과 맞습니다. @Around는 proceed를 한 번 호출합니다. 프록시로 weekly를 한 번 부를 때 애스펙트의 수, 이 메서드에 적용되는 어드바이저의 수, 대상 호출 수를 구하세요.

해설 보기

애스펙트는 1개입니다. Spring은 어드바이스 메서드마다 어드바이저를 만들므로 이 애스펙트에서 나온 어드바이저는 2개입니다. 다만 AspectJ 어드바이스가 있는 체인에는 자동 프록시 생성기가 ExposeInvocationInterceptor의 어드바이저를 맨 앞에 하나 더 넣으므로, 프록시의 Advised.getAdvisors()로 세면 3개가 나옵니다. 두 어드바이스가 모두 실행되어도 대상을 부르는 것은 @Around의 proceed 한 번뿐이므로 대상 호출은 1회입니다.

이처럼 애스펙트 수, 어드바이저 수, 대상 호출 수는 서로 다른 숫자입니다. 횡단 관심과 AOP의 ObservedOperation은 이 관계 가운데 주변 어드바이스 하나만 손으로 흉내 낸 것입니다.

다음 글 @Aspect 구성에서는 이 용어들을 실제 Spring 컨텍스트에 올려, @Aspect 빈을 등록하고 자동 프록시가 만들어지는 과정을 확인합니다.