본문 바로가기

런타임 포인트컷 지시자

execution·args·annotation·this·target이 보는 타입과 실행 시점의 차이를 실험하고 동적 조건의 노출과 비용을 제한합니다.

포인트컷 지시자(pointcut designator)는 모두 타입으로 대상을 고르지만, 보는 대상이 서로 다릅니다. execution의 파라미터 자리는 메서드 시그니처를, args는 런타임 인자를, this는 프록시 객체를, target은 실제 대상 객체를 기준으로 합니다.

이 차이를 모르면 JDK 프록시에서 구체 클래스 조건이 조용히 맞지 않게 되거나, 넓은 인자 매처가 예상하지 못한 하위 타입까지 잡습니다. 이 글에서는 같은 호출을 네 지시자가 각각 어떻게 보는지 확인하고, 애노테이션과 인자를 바인딩할 때의 제한과 비용을 정리합니다.

POINTCUT VIEWS · FOUR DISTINCT FACTS

선언·실제 인자·프록시·대상을 따로 본다

예: register(Command)에 SpecialCommand 값을 전달합니다. execution·args는 메서드와 값을, this·target은 서로 다른 객체를 기준으로 판단합니다.

선언 타입과 저장된 객체의 타입ArgumentTypes 예제만 나타낸다. 첫 화살표는 참조가 저장된 Imported 객체를 가리키는 관계이고 둘째는 그 객체를 declared 매개변수에 전달하는 호출이다. getClass의 결과는 Imported이며 declared=Command는 원 출력의 고정 문자열이다. 이 예제에는 Spring 프록시가 없다.
변수 value선언 타입 Command
연결 관계변수 value · 선언 타입 Command → 실제 객체 · Imported · ("AOP", "csv")
실제 객체Imported("AOP", "csv")
연결 관계실제 객체 · Imported · ("AOP", "csv") → declared(Command) · 결과 "Imported"
declared(Command)결과 "Imported"
인터페이스 프록시와 final 대상ProxyIdentityDemo에서 인터페이스 프록시를 선택한 객체 관계다. proxy는 ReportPort이고 ReportTarget은 아니며 실제 target은 ReportTarget이다. 화살표는 proxy에서 대상의 postCount로 위임하는 전달이다. 원 main은 타입 사실을 출력하며 실제 업무 호출이나 Advice 카운터는 직접 측정하지 않는다. 앞 Command 예제와 다른 객체다.
노출 proxyReportPort 구현
연결 관계노출 proxy · ReportPort 구현 → 실제 target · final ReportTarget
실제 targetfinal ReportTarget
연결 관계실제 target · final ReportTarget → postCount() · 업무 결과 12
postCount()업무 결과 12

execution · 선언 Method

register(Command)의 파라미터 선언은 Command입니다. 하위 타입 값을 전달해도 이 시그니처 자체가 바뀌지 않습니다.

args · 런타임 인자

실제 SpecialCommand 값의 대입 가능성을 봅니다. 기반 인자·null·가변 인자와 컬렉션 요소를 구분하고 호출별 매처 비용을 확인합니다.

this · 프록시 객체

JDK + this(ConcreteService): false

JDK 프록시는 노출 인터페이스 타입입니다. 클래스 프록시가 ConcreteService의 적격 하위 타입이면 this 조건은 맞을 수 있습니다.

target · 실제 대상 객체

target(ConcreteService): 대상이 해당 타입이면 true

JDK 방식에서도 실제 업무 대상을 봅니다. 프록시 방식 전환에 정책이 빠지지 않도록 두 방식을 대조합니다.

@annotation은 메서드 정책 메타데이터를 바인딩합니다. 안정적 작업 코드·마스킹 정책만 추출하며 본문·토큰·임의 toString을 기록하지 않습니다. 관찰 advice는 입력을 바꾸지 않습니다.

그림의 첫 부분은 ArgumentTypes 예제에서 Command로 선언된 변수가 실제로는 Imported 객체를 가리키고, 그 객체를 declared(Command)에 넘기면 getClass()가 Imported를 돌려준다는 관계입니다. 둘째 부분은 ProxyIdentityDemo에서 인터페이스 프록시가 ReportPort를 구현할 뿐 ReportTarget은 아니며, 실제 대상은 final인 ReportTarget이라는 관계입니다. 아래 네 칸은 execution이 선언된 Method를, args가 런타임 인자 값을, this가 프록시 객체를, target이 실제 대상을 본다는 점을 각각 정리합니다.


execution과 args

메서드가 register(Command)로 선언되어 있고 런타임에 SpecialCommand 값이 들어오면, execution은 Command라는 선언을 보고 args는 그 값이 SpecialCommand에 대입될 수 있는지를 봅니다. 정적 매처는 프록시를 만들 때 결과를 캐시할 수 있지만, 인자 조건은 호출마다 검사될 수 있습니다.

src/main/java/board/binding/ArgumentTypes.java
package board.binding;

public final class ArgumentTypes {
    public sealed interface Command permits Standard, Imported { }
    public record Standard(String title) implements Command { }
    public record Imported(String title, String source) implements Command { }

    public static String declared(Command command) {
        return command.getClass().getSimpleName();
    }

    public static void main(String[] args) {
        Command value = new Imported("AOP", "csv");
        System.out.printf("declared=Command runtime=%s%n", declared(value));
    }
}

value 변수와 declared 메서드의 파라미터는 Command로 선언되어 있지만, 저장된 객체는 Imported이고 getClass()는 그 실제 객체의 클래스를 읽습니다. getSimpleName()은 패키지와 바깥 클래스 이름을 뺀 이름만 돌려주므로, 중첩 레코드인 ArgumentTypes.Imported도 출력에는 Imported로만 나옵니다. 포인트컷에서는 이 차이가 execution이 보는 선언 타입과 args가 보는 런타임 타입의 차이에 해당합니다.

ArgumentTypes 실행
declared=Command runtime=Imported

출력의 declared=Command는 코드에 직접 적은 문자열이며, 리플렉션으로 선언을 검사한 결과는 아닙니다. 업무 분기를 인자 포인트컷으로 숨기기보다 도메인 코드의 디스패치를 씁니다. args는 특정 표시가 붙은 요청을 기록하거나 레거시 어댑터의 범위를 고르는 경우처럼, 횡단 정책에 실제 런타임 타입이 꼭 필요할 때로 제한합니다.


@annotation 바인딩

@annotation(audited)는 일치한 메서드의 애노테이션 인스턴스를 어드바이스 파라미터로 넘겨줄 수 있습니다. 어드바이스는 그 값에서 안정적인 작업 코드와 마스킹 정책을 읽습니다. 애노테이션을 대상 메서드와 인터페이스 가운데 어디에 붙일지는 일관되게 정합니다.

src/main/java/board/binding/Audited.java
package board.binding;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
    String operation();
    boolean includeActor() default true;
}

이름이 비슷한 지시자들도 보는 곳이 다릅니다. @within은 애노테이션이 붙은 타입 안의 메서드를, @target은 런타임 대상 클래스의 애노테이션을, @args는 런타임 인자 타입의 애노테이션을 봅니다. 서로 바꿔 쓸 수 있다고 생각하지 말고, 정책을 대상 클래스, 메서드, 인자 가운데 어디에 표시할지 먼저 고릅니다.


this와 target

JDK 프록시는 인터페이스 타입이므로 this(ConcreteService)가 일치하지 않을 수 있지만, target(ConcreteService)는 실제 대상을 봅니다. 클래스 프록시는 프록시 자체가 대상의 하위 타입이라 둘 다 맞을 수 있습니다. 프록시 방식을 바꿨을 때 정책이 사라지지 않는지 테스트로 확인합니다.

src/main/java/board/binding/ProxyIdentityDemo.java
package board.binding;

import org.springframework.aop.framework.ProxyFactory;

public final class ProxyIdentityDemo {
    interface ReportPort { int postCount(); }

    static final class ReportTarget implements ReportPort {
        @Override public int postCount() { return 12; }
    }

    public static void main(String[] args) {
        var target = new ReportTarget();
        var factory = new ProxyFactory(target);
        ReportPort proxy = (ReportPort) factory.getProxy();

        System.out.printf("thisIsPort=%s thisIsTarget=%s targetIsTarget=%s%n",
                proxy instanceof ReportPort,
                proxy instanceof ReportTarget,
                target instanceof ReportTarget);
    }
}

ProxyFactory는 대상이 인터페이스를 구현하면 기본으로 JDK 프록시를 만들므로, 출력은 thisIsPort=true thisIsTarget=false targetIsTarget=true입니다. 이 세 값은 노출된 참조와 실제 대상을 구별해 보여 줄 뿐이며, postCount의 12나 어드바이스 개수를 이 main이 출력하지는 않습니다. 이 예제의 포트는 ReportPort이고 postCount에는 인자가 없으므로, 앞 ArgumentTypes의 Command를 이 프록시의 인자처럼 끼워 넣어 읽지 않습니다.

ReportTarget처럼 구체 클래스가 final이라 클래스 프록시를 만들 수 없는 상황에서도, 포트 인터페이스를 기준으로 한 포인트컷은 안정적으로 동작합니다. 반대로 구체 타입에 this 조건을 걸면 다음과 같은 누락이 생깁니다.

this·target pointcut 불일치 실패
proxy implementation = JDK dynamic proxy
this(ReportTarget) matches = false
target(ReportTarget) matches = true
expected advice calls = 1
observed advice calls with this condition = 0
result = CONCRETE_THIS_POINTCUT_MISSED

이 진단 형식은 구체 타입의 this 조건을 썼을 때를 설명한 예시이며, 위 코드가 포인트컷 호출 횟수를 직접 측정한 결과는 아닙니다. 구체 대상에 정책을 걸고 싶다면 target을 쓰거나, 더 안정적인 포트·애노테이션 포인트컷으로 바꿉니다.


인자 바인딩의 제한

어드바이스 파라미터로 명령을 받았다고 해서 toString() 전체를 로그로 남기지 않습니다. 레코드에는 비밀번호, 게시글 본문, 토큰이 들어 있을 수 있습니다. 허용 목록 방식의 추출기가 구성원 ID나 요청 ID 같은 내부 식별자만 꺼내고, 그 값도 메트릭 태그로는 넣지 않습니다.

null 인자는 타입 기반의 인자 일치에 영향을 줍니다. AspectJ의 규칙에서 args(T)는 null 인자를, 선언된 파라미터 타입이 T와 같거나 T의 하위 타입일 때만 일치로 봅니다. 그래서 메서드 시그니처로 범위를 잡고 어드바이스 안에서 null을 안전하게 다루는 편이 더 예측하기 쉬울 수 있습니다.

가변 인자는 배열로 전달되며, 컬렉션 요소의 타입은 런타임에 남은 제네릭 정보만으로는 바로 일치를 판단할 수 없습니다. 어드바이스에서 인자를 바꿔 proceed(newArgs)로 넘기면 검증이나 인가를 거친 값과 달라질 수 있습니다. 값의 정규화는 명시적인 어댑터나 애플리케이션 코드에서 하고, 기록용 애스펙트는 값을 바꾸지 않습니다.


동적 포인트컷의 비용

런타임 인자 매처는 호출마다 조건식을 실행합니다. 아주 짧은 게터 수백만 건에 적용하면, 어드바이스 본문보다 일치 검사 비용이 더 커질 수 있습니다. 먼저 클래스와 메서드의 정적 조건으로 후보를 좁힌 뒤, 꼭 필요한 인자 조건만 더합니다.

null과 하위 타입을 섞은 부정 픽스처도 둡니다. register(Command)에 건 args(SpecialCommand)가 기반 명령과 null에서는 거짓이고 의도한 하위 타입에서만 참인지, 애노테이션을 바인딩하는 파라미터 이름이 포인트컷 변수와 정확히 같은지를 컨텍스트 시작과 호출 횟수로 확인합니다. 바인딩 이름의 오타는 애스펙트 생성 실패로 빨리 드러나야 하며, 예외를 삼키고 어드바이저 없이 시작해서는 안 됩니다.

성능은 JIT 준비, 프록시 체인, 객체 할당을 포함한 실제 작업 부하의 벤치마크로 판단합니다. 표현식의 파싱은 시작할 때 끝나더라도, 런타임 바인딩과 애노테이션 해석 비용은 호출마다 남을 수 있습니다.

더 깊이: 정적 검사와 동적 검사는 어디서 나뉠까

Spring AOP는 포인트컷 하나를 두 단계로 나눠 검사합니다. Spring Framework 7.0.10의 DefaultAdvisorChainFactory는 메서드의 인터셉터 목록을 만들 때 클래스 필터와 MethodMatcher.matches(method, targetClass)로 먼저 정적 검사를 하고, 이 단계에서 맞지 않으면 그 어드바이저를 목록에 넣지 않습니다. 정적 검사를 통과했는데 매처의 isRuntime()이 참이면, 인터셉터를 바로 넣지 않고 InterceptorAndDynamicMethodMatcher로 감싸 넣습니다. AspectJExpressionPointcut은 표현식이 실행 시점의 검사를 필요로 할 수 있을 때, 즉 args나 this, target 같은 조건이 정적으로 확정되지 않을 때 isRuntime()을 참으로 돌려줍니다.

이렇게 감싼 인터셉터는 ReflectiveMethodInvocation.proceed()가 체인을 진행할 때마다 matches(method, targetClass, arguments)로 실제 인자를 다시 검사하고, 거짓이면 그 인터셉터만 건너뛰고 다음으로 넘어갑니다. 인터셉터 목록 자체는 메서드마다 캐시되므로, 호출마다 남는 비용은 이 동적 검사입니다. 반대로 정적 검사에서 이미 확정되는 execution과 within만으로 이뤄진 포인트컷은 이 단계가 없습니다. 같은 정책이라도 인자 조건을 빼고 범위를 시그니처로 표현할 수 있다면, 호출 경로에서 검사 하나를 없애는 셈입니다.


지시자별 프록시 검증

JDK 프록시와 클래스 프록시 컨텍스트를 각각 만들어 this와 target의 일치 개수를 비교합니다. 선언된 기반 인자와 런타임 하위 타입, 애노테이션의 위치, null 입력을 표로 고정해 둡니다. 프레임워크를 업그레이드한 뒤 결과가 달라지면, 그것이 보안이나 트랜잭션의 적용 누락으로 이어지는지 평가합니다.


정리

  • execution은 선언된 시그니처를, args는 런타임 인자 값을, this는 프록시 객체를, target은 실제 대상 객체를 봅니다.
  • JDK 프록시에서는 this(구체 클래스)가 맞지 않을 수 있으므로, 구체 대상 정책은 target이나 포트·애노테이션 포인트컷으로 표현합니다.
  • @annotation, @within, @target, @args는 애노테이션을 찾는 위치가 서로 다르며, 바인딩한 값에서는 안정적인 코드만 꺼냅니다.
  • 런타임 인자 매처는 호출마다 비용이 들므로 정적 조건으로 먼저 좁히고, null과 하위 타입을 섞은 부정 픽스처로 검증합니다.

연습 문제

1. 행위자 ID를 가진 명령만 바인딩하기

@Audited 애플리케이션 메서드에서 행위자 ID를 가진 명령만 바인딩하는 포인트컷을 설계하세요. JDK 프록시에서도 적용되어야 하고, 명령 본문은 로그로 남기지 않으며, 행위자가 없으면 대상을 호출하기 전에 분명한 실패로 처리해야 합니다.

해설 보기

메서드 애노테이션으로 정적 범위를 잡고, 어드바이스가 명령 인터페이스를 파라미터로 바인딩하면 구체 구현의 이름에 덜 의존합니다.

package board.binding;

public interface ActorCommand {
    long actorId();

    static long requireActor(ActorCommand command) {
        if (command == null || command.actorId() < 1) {
            throw new IllegalArgumentException("valid actor is required");
        }
        return command.actorId();
    }
}

인가 자체는 전용 보안 정책이 수행하고, 감사 애스펙트는 그 결과 코드만 기록하도록 역할을 나눕니다.

actorId()는 원시 long이므로 그 값 자체가 null일 수는 없습니다. 그래서 행위자가 없는 입력은 null 명령이나 유효하지 않은 ID로 구체화해 시험합니다. requireActor가 대상보다 먼저 실행되게 하려면 @Audited와 메서드 시그니처로 진입 범위를 고정하고, 어드바이스 안에서 null일 수 있는 명령을 안전하게 꺼내 검사해야 합니다. args 조건이 null 호출과 일치하지 않는 구성이라면 그 호출에서는 어드바이스의 검사도 실행되지 않으므로, 바인딩을 선언했다고 모든 거부가 보장되지는 않습니다.

2. 세 입력의 검사 결과

세 입력 모두 같은 @Audited 메서드 범위에 들어오고, 어드바이스가 proceed 전에 requireActor를 정확히 한 번 호출한다고 가정합니다. A는 null 명령, B는 actorId가 0인 명령, C는 actorId가 7인 명령입니다. 각각의 검사 결과와 대상 호출 수를 구하세요. 정책 판단과 기록기는 실패하지 않고, 본문 전체를 출력하지 않습니다.

해설 보기

A는 첫 null 조건에서, B는 actorId() < 1 조건에서 같은 IllegalArgumentException으로 실패하고, 대상 호출은 각각 0회입니다. C는 7을 반환하며, 뒤이은 인가가 허용한 경우에만 대상 호출 1회로 이어집니다.

7이 양수라는 검사는 입력이 유효한지를 볼 뿐, 소유권이나 권한을 대신 확인해 주지 않습니다. 세 경우를 모두 어드바이스가 받는다는 전제가 빠지면, 거부 횟수도 이 표대로 보장할 수 없습니다.

3. 선언 타입과 실제 타입

ArgumentTypes의 main에서 값만 new Standard("AOP")로 바꾸면 출력은 어떻게 되나요? 별도로 Spring 빈에 declared와 같은 시그니처의 공개 인스턴스 메서드 handle(Command)가 있고, 그 실행에 args(board.binding.ArgumentTypes.Imported) 포인트컷을 건다면, 원래 값과 바꾼 값 가운데 어느 호출에서 일치하나요? 같은 메서드에 execution(* *(board.binding.ArgumentTypes.Imported))를 걸면 어떻게 되는지도 비교하세요. 정적 메서드인 declared는 Spring 프록시가 가로챌 수 없으므로 이렇게 바꿔 묻습니다.

해설 보기

파라미터 선언은 그대로이고 실제 객체만 바뀌므로 출력은 declared=Command runtime=Standard입니다.

args(Imported)는 런타임 인자를 보므로 원래의 Imported 값에서는 참이고, Standard 값에서는 거짓입니다. 반면 execution(* *(Imported))는 선언된 파라미터 타입을 보는데 handle의 선언은 Command이므로, 어떤 값을 넘기든 일치하지 않습니다. 같은 호출이라도 지시자에 따라 선언과 실제 값 가운데 다른 것을 본다는 점을 이 차이로 확인할 수 있습니다.

다음 글 로깅·재시도 AOP에서는 지금까지의 포인트컷과 어드바이스를 묶어, 로그 애스펙트와 재시도 애스펙트를 실제 컨텍스트에 적용하고 순서와 호출 횟수를 테스트로 확인합니다.