ProxyFactory 프록시는 어드바이저가 없으면 대상을 그대로 부른다

컨트롤러·서비스·리포지토리 세 계층에 로그 추적을 붙였다면, 요청 하나에 로그 추적기(요청 하나의 호출을 같은 ID로 묶어 계층마다 시작과 끝을 한 줄씩 찍는 도구)가 여섯 줄을 남겨야 한다. ProxyFactory(대상 객체를 받아 JDK 동적 프록시나 CGLIB 프록시를 만들어 주는 스프링 클래스)로 인터페이스와 구현 클래스로 만든 v1의 세 빈을 모두 프록시로 바꾼 설정에서는 리포지토리의 시작과 끝 두 줄만 찍혔다. 세 빈은 모두 프록시였지만, 어드바이저가 들어 있는 것은 리포지토리 프록시 하나뿐이었다

ProxyFactory는 어드바이저(부가 기능인 어드바이스와, 그 기능을 적용할 메서드를 거르는 포인트컷을 하나씩 묶은 것)가 하나도 없어도 프록시를 만들고, 그 프록시는 대상 객체의 메서드를 그대로 부른다. 시작 로그의 프록시 클래스 이름은 JDK 동적 프록시인지 CGLIB 프록시인지만 알려 주므로, 부가 기능이 붙었는지는 프록시마다 어드바이저 수를 세거나 요청 하나가 남긴 추적 로그 줄 수로 확인한다. 어드바이저가 여럿이면 어느 ProxyFactory에 몇 번째로 넣었는지가 실행 순서를 정한다. ProxyFactory 설정은 setFrozen(true)로 얼리지 않는 한 getProxy()로 프록시를 만든 뒤에도 바꿀 수 있어서, 나중에 넣은 어드바이저는 이미 만든 프록시에 붙는다

컨트롤러·서비스 프록시는 어드바이저 없이 만들어져 추적 로그가 두 줄이었다

v1의 설정 클래스 ProxyFactoryConfigV1이 @Bean 메서드마다 구현 객체를 만들어 ProxyFactory에 넘기고, getProxy()로 꺼낸 프록시를 빈으로 돌려준다. 이 설정 클래스는 ProxyFactory를 세 번 만들지만, 어드바이저를 넣는 addAdvisor()는 리포지토리에서만 부른다

@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
    OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
    ProxyFactory factory = new ProxyFactory(orderController);
    OrderControllerV1 proxy = (OrderControllerV1)factory.getProxy();
    log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
    return proxy;
}

// orderServiceV1()도 같은 모양이다

@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
    OrderRepositoryV1Impl orderRepository = new OrderRepositoryV1Impl();

    ProxyFactory factory = new ProxyFactory(orderRepository);
    factory.addAdvisor(getAdvisor(logTrace));
    OrderRepositoryV1 proxy = (OrderRepositoryV1)factory.getProxy();
    log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderRepository.getClass());
    return proxy;
}

private Advisor getAdvisor(LogTrace logTrace) {
    //pointcut
    NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
    pointcut.setMappedNames("request*", "order*", "save*");
    //advice
    LogTraceAdvice advice = new LogTraceAdvice(logTrace);
    return new DefaultPointcutAdvisor(pointcut, advice);
}

어드바이스 LogTraceAdvice는 MethodInterceptor(메서드 호출을 가로채는 어드바이스 인터페이스)를 구현해, 호출 앞뒤에서 로그 추적기의 begin()과 end()를 부르고 그 사이에서 invocation.proceed()로 다음 어드바이스나 대상 메서드를 부른다. 포인트컷 NameMatchMethodPointcut은 이름이 request·order·save로 시작하는 메서드에 맞는다. 이름으로만 보면 컨트롤러의 request(), 서비스의 orderItem(), 리포지토리의 save()가 모두 대상이고 noLog()만 빠진다

이 설정으로 서버를 띄우고 /proxy/v1/request?itemId=hello를 한 번 호출했다. 설정 클래스가 프록시를 만들며 찍은 줄과 그 요청이 남긴 추적 로그다

18:11:00.084 [main] ProxyFactoryConfigV1 : ProxyFactory proxy=class jdk.proxy2.$Proxy57, target=class hello.advanced.proxy.app.v1.OrderRepositoryV1Impl
18:11:00.086 [main] ProxyFactoryConfigV1 : ProxyFactory proxy=class jdk.proxy2.$Proxy58, target=class hello.advanced.proxy.app.v1.OrderServiceV1Impl
18:11:00.087 [main] ProxyFactoryConfigV1 : ProxyFactory proxy=class jdk.proxy2.$Proxy59, target=class hello.advanced.proxy.app.v1.OrderControllerV1Impl
18:11:00.849 [http-nio-18085-exec-1] ThreadLocalLogTrace : [221c2a12] OrderRepositoryV1.save()
18:11:01.854 [http-nio-18085-exec-1] ThreadLocalLogTrace : [221c2a12] OrderRepositoryV1.save(), time=1005ms

세 빈 모두 jdk.proxyN.$ProxyN 모양의 JDK 동적 프록시 클래스로 바뀌었지만, 추적 로그는 같은 ID로 묶인 OrderRepositoryV1.save()의 시작과 끝 두 줄이다. 리포지토리 줄이 찍혔으니 요청은 컨트롤러 프록시와 서비스 프록시를 지나 리포지토리까지 내려갔다. 두 프록시도 호출을 받았지만 실행할 어드바이스가 없어 대상 메서드로 넘기기만 했고, 시작 로그에는 어드바이저 없는 프록시에 대한 경고가 없다

스프링의 JDK 동적 프록시 구현인 JdkDynamicAopProxy는 호출이 들어오면 그 메서드에 적용할 어드바이스 목록을 가져오고(한 번 만든 목록은 캐시해 두고 다시 쓴다, AdvisedSupport.java — Spring Framework v7.0.9 소스), 목록이 비어 있으면 대상 메서드를 리플렉션으로 바로 부른다(JdkDynamicAopProxy.java — Spring Framework v7.0.9 소스). 인터페이스 없는 v2에 쓰는 CGLIB 프록시를 처리하는 CglibAopProxy도 목록이 비면 같은 식으로 대상을 바로 부른다(CglibAopProxy.java — Spring Framework v7.0.9 소스). 어드바이저가 없는 프록시에서는 이 목록이 늘 비어 있다. 프록시가 만들어졌다는 것은 호출이 한 단계를 더 거친다는 뜻일 뿐이고, 그 단계에서 무엇을 할지는 프록시에 든 어드바이저가 정한다

프록시 이름은 JDK와 CGLIB만 알려 주고, 어드바이저 수는 Advised로 센다

ProxyFactory는 대상 객체가 구현한 인터페이스를 모두 프록시하고(ProxyFactory — Spring Framework 7 API), 이때는 JDK 동적 프록시(인터페이스를 구현한 클래스를 실행 중에 만드는 JDK 기능)를 쓴다. 인터페이스가 없거나 setProxyTargetClass(true)를 주면 CGLIB(대상 클래스를 상속한 프록시 클래스를 실행 중에 만드는 라이브러리)로 만든다(DefaultAopProxyFactory — Spring Framework 7 API). 인터페이스가 있는 v1은 JDK 동적 프록시가, 인터페이스 없이 클래스만 있는 v2는 CGLIB 프록시가 된다. CGLIB 프록시 클래스 이름에는 대상 클래스 이름 뒤에 $$SpringCGLIB$$와 번호가 붙는데, Spring Framework 6.0부터 쓰는 이름 규칙이다(SpringNamingPolicy — Spring Framework 7 API)

이름으로 알 수 있는 것은 여기까지다. 앞의 시작 로그에서 어드바이저가 없는 컨트롤러·서비스 프록시와 어드바이저가 있는 리포지토리 프록시는 번호만 다른 같은 모양의 이름이었다. 어드바이저는 프록시에서 직접 꺼내 센다. ProxyFactory로 만든 프록시는 어떤 인터페이스를 구현했든 Advised로 캐스팅할 수 있고(Manipulating Advised Objects — Spring Framework 레퍼런스), getAdvisors()가 그 프록시에 적용되는 어드바이저 목록을 돌려준다. 빈을 꺼내 어드바이저 수를 세는 코드다

Object bean = applicationContext.getBean("orderControllerV1");
int advisors = (bean instanceof Advised advised) ? advised.getAdvisors().length : -1;

ProxyFactoryConfigV1과 v2용 ProxyFactoryConfigV2를 웹 서버 없이 스프링 컨텍스트에 올려, 이 방법으로 빈마다 프록시 종류와 어드바이저 수를 찍었다. ProxyFactoryConfigV2도 ProxyFactory를 세 번 만들고 addAdvisor()는 리포지토리에서만 부른다

18:10:50.889 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.orderControllerV1 result=JDK/advisors0 | target=OrderControllerV1Impl
18:10:50.889 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.orderServiceV1 result=JDK/advisors0 | target=OrderServiceV1Impl
18:10:50.889 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.orderRepositoryV1 result=JDK/advisors1 | target=OrderRepositoryV1Impl
18:10:51.957 [Test worker] Post05ConfigProbeTest : PROBE config key=V2.orderControllerV2 result=CGLIB/advisors0 | target=OrderControllerV2
18:10:51.957 [Test worker] Post05ConfigProbeTest : PROBE config key=V2.orderServiceV2 result=CGLIB/advisors0 | target=OrderServiceV2
18:10:51.957 [Test worker] Post05ConfigProbeTest : PROBE config key=V2.orderRepositoryV2 result=CGLIB/advisors1 | target=OrderRepositoryV2

result의 앞쪽이 프록시 종류, advisors 뒤의 숫자가 어드바이저 수다. v1은 셋 다 JDK, v2는 셋 다 CGLIB인데, 어드바이저는 두 설정 모두 리포지토리 프록시에만 하나 있다

컨트롤러와 서비스의 ProxyFactory에도 리포지토리와 같은 factory.addAdvisor(getAdvisor(logTrace));를 한 줄씩 넣고 같은 확인을 다시 돌렸다. v1 설정의 결과이고, 가운데는 컨트롤러의 request()를 한 번 부른 추적 로그, 끝 두 줄은 request()와 noLog()가 남긴 추적 줄 수다

18:10:55.156 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.orderControllerV1 result=JDK/advisors1 | target=OrderControllerV1Impl
18:10:55.156 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.orderServiceV1 result=JDK/advisors1 | target=OrderServiceV1Impl
18:10:55.157 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.orderRepositoryV1 result=JDK/advisors1 | target=OrderRepositoryV1Impl
18:10:55.159 [Test worker] ThreadLocalLogTrace : [0a3c25b4] OrderControllerV1.request()
18:10:55.159 [Test worker] ThreadLocalLogTrace : [0a3c25b4] |-->OrderServiceV1.orderItem()
18:10:55.159 [Test worker] ThreadLocalLogTrace : [0a3c25b4] |    |-->OrderRepositoryV1.save()
18:10:56.164 [Test worker] ThreadLocalLogTrace : [0a3c25b4] |    |<--OrderRepositoryV1.save(), time=1005ms
18:10:56.165 [Test worker] ThreadLocalLogTrace : [0a3c25b4] |<--OrderServiceV1.orderItem(), time=1006ms
18:10:56.165 [Test worker] ThreadLocalLogTrace : [0a3c25b4] OrderControllerV1.request(), time=1006ms
18:10:56.166 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.request result=traceLines6
18:10:56.166 [Test worker] Post05ConfigProbeTest : PROBE config key=V1.noLog result=traceLines0

어드바이저가 셋 다 하나씩이 되자 같은 ID로 묶인 여섯 줄이 찍혔다. 화살표 앞에 붙는 |의 개수가 호출 깊이라, 컨트롤러에서 서비스, 리포지토리로 들어갔다가 거꾸로 나오는 모양이 보인다. 포인트컷이 거르는 noLog()는 추적 줄이 없다(traceLines0). v2 설정도 셋 다 어드바이저가 하나씩이 되고 여섯 줄이 찍혔다

서버를 띄워 v1·v2에 같은 요청을 보낸 결과는 실험 요약에 이렇게 남았다. 각 줄은 조건 이름, 요청, 상태 코드, 응답 앞부분 다음에 그 요청이 남긴 추적 로그 줄 수(trace), 추적 ID 개수(ids), 추적된 메서드(calls)가 온다. logLines는 서버 로그에서 그 요청 구간의 줄 번호, [OK]는 실험 스크립트에 미리 적어 둔 예상과 같다는 표시다

v1-as-is GET /proxy/v1/request?itemId=hello -> 200 ok trace=2 ids=1 calls=OrderRepositoryV1.save() logLines=22-26 [OK]
v1-all-advisors GET /proxy/v1/request?itemId=hello -> 200 ok trace=6 ids=1 calls=OrderControllerV1.request()>OrderServiceV1.orderItem()>OrderRepositoryV1.save() logLines=22-30 [OK]
v2-as-is GET /proxy/v2/request?itemId=hello -> 200 ok trace=2 ids=1 calls=OrderRepositoryV2.save() logLines=22-26 [OK]
v2-all-advisors GET /proxy/v2/request?itemId=hello -> 200 ok trace=6 ids=1 calls=OrderControllerV2.request()>OrderServiceV2.orderItem()>OrderRepositoryV2.save() logLines=22-30 [OK]

-as-is는 설정 그대로, -all-advisors는 컨트롤러·서비스에도 어드바이저를 넣은 조건이다. 네 요청 모두 200이고, 어드바이저를 넣자 trace가 2에서 6으로 늘며 calls에 컨트롤러와 서비스가 들어온다

추적 줄이 기대보다 적을 때 확인할 것은 두 가지다. 이 로그 추적기는 계층마다 시작과 끝을 한 줄씩 찍으므로, 포인트컷에 맞는 메서드를 부르는 요청이라면 추적 줄 수가 프록시를 끼운 계층 수의 두 배인지 센다. 그리고 프록시마다 getAdvisors()가 비어 있지 않은지 꺼내 본다. 시작 로그의 클래스 이름은 어드바이저가 있든 없든 같은 모양이라 이 판단에 쓸 수 없다

어드바이저 순서는 어느 ProxyFactory에 몇 번째로 넣었는지가 정한다

한 대상에 어드바이저 둘을 적용하는 첫 방법은 프록시를 겹치는 것이다. 대상을 감싼 proxy1에 advisor1을 넣고, proxy1을 다시 감싼 proxy2에 advisor2를 넣는다. 강의는 클라이언트가 바깥 proxy2를 부르므로 advice2가 먼저, advice1이 나중에 불린다고 설명한다. 프록시를 하나만 만들고 어드바이저를 여럿 넣으면 넣은 순서대로 불린다. addAdvisor()는 어드바이저를 어드바이저 체인(호출마다 차례로 실행할 어드바이저 목록)의 끝에 붙이고, 위치를 받는 addAdvisor(int, Advisor)는 그 자리에 넣는다(Advised — Spring Framework 7 API)

겹친 프록시를 만드는 테스트 multiAdvisorTest1은 두 번째 어드바이저를 proxyFactory2가 아니라 proxyFactory1에 넣는다. Pointcut.TRUE는 모든 메서드에 맞는 포인트컷이다

//프록시1 생성
ServiceImpl target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy1 = (ServiceInterface)proxyFactory1.getProxy();

//프록시2 생성, target -> proxy1 입력
ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
proxyFactory1.addAdvisor(advisor2);
ServiceInterface proxy2 = (ServiceInterface)proxyFactory2.getProxy();

//실행
proxy2.save();

이 테스트를 실행한 출력이다. 각 어드바이스는 불릴 때 자기 이름을 찍고, 마지막 줄은 대상 ServiceImpl이 찍었다

18:10:42.808 [Test worker] MultiAdvisorTest$Advice1 : advice1 호춣
18:10:42.810 [Test worker] MultiAdvisorTest$Advice2 : advice2 호춣
18:10:42.810 [Test worker] ServiceImpl : save 호출

advice1이 먼저 불렸다. 이 코드는 advisor2를 proxyFactory1에 넣어서, proxy2에 든 advisor2가 먼저 불린다는 강의 설명과 순서가 반대로 나왔다. 테스트는 순서를 단언하지 않아 그대로 통과한다. proxyFactory1.addAdvisor(advisor2); 줄의 proxyFactory1만 proxyFactory2로 바꿔 다시 실행한 출력이다

18:10:46.271 [Test worker] MultiAdvisorTest$Advice2 : advice2 호춣
18:10:46.274 [Test worker] MultiAdvisorTest$Advice1 : advice1 호춣
18:10:46.274 [Test worker] ServiceImpl : save 호출

이번에는 advice2, advice1 순이다. 바뀐 것은 advisor2가 들어간 프록시다. 원래 코드는 advisor2를 proxy1을 만든 proxyFactory1에 넣고, ProxyFactory 설정은 getProxy() 뒤에도 살아 있어서 advisor2가 이미 만든 proxy1의 체인 끝에 붙는다. 레퍼런스는 기본적으로 프록시를 만든 뒤에도 어드바이저나 인터셉터를 더하거나 뺄 수 있다고 적는다(Manipulating Advised Objects — Spring Framework 레퍼런스). proxyFactory2에는 어드바이저가 없으니 proxy2는 proxy1을 그대로 부르고, proxy1은 넣은 순서대로 advice1, advice2를 부른다. 앞 절의 컨트롤러·서비스 프록시처럼 어드바이저 없는 프록시가 여기에도 하나 생긴 셈이다

같은 모양을 옮긴 확인 테스트로 프록시마다 어드바이저 수를 세고, 이미 만든 프록시의 설정을 바꿔 봤다. 대상이 인터페이스를 구현한 ServiceImpl이라 프록시는 모두 JDK 동적 프록시다. 각 줄의 result가 결과, expect가 테스트에 미리 적어 둔 예상이고, | 뒤는 함께 찍은 값이다

18:10:48.673 [Test worker] Post05ProbeTest : PROBE stack case=repo-form result=advice1>advice2 expect=advice1>advice2 | outerAdvisors=0 innerAdvisors=2
18:10:48.673 [Test worker] Post05ProbeTest : PROBE order case=add(advice2),add(advice1) result=advice2>advice1 expect=advice2>advice1
18:10:48.673 [Test worker] Post05ProbeTest : PROBE order case=add(0,advice0)-after-getProxy result=advice0>advice2>advice1 expect=advice0>advice2>advice1 | 같은 프록시 객체를 다시 호출
18:10:48.693 [Test worker] Post05ProbeTest : PROBE live case=addAdvisor-after-getProxy result=before0,after1 expect=before0,after1 | 같은 프록시 객체에서 어드바이스 호출 수
18:10:48.695 [Test worker] Post05ProbeTest : PROBE frozen case=addAdvisor-on-frozen result=AopConfigException expect=AopConfigException | message=Cannot add advisor: Configuration is frozen.

줄마다 확인한 것은 다음과 같다

  • 첫 줄은 multiAdvisorTest1과 같은 모양이다. 바깥 proxy2의 어드바이저는 0개(outerAdvisors=0), 안쪽 proxy1은 2개(innerAdvisors=2)다
  • 둘째 줄은 프록시 하나에 advice2, advice1 순으로 넣은 경우로, 넣은 순서대로 불린다
  • 셋째 줄은 그 프록시를 만든 뒤 addAdvisor(0, …)로 맨 앞에 하나 더 넣고 같은 프록시 객체를 다시 부른 결과다. 새로 넣은 advice0이 다음 호출부터 맨 앞에서 불린다
  • 넷째 줄은 어드바이저 없이 만든 프록시를 한 번 부른 뒤 어드바이저를 넣은 경우다. 같은 객체에서 어드바이스 호출이 0번에서 1번이 됐다
  • 다섯째 줄은 setFrozen(true)로 얼린 설정에 어드바이저를 넣은 경우로, AopConfigException이 난다

어드바이저 순서를 확인할 때는 addAdvisor()를 부른 차례와 함께 어느 팩토리 변수에 넣었는지, getProxy() 앞인지 뒤인지를 본다. 순서가 결과를 바꾸는 어드바이저라면 테스트에서 호출 순서를 단언해 두는 편이 낫다. 만든 뒤 바뀌면 안 되는 설정은 setFrozen(true)로 얼릴 수 있고, 레퍼런스도 호출하는 코드가 보안 인터셉터를 빼지 못하게 막는 경우를 얼리기의 쓰임으로 든다

프록시로 감싸도 컨트롤러 인식 규칙은 그대로다

Spring Framework 6.0부터 타입에 붙은 @RequestMapping만으로는 컨트롤러로 인식되지 않는다는 것과, Boot 4.1에서 강의 코드를 돌리는 두 길은 앞서 쓴 @RequestMapping 글에서 다뤘다. 6.0 릴리스 노트는 이 변경으로 웹 컨트롤러에 건 인터페이스 기반 AOP 프록시가 동작하지 않을 수 있다며, 클래스 기반 프록시를 쓰거나 인터페이스에도 @Controller를 붙이라고 안내한다(Spring Framework 6.0 Release Notes). ProxyFactoryConfigV1·V2에는 그 글의 두 번째 길, 곧 isHandler()가 v1·v2 타입도 컨트롤러로 보게 하는 WebMvcRegistrations 빈이 하나씩 들어 있어서, @Controller 없이도 지금까지의 요청이 200이었다

릴리스 노트가 먼저 권하는 클래스 기반 프록시는 v2가 이미 그렇다. 세 곳 모두 어드바이저를 넣은 상태에서 WebMvcRegistrations 빈을 빼고 띄운 뒤, 이어서 @Controller를 붙여 다시 띄웠다. v1은 인터페이스에, v2는 v1 인터페이스와 v2 클래스에 붙였다. 줄의 형식은 앞의 요청 줄과 같다

v1-no-registrations GET /proxy/v1/request?itemId=hello -> 404 {"timestamp":"2026-11-10T09:11:11.410Z", trace=0 ids=0 calls=- logLines=22-24 [OK]
v1-controller-on-interface GET /proxy/v1/request?itemId=hello -> 200 ok trace=6 ids=1 calls=OrderControllerV1.request()>OrderServiceV1.orderItem()>OrderRepositoryV1.save() logLines=22-30 [OK]
v2-no-registrations GET /proxy/v2/request?itemId=hello -> 404 {"timestamp":"2026-11-10T09:11:40.131Z", trace=0 ids=0 calls=- logLines=22-24 [OK]
v2-controller-on-both GET /proxy/v2/request?itemId=hello -> 200 ok trace=6 ids=1 calls=OrderControllerV2.request()>OrderServiceV2.orderItem()>OrderRepositoryV2.save() logLines=22-30 [OK]

JDK 동적 프록시인 v1은 앞 편에서 본 것처럼 이 빈을 빼면 404이고, 인터페이스에 @Controller를 붙이면 200이다. v2는 클래스 기반(CGLIB) 프록시인데도 이 빈을 빼자 서버는 정상으로 뜨고 요청은 404였다. 클래스에 @Controller를 붙이자 200이 되고 추적도 여섯 줄 그대로다. 클래스 기반 프록시로 바꾸라는 릴리스 노트의 안내는 클래스에 @Controller가 붙어 있는 경우를 말한 것으로 읽힌다. 프록시가 어느 방식이든 컨트롤러로 인식되려면 @Controller(v1은 인터페이스, v2는 클래스에)나 isHandler() 확장 중 하나가 있어야 했다

v2 클래스에 @Controller를 붙인 채 ProxyFactoryConfigV1만 불러오면 서버가 뜨지 않는다. v1 인터페이스에 @Controller를 붙인 조건(v1-controller-on-interface)에 v2 클래스의 @Controller만 더해 띄운 시작 로그의 원인이다

Parameter 0 of constructor in hello.advanced.proxy.app.v2.OrderControllerV2 required a bean of type 'hello.advanced.proxy.app.v2.OrderServiceV2' that could not be found.

애플리케이션의 컴포넌트 스캔 범위가 v1·v2와 컴포넌트 스캔용 v3 패키지를 모두 덮으므로 @Controller가 붙은 v2 클래스가 빈으로 올라오는데, ProxyFactoryConfigV1만 불러온 상태에서는 v2 서비스 빈이 없어 생성자를 채우지 못했다. v1만 등록하는 설정을 불러올 때 생기는 같은 실패와, 앱의 스캔 범위를 v3 패키지로 좁혀 피하는 방법은 그 @RequestMapping 글에 덧붙인 보완(2026년 11월 10일)에서 다뤘다

대상마다 ProxyFactory를 쓰는 설정은 어드바이저를 빠뜨려도 알려 주지 않는다

ProxyFactoryConfigV1·V2는 대상 하나마다 ProxyFactory를 만들고, 어드바이저를 넣고, 프록시를 꺼내는 코드를 따로 쓴다. 그중 한 곳에서 addAdvisor()가 빠져도 프록시는 만들어지고 시작 로그에 경고가 없어서, 첫 절처럼 추적 줄 수를 세거나 어드바이저 수를 꺼내 봐야 드러난다. 대상이 100개면 이 코드도 100번 써야 한다. 컴포넌트 스캔으로 올라가는 v3는 설정 클래스가 객체를 만들지 않으니, 이 방식으로는 프록시를 끼울 자리가 아예 없다. 다음 편에서는 스프링이 빈 객체를 만든 뒤 등록하기 전에 다른 객체로 바꿔 끼우는 빈 후처리기로 프록시를 붙인다


출처와 범위

인프런 김영한님의 「스프링 핵심 원리 – 고급편」 강의를 듣고, 강의를 따라 만든 예제를 Spring Boot 4.1.1(Spring Framework 7.0.9, Tomcat 11.0.24)과 Java 25로 옮겨 실행하며 썼다. 서버 실험은 조건마다 코드를 바꿔 서버를 새로 띄우고 요청을 한 번씩 보냈다. 어드바이저 수는 설정 클래스를 웹 서버 없이 스프링 컨텍스트에 올려 셌고, 어드바이저 순서는 테스트를 한 번씩 실행해 확인했다. 2026년 11월 10일 기준 공식 문서와 스프링 소스 대조, 비교 실험 스크립트와 확인 테스트 작성, 원고 정리에 AI(Claude)를 썼다

참고 자료