ProxyFactory 구성
ProxyFactory가 포인트컷과 어드바이스로 인터셉터 체인을 만드는 과정을 실행하고 적용 조건과 여러 어드바이저의 순서를 설계합니다.
JDK 핸들러 하나에 메서드를 고르는 코드와 부가 기능 코드를 함께 쓰면 둘 다 재사용하기 어렵습니다. Spring AOP는 어디에 적용할지를 포인트컷(pointcut)으로, 무엇을 할지를 어드바이스(advice)로 나누고, 둘을 어드바이저(advisor)로 묶습니다.
ProxyFactory는 대상이 구현한 인터페이스와 설정을 보고 프록시를 만들며, 호출이 들어오면 그 메서드에 맞는 어드바이저 체인을 실행합니다. 이 글에서는 이름 기반 포인트컷 하나로 어드바이저를 조립해 메서드마다 체인이 어떻게 달라지는지 확인하고, 여러 어드바이저의 순서를 정하는 방법을 살펴봅니다.
ProxyFactory 구성
pointcut 일치 시 Advice 1/Target 1, 불일치 시 Advice 0/Target 1인 실제 호출 횟수 변화가 있다.
POINTCUT · PER-METHOD CHAIN + TARGET
메서드별 체인이 달라도 대상에는 도달할 수 있다
Pointcut은 적용 위치, Advice는 동작, Advisor는 둘의 결합입니다. 이 교재의 관찰 advice는 proceed를 한 번 호출하고 register에만 일치합니다.
- register · 일치
관찰 advice가 proceed를 한 번 호출합니다. advice 1회·target 1회입니다.
- find · 불일치
이 관찰 advice를 생략하지만 대상은 실행됩니다. advice 0회·target 1회입니다.
Pointcut · class ∩ method
클래스 필터와 메서드 매처의 교집합입니다. 예제 startsWith("board")는 문자열 접두 비교이므로 정확한 패키지 경계가 필요하면 추가로 좁힙니다.
proceed와 복수 Advisor
일반 advice는 0회로 차단하거나 여러 번 호출할 수 있습니다. register/find·Object 메서드·다른 패키지의 적용 수를 확인합니다. retry/transaction 순서는 시도별 물리 경계 요구에 맞게 정합니다.
프록시 생성 여부만으로 적용 범위를 증명하지 않습니다. advisor 수·순서와 메서드별 advice/target 개수를 함께 확인합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원래 AdvisorDemo의 관찰 advice는 proceed를 한 번 호출한다. register 일치는 Advice 1/Target 1이며 find 불일치는 이 Advice 0/Target 1이다. 두 입력은 각각의 호출 경로이며 find의 대상이 생략되지 않는다. 일반적인 around Advice가 proceed를 0번 또는 여러 번 호출하는 계약은 다른 조건이다.
- register는 pointcut과 일치해 advice 한 번을 거쳐 target 한 번 실행된다. find는 pointcut 불일치로 이 advice를 생략하지만 target 한 번 실행된다. 일반 around advice의 proceed 횟수는 별도 계약이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | ProxyFactory 구성 |
| 6 | 원래 AdvisorDemo의 관찰 advice는 proceed를 한 번 호출한다. register 일치는 Advice 1/Target 1이며 find 불일치는 이 Advice 0/Target 1이다. 두 입력은 각각의 호출 경로이며 find의 대상이 생략되지 않는다. 일반적인 around Advice가 proceed를 0번 또는 여러 번 호출하는 계약은 다른 조건이다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 두 독립 입력 register와 find 호출을 각 비교 슬롯에 둡니다. | register: pointcut 일치 · find: 불일치 | 같은 proxy의 선택 조건이 달라집니다. |
| advice 선택 register만 이 advice를 실행하고 find도 target은 호출합니다. | register: Advice 1 · find: Advice 0 | 일치 여부가 advice 수만 바꿉니다. |
| target 결과 관찰 advice가 proceed를 한 번 호출하는 조건에서 둘 다 target 한 번입니다. | register: Target 1 · find: Target 1 | 불일치가 target 생략을 뜻하지 않음을 확인합니다. |
ProxyFactory 구성
pointcut 일치 시 Advice 1/Target 1, 불일치 시 Advice 0/Target 1인 실제 호출 횟수 변화가 있다.
POINTCUT · PER-METHOD CHAIN + TARGET
메서드별 체인이 달라도 대상에는 도달할 수 있다
Pointcut은 적용 위치, Advice는 동작, Advisor는 둘의 결합입니다. 이 교재의 관찰 advice는 proceed를 한 번 호출하고 register에만 일치합니다.
- register · 일치
관찰 advice가 proceed를 한 번 호출합니다. advice 1회·target 1회입니다.
- find · 불일치
이 관찰 advice를 생략하지만 대상은 실행됩니다. advice 0회·target 1회입니다.
Pointcut · class ∩ method
클래스 필터와 메서드 매처의 교집합입니다. 예제 startsWith("board")는 문자열 접두 비교이므로 정확한 패키지 경계가 필요하면 추가로 좁힙니다.
proceed와 복수 Advisor
일반 advice는 0회로 차단하거나 여러 번 호출할 수 있습니다. register/find·Object 메서드·다른 패키지의 적용 수를 확인합니다. retry/transaction 순서는 시도별 물리 경계 요구에 맞게 정합니다.
프록시 생성 여부만으로 적용 범위를 증명하지 않습니다. advisor 수·순서와 메서드별 advice/target 개수를 함께 확인합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원래 AdvisorDemo의 관찰 advice는 proceed를 한 번 호출한다. register 일치는 Advice 1/Target 1이며 find 불일치는 이 Advice 0/Target 1이다. 두 입력은 각각의 호출 경로이며 find의 대상이 생략되지 않는다. 일반적인 around Advice가 proceed를 0번 또는 여러 번 호출하는 계약은 다른 조건이다.
- register는 pointcut과 일치해 advice 한 번을 거쳐 target 한 번 실행된다. find는 pointcut 불일치로 이 advice를 생략하지만 target 한 번 실행된다. 일반 around advice의 proceed 횟수는 별도 계약이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | ProxyFactory 구성 |
| 6 | 원래 AdvisorDemo의 관찰 advice는 proceed를 한 번 호출한다. register 일치는 Advice 1/Target 1이며 find 불일치는 이 Advice 0/Target 1이다. 두 입력은 각각의 호출 경로이며 find의 대상이 생략되지 않는다. 일반적인 around Advice가 proceed를 0번 또는 여러 번 호출하는 계약은 다른 조건이다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 두 독립 입력 register와 find 호출을 각 비교 슬롯에 둡니다. | register: pointcut 일치 · find: 불일치 | 같은 proxy의 선택 조건이 달라집니다. |
| advice 선택 register만 이 advice를 실행하고 find도 target은 호출합니다. | register: Advice 1 · find: Advice 0 | 일치 여부가 advice 수만 바꿉니다. |
| target 결과 관찰 advice가 proceed를 한 번 호출하는 조건에서 둘 다 target 한 번입니다. | register: Target 1 · find: Target 1 | 불일치가 target 생략을 뜻하지 않음을 확인합니다. |
그림은 예제의 register와 find 호출을 나란히 놓은 두 경로입니다. register는 포인트컷과 일치해 어드바이스를 한 번 거친 뒤, 어드바이스가 proceed()를 한 번 호출해 대상이 한 번 실행됩니다. find는 포인트컷과 맞지 않아 이 어드바이스가 0회이지만, 대상은 생략되지 않고 한 번 실행됩니다. 일반적인 주변 어드바이스는 proceed()를 0회 또는 여러 번 호출할 수도 있으며, 그 경우는 아래 절에서 따로 다룹니다.
포인트컷의 교집합
포인트컷은 클래스 필터(class filter)와 메서드 매처(method matcher)의 교집합입니다. 클래스 필터가 거절하면 메서드 매처는 볼 필요가 없습니다. 정적 매처는 Method와 대상 클래스만 쓰므로 프록시를 만들 때 체인을 캐시하기 좋습니다. 인자 값까지 보는 런타임 매처는 호출마다 비용이 들고, 적용 여부가 데이터에 따라 달라집니다.
메서드 이름의 접두사만으로 save를 고르면 다른 모듈의 같은 이름 메서드에도 적용될 수 있습니다. 패키지, 애노테이션, 타입, 시그니처를 조합해 의도한 범위로 좁힙니다. 업무 파라미터의 값으로 인가를 구현해야 한다면 포인트컷보다 별도의 보안 인터셉터가 더 명시적입니다.
package board.advisor;
import java.lang.reflect.Method;
import org.springframework.aop.ClassFilter;
import org.springframework.aop.MethodMatcher;
import org.springframework.aop.Pointcut;
public final class BoardPointcut implements Pointcut {
@Override
public ClassFilter getClassFilter() {
return type -> type.getPackageName().startsWith("board");
}
@Override
public MethodMatcher getMethodMatcher() {
return new MethodMatcher() {
@Override
public boolean matches(Method method, Class<?> targetClass) {
return method.getName().startsWith("register");
}
@Override
public boolean isRuntime() {
return false;
}
@Override
public boolean matches(
Method method,
Class<?> targetClass,
Object[] arguments
) {
return matches(method, targetClass);
}
};
}
}포인트컷 객체를 상태 없이 만들면 여러 어드바이저에서 안전하게 재사용할 수 있습니다. 패키지를 옮기거나 메서드 이름을 바꾸면 적용 범위도 바뀌므로, 포인트컷 테스트를 아키텍처 테스트처럼 함께 유지합니다.
이 클래스 필터는 패키지 이름을 문자열 접두사로 검사한다는 점에 주의합니다. board.command뿐 아니라 boardwalk.command도 startsWith("board")가 참입니다. 실제 board 패키지와 그 하위 패키지만 원한다면, 패키지 이름이 board와 같거나 board.으로 시작하는지를 나눠 검사해야 합니다. 이 계산은 연습 문제에서 해 봅니다.
MethodInterceptor의 흐름
주변 어드바이스(around advice)는 MethodInvocation.proceed()를 호출해야 다음 인터셉터나 대상으로 넘어갑니다. 호출하지 않으면 접근 거부나 캐시 적중처럼 체인이 그 자리에서 끝납니다. 두 번 호출하면 대상도 두 번 실행될 수 있으므로, 재시도를 의도한 경우가 아니라면 금지합니다.
package board.advisor;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
public final class AdvisorDemo {
public interface Command {
long register(String title);
long find(long id);
}
static final class Target implements Command {
@Override
public long register(String title) {
return 41L;
}
@Override
public long find(long id) {
return id;
}
}
public static void main(String[] args) {
MethodInterceptor advice = invocation -> {
System.out.println("advised=" + invocation.getMethod().getName());
return invocation.proceed();
};
var advisor = new DefaultPointcutAdvisor(new BoardPointcut(), advice);
var factory = new ProxyFactory(new Target());
factory.addAdvisor(advisor);
Command proxy = (Command) factory.getProxy();
System.out.println("created=" + proxy.register("advisor"));
System.out.println("found=" + proxy.find(7L));
}
}new ProxyFactory(new Target())는 대상이 구현한 Command 인터페이스를 프록시하므로 JDK 프록시가 만들어집니다. 실행하면 register에서만 어드바이스 메시지가 나옵니다.
advised=register
created=41
found=7find는 대상에 도달하지만 포인트컷과 맞지 않아 어드바이스 메시지가 없습니다. 프록시 자체는 이 객체의 모든 인터페이스 호출을 받되, 메서드마다 체인이 달라집니다. 결과를 세어 보면 register는 어드바이스 1회와 대상 1회, find는 어드바이스 0회와 대상 1회입니다. 이렇게 호출 수를 세는 테스트는 프록시가 생성되었는지만 보는 테스트보다 잘못된 메서드 일치를 직접 드러냅니다.
여러 어드바이저의 순서
트랜잭션, 인가, 재시도, 메트릭 어드바이저가 함께 적용되면 순서가 동작을 바꿉니다. 재시도가 트랜잭션 바깥에 있으면 시도마다 새 트랜잭션을 만들 수 있고, 안쪽에 있으면 이미 롤백 전용으로 표시된 트랜잭션 안에서 재시도를 반복할 수 있습니다. 인가는 비용이 큰 작업보다 먼저 실행되어야 합니다.
Ordered나 어드바이저 등록 순서로 순서를 명시하고, 체인을 확인하는 테스트를 둡니다. ProxyFactory에 addAdvisor로 직접 추가할 때는 등록한 위치가 곧 순서입니다. Advised API의 위치 지정 추가와 getAdvisors를 함께 확인하고, 어드바이저에 order 숫자가 있다는 사실만으로 이 수동 팩토리가 자동 프록시 생성기처럼 정렬까지 해 준다고 가정하지 않습니다.
여러 모듈이 순서 숫자를 제각각 정하면 충돌하므로, 의미 있는 상수 범위를 정해 관리합니다. 같은 순서 값을 가진 어드바이저끼리의 상대 순서에는 의존하지 않습니다.
호출 전, 정상 반환 후, 예외 후 어드바이스는 표현이 간결하지만, 가장 일반적인 형태는 주변 어드바이스입니다. 반환과 예외를 모두 다루는 메트릭에는 try/catch/finally가 코드에 드러나는 MethodInterceptor가 어울립니다.
ProxyFactory 옵션
인터페이스가 있으면 기본적으로 JDK 프록시를 만들고, proxyTargetClass를 켜면 클래스 프록시를 강제할 수 있습니다. optimize, opaque, exposeProxy, 고정 구성 같은 옵션은 필요한 이유가 없다면 기본값을 유지합니다. exposeProxy는 자기 호출을 우회하는 방법을 열어 주지만, 애플리케이션 코드를 AOP API에 묶습니다.
대상 소스(target source)를 바꾸는 고급 프록시로 요청별 대상이나 풀링을 구현할 수도 있지만, 싱글톤 생명 주기와 상태, 스레드 안전성이 복잡해집니다. 일반 서비스는 고정된 싱글톤 대상을 씁니다. 프록시를 직렬화하거나 구체 구현 클래스로 찾지 않으며, 진단할 때는 어드바이저 목록과 대상 클래스를 Spring API로 확인합니다.
더 깊이: ProxyFactory가 방식을 고르는 실제 규칙
new ProxyFactory(target)은 생성될 때 대상이 구현한 모든 인터페이스를 모아 프록시 인터페이스로 등록합니다. 그다음 Spring Framework 7.0.10의 DefaultAopProxyFactory.createAopProxy가 방식을 정하는데, optimize나 proxyTargetClass가 켜져 있거나 사용자가 준 인터페이스가 없으면 클래스 프록시를 후보로 봅니다. 여기서 SpringProxy 같은 표시용 인터페이스나 어드바이저가 도입한 인터페이스는 사용자가 준 것으로 세지 않습니다. 후보가 클래스 프록시라도 대상 클래스가 인터페이스 자체이거나, 이미 JDK 프록시 클래스이거나, 람다 클래스라면 하위 클래스를 만들 수 없으므로 JDK 프록시로 돌아갑니다. 그래서 Command를 구현한 람다를 대상으로 넣으면 setProxyTargetClass(true)를 지정해도 JDK 프록시가 만들어집니다.
메서드마다 어떤 인터셉터를 거칠지도 호출할 때마다 새로 계산하지 않습니다. 프록시 설정을 담는 AdvisedSupport는 메서드별 인터셉터 목록을 캐시에 두고, 같은 메서드가 다시 불리면 그 목록을 그대로 씁니다. 어드바이저를 추가하거나 빼면 캐시를 비우고 다음 호출에서 다시 계산합니다. 그러니 이 예제처럼 인자를 보지 않는 포인트컷에서 register와 find의 체인이 다른 것은, 호출마다 포인트컷을 다시 평가해서가 아니라 메서드별로 한 번 계산해 둔 목록이 다르기 때문입니다.
포인트컷 테스트와 동작 테스트
포인트컷 테스트는 대표 Method 객체를 가져와 register는 참, find와 다른 패키지의 클래스는 거짓인지 빠르게 검사합니다. 동작 테스트는 프록시를 실제로 호출해 어드바이스 수, 대상 수, 예외 동일성, 순서를 확인합니다.
메서드를 추가했을 때 포인트컷이 너무 넓게 일치하는 것도 회귀입니다. 일치하지 않아야 하는 부정 픽스처(negative fixture)를 충분히 둡니다. 컴파일러가 만든 브리지 메서드(bridge method)나 합성 메서드가 보이면, 가장 구체적인 메서드를 찾는 일은 Spring 유틸리티에 맡깁니다.
정리
- 포인트컷은 적용 위치, 어드바이스는 동작이며, 어드바이저가 둘을 묶어
ProxyFactory에 등록됩니다. - 포인트컷은 클래스 필터와 메서드 매처의 교집합이고, 맞지 않는 메서드도 대상까지는 그대로 호출됩니다.
- 주변 어드바이스는
proceed()를 한 번 호출하는 것이 기본이며, 0회는 체인 종료, 2회 이상은 대상의 중복 실행입니다. - 여러 어드바이저는 순서가 동작을 바꾸므로 순서를 명시하고, 메서드별 어드바이스·대상 호출 수로 테스트합니다.
연습 문제
1. @Measured 메서드만 고르는 포인트컷
@Measured가 붙은 공개 애플리케이션 메서드만 고르는 포인트컷과 타이머 어드바이스를 만드세요. 객체 메서드와 애노테이션이 없는 메서드는 제외하고, 실패한 호출에서도 지속 시간이 한 번 기록되며 원래 예외가 유지되는지 테스트하세요.
해설 보기
애노테이션을 찾을 때는 인터페이스 메서드와 구현 메서드 가운데 어디에 붙었는지를 고려해야 합니다. 구체 클래스를 기준으로 한 간단한 예는 다음과 같습니다.
package board.advisor;
import java.lang.reflect.Method;
import org.springframework.aop.support.StaticMethodMatcherPointcut;
import org.springframework.core.annotation.AnnotatedElementUtils;
public final class MeasuredPointcut extends StaticMethodMatcherPointcut {
@Override
public boolean matches(Method method, Class<?> targetClass) {
try {
Method specific = targetClass.getMethod(
method.getName(), method.getParameterTypes());
return AnnotatedElementUtils.hasAnnotation(specific, Measured.class);
} catch (NoSuchMethodException failure) {
return false;
}
}
public @interface Measured { }
}이 블록의 최소 Measured 선언을 그대로 쓰면 기본 유지 정책(retention)이 CLASS여서 런타임에 애노테이션을 찾을 수 없습니다. 유지 정책별 차이는 Java 교재의 메타 애노테이션 @Retention·@Target·@Inherited·@Repeatable에서 다룹니다. MeasuredPointcut 안의 선언을 다음처럼 보완해야 합니다.
@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
@java.lang.annotation.Target(java.lang.annotation.ElementType.METHOD)
public @interface Measured { }또한 이 MeasuredPointcut은 targetClass의 공개 메서드에서 애노테이션을 찾을 뿐, 애플리케이션 패키지 제한이나 객체 메서드 제외는 구현하지 않았습니다. 애노테이션을 붙인 toString 재정의도 그대로는 선택될 수 있습니다. 연습의 조건을 채우려면 클래스 필터를 원하는 패키지로 좁히고, equals(Object), hashCode(), toString()의 시그니처를 명시적으로 제외합니다. 이름이 같을 뿐 인자를 받는 업무 메서드가 함께 빠지지 않도록 이름과 매개변수 목록을 함께 대조합니다.
타이머 어드바이스는 시작 값을 읽고, try에서 proceed()를 한 번 호출한 뒤, finally에서 경과 시간을 한 번 기록합니다. 기록기가 실패하지 않는다면 정상 반환과 대상 실패 모두 기록은 한 번이고, 원래 결과와 실패가 그대로 전달됩니다. 기록기가 새 예외를 던지는 경우는 템플릿 메서드에서 다룬 별도의 문제입니다.
2. 접두사 비교의 교집합
BoardPointcut을 그대로 쓸 때 다음 네 메서드 가운데 어드바이스가 붙는 것을 고르세요. board.command 패키지의 registerPost, board.command 패키지의 find, boardwalk.command 패키지의 registerPost, board.audit 패키지의 registerAudit입니다. 각각 클래스 필터와 메서드 매처의 결과를 따로 적으세요.
해설 보기
board.command의 registerPost는 두 조건이 모두 참이어서 붙습니다. board.command의 find는 클래스 필터는 참이지만 메서드 매처가 거짓이라 붙지 않습니다.
boardwalk.command의 registerPost는 startsWith("board")가 참이고 메서드 이름도 맞아서 붙습니다. board.audit의 registerAudit도 두 조건이 모두 참이라 붙습니다. 뒤의 두 경우는 의도하지 않았을 가능성이 높으므로, 패키지는 board와 같거나 board.으로 시작하는지로, 메서드는 이름 접두사 대신 애노테이션이나 선언 타입으로 좁히는 편이 안전합니다.
3. 패키지와 객체 메서드 조건 추가
연습 1에서 보완한 RUNTIME 애노테이션을 쓰되, MeasuredPointcut의 matches는 그대로 둡니다. boardwalk.command에 있는 @Measured 공개 register, board.command에 있는 애노테이션 없는 find, @Measured를 붙인 공개 toString 재정의는 각각 일치하나요? 애플리케이션 패키지 조건과 객체 메서드 제외 조건을 추가하면 첫째와 셋째는 어떻게 바뀌어야 하나요?
해설 보기
지금의 matches에는 패키지 조건이 없으므로 첫째와 셋째는 참이고, 애노테이션이 없는 둘째는 거짓입니다. board와 그 하위 패키지로 제한하면 boardwalk의 첫째는 거짓이 되어야 하고, 객체 메서드 시그니처를 제외하면 셋째의 toString도 거짓이 되어야 합니다.
좋은 부정 픽스처는 프록시가 존재하는지보다, 적용 범위가 잘못 넓어진 곳을 드러냅니다. BoardPointcut의 이름 기반 규칙과 이 애노테이션 기반 규칙은 서로 다른 선택 기준이라는 점도 함께 기억합니다.
다음 글 BeanPostProcessor에서는 빈마다 ProxyFactory를 손으로 호출하지 않고, Spring 컨테이너가 빈을 만드는 시점에 대상을 프록시로 바꿔 등록하는 빈 후처리기를 다룹니다.