본문 바로가기

실행·범위 포인트컷

execution 표현식을 접근 제한자·반환형·선언 타입·메서드·매개변수로 분해하고 within과 적용 범위를 비교합니다.

포인트컷 표현식 한 줄이 어떤 빈의 어떤 메서드에 정책이 적용될지를 정합니다. 너무 넓으면 상태 확인 메서드와 리포지토리의 게터에까지 트랜잭션이나 로그가 붙고, 너무 좁으면 새로 만든 구현에서 보안 검사가 빠집니다.

그래서 execution을 왼쪽부터 통째로 외우기보다, 시그니처 조건별로 나눠 대표 메서드에 하나씩 대입해 봅니다. 이 글에서는 execution의 각 자리, 타입 범위를 고르는 within, 빈 이름을 고르는 bean()을 차례로 살펴보고, 조건을 교집합으로 쌓는 방법과 회귀 테스트를 다룹니다.

POINTCUT · SIGNATURE AND REGRESSION

execution은 선언 시그니처의 조건을 결합한다

포인트컷 문자열은 정책 코드입니다. 후보를 좁힌 이유를 항목별로 설명하고 실제 Method·target class와 포함/제외 픽스처를 함께 확인합니다.

Method 선언을 실행 패턴에 대입한다원 PointcutMatchDemo의 실제 execution(public long *..*.register(String)) 판별이다. 패키지는 열려 있으며 register는 참, find는 이름과 선언 인자가 달라 거짓이다. 다이아몬드는 합성 조건의 판별을 나타내며 실제 매처의 평가 순서를 뜻하지 않는다. 일치는 프록시 호출이나 빈 이름 검사의 증거가 아니다.후보 Method대상 클래스 선언public longregister(String)?참 · register일치 후보거짓 · find이 Advisor 제외예아니요
Method 선언을 실행 패턴에 대입한다원 PointcutMatchDemo의 실제 execution(public long *..*.register(String)) 판별이다. 패키지는 열려 있으며 register는 참, find는 이름과 선언 인자가 달라 거짓이다. 다이아몬드는 합성 조건의 판별을 나타내며 실제 매처의 평가 순서를 뜻하지 않는다. 일치는 프록시 호출이나 빈 이름 검사의 증거가 아니다.후보 Method대상 클래스 선언public longregister(String)?참 · register일치 후보거짓 · find이 Advisor 제외예아니요

시그니처를 항목별로 읽기

  1. 가시성 · 반환형

    public과 long은 별도 사실입니다. long과 Long은 같지 않고 *는 해당 자리를 넓힙니다.

  2. 선언 타입 · 메서드 이름

    board.application..*Service 범위와 register* 이름 조건을 구분합니다.

  3. 선언 파라미터

    RegisterCommand는 시그니처 조건입니다. *는 한 인자, ..는 0개 이상 인자를 허용합니다.

범위와 적용 증거

  1. within ∩ execution ∩ 명시 메타데이터

    within은 타입 범위이며 메서드 이름·반환형 조건을 대신하지 않습니다.

  2. 포함 + 제외를 동시에

    필수 register는 true, query·protected helper·다른 패키지는 false인지 봅니다. 오버로드와 bridge도 확인합니다.

  3. bean()은 실제 컨텍스트

    빈 이름·별칭 등 컨테이너 동일성이 필요합니다. Method만의 단위 검사는 실제 빈 적용과 프록시 경로를 증명하지 못합니다.

위 항목은 조건 분해이며 런타임 평가 순서가 아닙니다. parse 성공과 실제 일치, 표현식 일치와 프록시 가로채기 가능성을 구분합니다.

그림의 위쪽은 아래 PointcutMatchDemo가 실제로 넣는 execution(public long *..*.register(String))에 두 후보 메서드를 대입하는 판별입니다. 패키지 자리는 열려 있으므로 register(String)는 참이고, find(long)는 이름과 선언된 인자가 달라 거짓입니다. 마름모는 여러 조건을 합친 판별을 한 칸에 나타낸 것이며, 실제 매처가 그 순서로 평가한다는 뜻은 아닙니다. 아래 칸들은 가시성·반환형, 선언 타입·메서드 이름, 선언 파라미터를 항목별로 읽는 방법과, within·bean()이 서로 다른 범위를 고른다는 점을 정리합니다.


execution 패턴

일반 형태는 괄호 안에 선택적인 접근 제한자, 반환형, 선언 타입과 메서드 이름, 파라미터 목록, 선택적인 예외 조건을 차례로 둡니다. *는 한 구간의 임의 문자열이고, ..는 위치에 따라 하위 패키지 전체나 0개 이상의 파라미터를 뜻합니다. 각 자리는 자바 리플렉션으로 확인할 수 있는 서로 다른 시그니처 정보를 제한합니다.

예를 들어 execution(public * board.application..*Service.*(..))는 board.application과 그 하위 패키지에서 이름이 Service로 끝나는 타입의, 반환형·메서드 이름·파라미터와 관계없이 공개인 모든 메서드를 고릅니다. 이름 관례만으로 정책 적용 여부를 정해도 충분한지는 따로 검토합니다. 표현식을 한 덩어리로 외우지 말고, 접근 제한자, 반환형, 소유 타입, 메서드 이름, 인자 목록이 각각 어떤 픽스처를 걸러 냈는지 일치 표에 남깁니다.

src/main/java/board/pointcut/ExecutionExpressions.java
package board.pointcut;

public final class ExecutionExpressions {
    public static final String APPLICATION_PUBLIC =
            "execution(public * board.application..*(..))";
    public static final String REGISTER_ONE_ARGUMENT =
            "execution(* board.application..*.register(*))";
    public static final String LONG_RESULT =
            "execution(long board.application..*(..))";

    private ExecutionExpressions() { }
}

반환형 자리에서 원시 타입 long과 래퍼 타입 Long은 서로 다릅니다. 오버로드된 메서드는 파라미터 패턴으로 구분하고, 가변 인자가 리플렉션에서 배열로 보인다는 점도 테스트합니다.


표현식 포인트컷 검증

Spring의 AspectJExpressionPointcut을 쓰면 컨텍스트 전체를 띄우지 않고도 표현식과 Method의 일치를 검증할 수 있습니다. 일치해야 하는 메서드와 일치하지 않아야 하는 메서드를 함께 출력합니다.

src/main/java/board/pointcut/PointcutMatchDemo.java
package board.pointcut;

import java.lang.reflect.Method;

import org.springframework.aop.aspectj.AspectJExpressionPointcut;

public final class PointcutMatchDemo {
    static class ApplicationService {
        public long register(String title) { return 41L; }
        public long find(long id) { return id; }
        protected void internal() { }
    }

    public static void main(String[] args) throws Exception {
        var pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(public long *..*.register(String))");
        Method register = ApplicationService.class
                .getMethod("register", String.class);
        Method find = ApplicationService.class.getMethod("find", long.class);

        System.out.printf("register=%s find=%s%n",
                pointcut.matches(register, ApplicationService.class),
                pointcut.matches(find, ApplicationService.class));
    }
}

register(String)는 공개, long 반환, 이름 register, String 인자라는 네 조건을 모두 만족합니다. find(long)는 공개와 long 반환은 같지만 이름과 인자 조건이 달라 빠집니다.

PointcutMatchDemo 실행
register=true find=false

이 검사는 execution의 시그니처 패턴을 확인할 뿐입니다. 예제 클래스는 board.pointcut에 있고, 실행한 표현식은 패키지를 *..*로 열어 두었습니다. 따라서 register=true는 APPLICATION_PUBLIC의 board.application 범위까지 확인한 결과가 아니므로, 두 상수를 섞어 읽지 않습니다. 선택된 후보가 실제로 프록시를 통해 호출되는지, 빈 이름 조건까지 맞는지는 컨텍스트를 띄워 따로 확인해야 합니다.

표현식 문자열이 파싱된다고 해서 애플리케이션 패키지에 실제로 일치하는 메서드가 있다는 뜻도 아닙니다. 아키텍처를 리팩터링한 뒤에는 픽스처 클래스와 운영 코드의 대표 메서드를 모두 확인합니다.


within 타입 범위

within(board.application..*)는 해당 패키지의 타입 안에서 실행되는 메서드 조인 포인트 후보를 고릅니다. 메서드 이름이나 반환형의 차이는 직접 표현하지 않으므로, 넓은 모듈 범위를 먼저 잡은 뒤 애노테이션이나 execution과 결합하기 좋습니다.

within(board.application..*Service)의 타입 패턴과 execution(* board.application..*Service.*(..))는 비슷해 보이지만, 상속과 프록시의 메서드 해석에서 결과가 갈릴 수 있으므로 테스트로 확인합니다. 인터페이스에 선언된 메서드와 구현 클래스의 패키지가 다르다면, 대상 클래스를 기준으로 일치하는지를 분명히 합니다.

Spring 빈이 아니라면 within에 타입이 맞아도 프록시가 생성되지 않습니다. 포인트컷 언어에서 일치하는지와, 런타임에 실제로 적용될 수 있는지는 따로 봅니다.


bean()으로 빈 이름 고르기

bean(namePattern)은 Spring AOP가 추가로 제공하는 포인트컷 지시자입니다. AspectJ 언어의 타입 패턴이 아니라, 현재 컨테이너에 등록된 빈의 이름을 기준으로 적용 대상을 고릅니다. 같은 클래스로 만든 빈 둘 가운데 하나만 고르거나, 조직이 관리하는 이름 규칙을 정책 적용 기준으로 쓸 때 의미가 있습니다.

src/main/java/board/aop/RepositoryObservationAspect.java
package board.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public final class RepositoryObservationAspect {
    private static final Logger log = LoggerFactory.getLogger(
            RepositoryObservationAspect.class);

    @Pointcut("bean(*Repository)")
    void repositoryBean() {
    }

    @Pointcut("execution(public * *(..))")
    void publicOperation() {
    }

    @Around("repositoryBean() && publicOperation()")
    Object observe(ProceedingJoinPoint joinPoint) throws Throwable {
        long started = System.nanoTime();
        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = System.nanoTime() - started;
            log.debug(
                    "repository={} elapsedNanos={}",
                    joinPoint.getTarget().getClass().getSimpleName(),
                    elapsed);
        }
    }
}

BoardPostRepository 클래스의 기본 컴포넌트 이름은 boardPostRepository이므로 bean(*Repository)와 일치합니다. 반면 @Bean("primaryPostStore")처럼 명시한 이름은 패턴과 맞지 않으므로, 빈 이름을 바꾸는 리팩터링이 어드바이스의 적용 범위까지 바꿉니다. 타입 전체에 정책을 적용하려는 것이라면 within, target, 명시적인 애노테이션이 이름 규칙보다 안전하며, bean()은 컨테이너의 빈 단위 구분이 꼭 필요한 예외적인 경우로 제한합니다.

별칭, 자동 생성된 이름, FactoryBean이 노출하는 객체, 인프라 빈이 예상한 패턴에 들어오는지는 일치해야 할 픽스처와 일치하지 않아야 할 픽스처로 확인합니다. AspectJExpressionPointcut에 Method만 넘기는 단위 검사는 빈 이름을 알 수 없으므로, 작은 ApplicationContext를 띄워 실제 프록시 생성과 호출 횟수를 검증합니다.


포인트컷의 교집합

모듈, 가시성, 명시적인 적용 표시를 교집합으로 쌓으면 각 조건이 왜 필요한지 설명할 수 있습니다. 개념으로 줄여 쓰면 다음과 같습니다.

교집합 개념 표기
within(application 패키지) && execution(공개 메서드) && @annotation(Measured)

이 줄은 조건의 종류를 비교하기 위한 축약 표기이지, 그대로 복사할 수 있는 실행 문자열이 아닙니다. 실제 파일에는 완전한 board.application 패키지, 반환형과 메서드 자리, 애노테이션 타입의 완전한 이름을 채우고, 파싱과 일치·불일치 메서드를 따로 확인합니다. 글에서 왼쪽에 썼다고 실제 매처가 그 조건부터 평가한다고 가정하지도 않습니다.

여러 패키지를 ||로 합친다면, 새 모듈이 빠지거나 같은 메서드가 중복으로 일치하지 않는지 테스트합니다. !within(board..config..*) 같은 부정 연산자는 편리하지만, 제외 목록이 계속 늘어난다면 기본 범위가 지나치게 넓다는 신호입니다. 고를 대상을 나열하는 허용 목록 방식의 포인트컷을 우선합니다. !execution(* get*(..))처럼 이름으로 제외하면 중요한 게터까지 빠질 수 있습니다.

더 깊이: 조건의 순서는 누가 정할까

Spring 레퍼런스의 좋은 포인트컷 작성 절은 먼저 AspectJ가 컴파일할 때 포인트컷을 다루는 방식을 설명합니다. AspectJ는 포인트컷 선언을 처음 만날 때 그것을 논리합 표준형(DNF, disjunctive normal form)으로 다시 쓰고, 평가 비용이 싼 구성 요소를 먼저 검사하도록 정렬합니다. 다만 Spring AOP가 실행 중에 표현식을 해석할 때 쓰는 AspectJ 1.9.25.1의 PointcutParser는 이 재작성 단계를 거치지 않습니다. 대신 Spring의 AspectJExpressionPointcut이 메서드마다 일치 결과를 한 번 계산해 캐시하므로, 조건의 순서가 호출 성능을 좌우하지는 않습니다. 그러니 순서는 성능이 아니라 읽기 좋은 쪽을 기준으로 고르면 됩니다.

대신 같은 절은 검색 범위 자체를 좁히라고 권합니다. 지시자는 조인 포인트의 종류를 고르는 것(execution 등), 범위를 고르는 것(within 등), 문맥으로 고르고 값을 바인딩하는 것(this, target, @annotation)으로 나뉘며, 잘 쓴 포인트컷은 적어도 앞의 두 가지를 함께 가집니다. 범위 지시자는 아주 빠르게 맞춰 볼 수 있어서 처리할 필요가 없는 조인 포인트의 무리를 일찍 걸러 내므로, 가능하면 언제나 넣으라고 합니다. 이 글의 교집합이 within으로 모듈을 먼저 정하는 것도 같은 방향이며, 종류나 문맥 지시자 하나만 쓰면 동작은 해도 분석에 드는 시간과 메모리가 늘 수 있습니다.


합성 메서드 고려

제네릭 인터페이스를 구현하면 컴파일러가 브리지 메서드(bridge method)를 만들 수 있습니다. 포인트컷과 어드바이스가 두 번 실행되지 않는지, Spring이 가장 구체적인 메서드로 해석하는지를 통합 테스트로 확인합니다. 런타임에 생성된 클래스 이름을 표현식에 넣지도 않습니다.

클래스 프록시가 protected 메서드를 가로챌 수 있는지 따지기보다, 애플리케이션의 진입점을 공개 메서드로 두는 원칙이 더 읽기 쉽습니다. private 메서드와 자기 호출은 표현식이 맞아도 프록시의 조인 포인트가 아닙니다. 반환형의 공변 재정의와 인터페이스에 붙인 애노테이션의 위치도 포인트컷 결과에 영향을 줄 수 있으므로, 대표적인 두 프록시 방식을 모두 작은 테스트로 검증합니다.


포인트컷 회귀 테스트

보안이나 트랜잭션 포인트컷에는 반드시 포함되어야 할 메서드 목록과, 절대 포함되면 안 되는 메서드 목록을 둡니다. 패키지를 옮기는 PR에서 이 테스트가 실패하면, 표현식과 아키텍처의 의도를 함께 검토합니다. 스냅샷만 갱신하고 넘어가지 않습니다.

운영 환경에서 시작할 때 일치한 빈의 수가 갑자기 0이 되거나 두 배가 되면 경보를 낼 수 있습니다. 다만 모든 메서드 이름을 메트릭 태그로 내보내지는 않습니다.


정리

  • execution은 접근 제한자, 반환형, 선언 타입, 메서드 이름, 파라미터를 각각 제한하며, 자리마다 *와 ..의 의미가 다릅니다.
  • AspectJExpressionPointcut으로 표현식과 Method의 일치를 빠르게 검사할 수 있지만, 실제 프록시 적용은 컨텍스트에서 따로 확인합니다.
  • within은 타입 범위를, bean()은 컨테이너의 빈 이름을 기준으로 고르므로, 이름 변경이 적용 범위를 바꿀 수 있습니다.
  • 조건은 허용 목록 방식의 교집합으로 쌓고, 포함·제외 목록으로 회귀 테스트를 둡니다.

연습 문제

1. register 명령만 고르는 표현식

애플리케이션 명령의 공개 register*(RegisterCommand)만 고르고, 조회 메서드, protected 도우미, 다른 패키지를 제외하는 표현식을 작성하세요. 오버로드 네 개를 픽스처로 만들어 참·거짓 표로 검증하세요.

해설 보기

파라미터 타입의 완전한 이름과 패키지 범위를 함께 쓰면 의도를 좁힐 수 있습니다.

package board.pointcut;

import org.springframework.aop.aspectj.AspectJExpressionPointcut;

public final class RegisterCommandPointcut {
    public static AspectJExpressionPointcut create() {
        var pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression("execution(public * board.application..*.register*(board.application.RegisterCommand))");
        return pointcut;
    }

    private RegisterCommandPointcut() { }
}

하위 타입 파라미터도 포함해야 한다면 + 타입 패턴의 의미를 별도의 픽스처로 검증합니다. 메서드에 실제로 넘어오는 인자의 런타임 타입과, 선언된 시그니처를 혼동하지 않습니다.

2. 오버로드 일치 표

연습 1의 표현식을 그대로 씁니다. board.application.PostService에 public long register(RegisterCommand), public long register(String), public long register(RegisterCommand, String), protected long register(RegisterCommand, int)가 있습니다. 네 오버로드의 일치 표를 먼저 쓰세요. 별도로 다른 패키지의 공개 register(RegisterCommand)와 board.application 안의 공개 query(RegisterCommand)를 추가하면, 각각 어느 조건에서 빠지나요?

해설 보기

첫 오버로드만 참입니다. 둘째는 선언된 인자 타입이, 셋째는 인자 개수가, 넷째는 공개 조건과 인자 개수가 다릅니다. 다른 패키지의 register는 타입 범위에서, query는 register* 이름 조건에서 빠집니다.

첫 메서드에 RegisterCommand의 하위 타입 객체를 넘겨도 선언은 그대로이므로 이 execution 판별은 바뀌지 않습니다. 선언 자체를 하위 타입으로 바꾼 별도 메서드까지 포함하려면 + 패턴과 새 픽스처로 검증해야 합니다.

3. 반환형과 인자 하나만 바꾸면

PointcutMatchDemo의 표현식은 그대로 두고, 별도 변형에서 register(String)의 반환형만 Long으로 바꾸면 register의 일치 결과는 어떻게 되나요? 다른 변형에서 반환형은 long으로 두고 메서드를 register(long)로 바꾸면 어떻게 되나요? 같은 클래스를 ExecutionExpressions.APPLICATION_PUBLIC으로 검사하면 원래 register는 일치하나요?

해설 보기

반환형을 Long으로 바꾸면 원시 타입 long을 요구하는 반환형 조건이 깨져 거짓입니다. register(long)로 바꾸면 String을 요구하는 인자 조건이 깨져 역시 거짓입니다. 두 경우 모두 런타임에 어떤 값이 오가는지와 관계없이, Method의 선언만 보고 판별합니다.

APPLICATION_PUBLIC은 board.application 패키지와 그 하위만 고르는데, 예제 클래스는 board.pointcut에 있으므로 원래 register도 일치하지 않습니다. 같은 메서드라도 어느 상수로 검사했는지에 따라 결과가 달라지므로, 표현식과 픽스처의 패키지를 함께 기록합니다.

다음 글 런타임 포인트컷 지시자에서는 args, @annotation, this, target처럼 호출 시점의 인자나 객체 타입을 보는 지시자와, 그 비용과 바인딩 규칙을 다룹니다.