앞 글에서는 로그 추적을 붙이려고 컨트롤러·서비스·리포지토리마다 프록시 클래스를 하나씩 만들었다. JDK 동적 프록시를 쓰면 부가 기능은 InvocationHandler 하나에 쓰고, 프록시 클래스는 실행 중에 JDK가 만든다. 강의의 핸들러는 리플렉션(클래스와 메서드의 정보를 실행 중에 객체로 꺼내 다루는 자바 기능)의 method.invoke(target, args)로 실제 객체를 부르고, 예외가 나면 추적 로그를 남긴 뒤 받은 예외를 다시 던진다. 앞 글의 프록시와 같은 모양이라 대상의 예외가 그대로 나갈 것 같지만, itemId=ex 요청으로 리포지토리가 IllegalStateException을 던지게 하면 추적 로그 세 줄에는 InvocationTargetException이 찍히고 스프링 MVC가 받는 예외는 UndeclaredThrowableException이다
Method.invoke가 대상의 예외를 InvocationTargetException으로 감싸고, 핸들러가 그 예외를 그대로 던지면 프록시가 다시 UndeclaredThrowableException으로 감싼다. 그래서 프록시를 부르는 쪽의 catch (IllegalStateException e)는 이 예외를 잡지 못하고, IllegalStateException 처리기와 같은 @RestControllerAdvice에 Exception 처리기가 있으면 스프링 MVC는 Exception 처리기를 고른다. 핸들러에서 InvocationTargetException을 잡아 getTargetException()을 던지면 원래 예외가 그대로 나온다. 이 동작은 Boot 4.1에서 생긴 차이가 아니라 JDK 명세에 적힌 규칙이고, Boot 기본 오류 응답의 HTTP 상태는 두 경우 모두 500이라 상태 코드로는 드러나지 않는다
InvocationHandler 하나가 대상마다 만들던 프록시 클래스를 대신한다
JDK 동적 프록시는 리플렉션 위에서 돈다. Method는 메서드 하나를 나타내는 객체이고, method.invoke(target, args)는 그 메서드를 target 객체에서 args를 넘겨 실행한다. 어떤 메서드를 부를지가 코드에 박혀 있지 않고 값으로 넘어오므로, 메서드마다 다른 호출을 코드 하나로 처리할 수 있다
Proxy.newProxyInstance()는 넘겨받은 인터페이스를 구현한 프록시 클래스를 실행 중에 만들고, 그 프록시의 어느 메서드가 불리든 InvocationHandler의 invoke(proxy, method, args)로 넘긴다. 핸들러는 불린 메서드의 Method와 인수를 받아 부가 기능을 수행하고 실제 객체를 부른다. 인터페이스를 구현해 만드는 방식이라 인터페이스가 있어야 한다. 걸린 시간을 로그로 남기는 강의의 핸들러다
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
private final Object target;
public TimeInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = method.invoke(target, args);
// ... 걸린 시간을 계산해 "TimeProxy 종료 resultTime=" 로그를 남긴다
return result;
}
}
같은 TimeInvocationHandler 클래스로 AImpl(AInterface 구현체)과 BImpl(BInterface 구현체)을 각각 Proxy.newProxyInstance()로 감싼 강의 테스트에서, 실제 객체와 프록시의 클래스를 찍은 줄이다
18:08:06.715 [Test worker] JdkDynamicProxyTest : targetClass=class hello.advanced.proxy.jdkdynamic.code.AImpl 18:08:06.715 [Test worker] JdkDynamicProxyTest : proxyClass=class jdk.proxy3.$Proxy27 18:08:06.716 [Test worker] JdkDynamicProxyTest : targetClass=class hello.advanced.proxy.jdkdynamic.code.BImpl 18:08:06.716 [Test worker] JdkDynamicProxyTest : proxyClass=class jdk.proxy3.$Proxy28
프록시 클래스를 직접 만들지 않았는데 AInterface와 BInterface에 하나씩 서로 다른 클래스가 생겼고, 부가 기능은 TimeInvocationHandler 한 곳에만 있다. 프록시 클래스는 클래스 로더와 인터페이스 목록마다 하나라서, 순서까지 같은 인터페이스 목록으로 다시 만들면 이미 만든 클래스를 쓴다(지금은 deprecated된 getProxyClass()의 설명, Proxy — Java SE 25 API). 두 클래스 모두 jdk.proxy로 시작하는 패키지에 생겼는데, 강의에서는 com.sun.proxy 패키지였다. JDK 16부터 public 인터페이스의 프록시 클래스는 이름 있는 모듈의 패키지에 만들어지고(JDK 16 Release Notes), 같은 API 문서에 따르면 패키지와 클래스의 이름은 정해져 있지 않다
앱에서는 로그 추적기로 같은 일을 한다. 핸들러는 Method에서 메서드를 선언한 인터페이스 이름과 메서드 이름을 꺼내 로그 메시지를 만들므로, 감쌀 대상이 몇 개든 핸들러 클래스는 하나다
public class LogTraceBasicHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
// ... 생성자
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
TraceStatus status = null;
try {
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
status = logTrace.begin(message);
//target 호출
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
설정 클래스는 컨트롤러·서비스·리포지토리의 실제 객체를 만들 때마다 이 핸들러를 붙인 프록시로 감싸고, 그 프록시를 빈으로 등록한다
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1Impl orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1)Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[] {OrderControllerV1.class}, new LogTraceBasicHandler(orderController, logTrace));
return proxy;
}
// orderServiceV1(), orderRepositoryV1()도 같은 모양으로 감싼다
Boot 4.1에서 이 설정만으로 띄우면 /proxy/v1/request는 404다. 타입 수준 @RequestMapping만 붙은 v1 인터페이스는 Spring 6.0부터 컨트롤러로 인식되지 않고(Spring Framework 6.0 Release Notes), 앞 글에서처럼 인터페이스에 @Controller를 붙이면 프록시 빈도 컨트롤러로 잡혀 200이 나왔다. 이 글의 다른 앱 실행은 설정 클래스에 앞 글의 다른 길인 WebMvcRegistrations 빈(isHandler()를 v1·v2 타입까지 넓힌다)을 둔 상태다
method.invoke를 거친 예외는 두 번 감싸여 catch를 빠져나간다
LogTraceBasicHandler는 catch에서 받은 예외를 로그에 남기고 그대로 다시 던진다. 강의는 프록시가 흐름을 바꾸면 안 되고 예외도 그대로 던져야 한다고 말하고, 앞 글의 인터페이스 프록시도 그렇게 짰다. 그 프록시는 target.request(itemId)를 직접 불렀으므로 catch의 e가 대상이 던진 예외 자체였다. 여기서는 대상을 method.invoke로 부르므로 e가 달라진다
Method.invoke는 호출한 메서드가 예외를 던지면 그것을 InvocationTargetException에 담아 던진다(Method.invoke — Java SE 25 API). InvocationTargetException은 검사 예외(컴파일러가 throws 선언이나 catch를 요구하는 예외)인데, 프록시가 구현한 인터페이스 메서드는 이 예외를 선언하지 않았다. 핸들러가 인터페이스 메서드에 선언되지 않은 검사 예외를 던지면 프록시는 그 예외를 UndeclaredThrowableException에 담아 던진다(InvocationHandler.invoke — Java SE 25 API). UndeclaredThrowableException은 런타임 예외라서 throws 선언 없이 호출한 쪽까지 올라간다
이 규칙을 스프링 없이 확인했다. call()에서 IllegalStateException("예외 발생!")을 던지는 AInterface 구현체를 두 핸들러로 각각 감쌌다. 하나는 강의의 TimeInvocationHandler이고, 다른 하나는 InvocationTargetException을 풀어 던지는 다음 핸들러다
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
return method.invoke(target, args);
} catch (InvocationTargetException e) {
throw e.getTargetException();
}
}
두 프록시를 각각 try { proxy.call(); } catch (IllegalStateException e) { ... }로 불렀다. 이 catch에 잡히면 메시지를 찍고, 잡히지 않으면 뒤에 둔 catch (RuntimeException e)에서 받은 예외와 원인 사슬(getCause()를 따라간 순서)을 찍는다
18:08:09.778 [main] Post04Probe : CHECK 강의 TimeInvocationHandler catch (IllegalStateException)로 못 잡음, 받은 예외=java.lang.reflect.UndeclaredThrowableException 18:08:09.778 [main] Post04Probe : CHECK 강의 TimeInvocationHandler 원인 사슬=java.lang.reflect.UndeclaredThrowableException > java.lang.reflect.InvocationTargetException > java.lang.IllegalStateException 18:08:09.779 [main] Post04Probe : CHECK 풀어 던지는 핸들러 catch (IllegalStateException)로 잡힘=예외 발생!
강의 핸들러로 감싼 프록시는 catch (IllegalStateException)에 잡히지 않고 UndeclaredThrowableException을 던졌다. 원인을 따라가면 InvocationTargetException을 거쳐서야 원래의 IllegalStateException이 나온다. 풀어 던지는 핸들러로 감싼 프록시는 같은 catch에 잡혔고 메시지도 예외 발생! 그대로다. 이런 프록시를 부르는 코드가 대상의 예외 타입으로 catch한다면 스프링 MVC와 관계없이 같은 일이 생긴다. Method.invoke와 InvocationHandler의 이 규칙은 JDK 명세라 강의가 나온 2021년 환경에서도 같았을 것으로 보지만, 그 환경에서 실행해 보지는 않았다
추적 로그에는 InvocationTargetException만 남고 응답은 똑같이 500이다
앱의 v1 리포지토리는 itemId가 ex이면 IllegalStateException("예외 발생!")을 던진다. 강의 핸들러 그대로 /proxy/v1/request?itemId=ex를 호출한 추적 로그와, 이어서 톰캣이 남긴 오류 로그의 첫 두 줄이다
18:08:18.292 [http-nio-18084-exec-4] ThreadLocalLogTrace : [e2a4349a] OrderControllerV1.request() 18:08:18.293 [http-nio-18084-exec-4] ThreadLocalLogTrace : [e2a4349a] |-->OrderServiceV1.orderItem() 18:08:18.293 [http-nio-18084-exec-4] ThreadLocalLogTrace : [e2a4349a] | |-->OrderRepositoryV1.save() 18:08:18.294 [http-nio-18084-exec-4] ThreadLocalLogTrace : [e2a4349a] | |<X-OrderRepositoryV1.save(), time=1ms ex=java.lang.reflect.InvocationTargetException 18:08:18.294 [http-nio-18084-exec-4] ThreadLocalLogTrace : [e2a4349a] |<X-OrderServiceV1.orderItem(), time=1ms ex=java.lang.reflect.InvocationTargetException 18:08:18.294 [http-nio-18084-exec-4] ThreadLocalLogTrace : [e2a4349a] OrderControllerV1.request(), time=2ms ex=java.lang.reflect.InvocationTargetException 18:08:18.304 [http-nio-18084-exec-4] [dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.reflect.UndeclaredThrowableException] with root cause java.lang.IllegalStateException: 예외 발생!
추적 로그의 ex= 세 줄은 모두 메시지 없는 InvocationTargetException이고, 톰캣 로그의 Request processing failed: 뒤에 적힌 것처럼 스프링 MVC가 처리하지 못하고 내보낸 예외는 UndeclaredThrowableException이다. 원래 예외 IllegalStateException: 예외 발생!은 맨 아래 root cause(가장 안쪽 원인) 줄에만 나온다. 서비스와 컨트롤러 줄도 InvocationTargetException이다. 앞의 두 규칙을 단계마다 적용하면, 리포지토리 프록시가 던진 UndeclaredThrowableException이 서비스 구현체를 거쳐 나올 때 서비스 프록시의 핸들러에서 method.invoke가 그것을 다시 InvocationTargetException으로 감싼다. 서비스·컨트롤러 줄은 이 설명과 맞지만, 원인 사슬 전체를 찍어 보지는 않았다
LogTraceBasicHandler의 Object result = method.invoke(target, args); 한 줄만 앞의 try/catch로 바꾸고 같은 요청을 보냈다
18:08:35.349 [http-nio-18084-exec-1] ThreadLocalLogTrace : [271b3a23] OrderControllerV1.request() 18:08:35.350 [http-nio-18084-exec-1] ThreadLocalLogTrace : [271b3a23] |-->OrderServiceV1.orderItem() 18:08:35.350 [http-nio-18084-exec-1] ThreadLocalLogTrace : [271b3a23] | |-->OrderRepositoryV1.save() 18:08:35.350 [http-nio-18084-exec-1] ThreadLocalLogTrace : [271b3a23] | |<X-OrderRepositoryV1.save(), time=0ms ex=java.lang.IllegalStateException: 예외 발생! 18:08:35.350 [http-nio-18084-exec-1] ThreadLocalLogTrace : [271b3a23] |<X-OrderServiceV1.orderItem(), time=0ms ex=java.lang.IllegalStateException: 예외 발생! 18:08:35.350 [http-nio-18084-exec-1] ThreadLocalLogTrace : [271b3a23] OrderControllerV1.request(), time=1ms ex=java.lang.IllegalStateException: 예외 발생! 18:08:35.352 [http-nio-18084-exec-1] [dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: 예외 발생!] with root cause
ex= 세 줄 모두 IllegalStateException: 예외 발생!이 찍혔고, 톰캣 로그의 Request processing failed: 뒤도 그 예외다. 두 실행의 응답 상태와 응답 본문 앞부분(80바이트)이다
app-basic GET /proxy/v1/request?itemId=ex -> 500 추적 로그 6줄 응답 앞 80바이트: {"timestamp":"2026-11-10T09:08:18.316Z","status":500,"error":"Internal Server Er
app-unwrap GET /proxy/v1/request?itemId=ex -> 500 추적 로그 6줄 응답 앞 80바이트: {"timestamp":"2026-11-10T09:08:35.381Z","status":500,"error":"Internal Server Er
둘 다 500이고 Boot 기본 오류 응답이다. 상태 코드와 본문 앞부분만 확인했지만, 클라이언트가 받는 응답에서는 차이가 드러나지 않았다. 달라지는 곳은 서버 쪽의 추적 로그와, 다음 절의 예외 처리기 선택이다
Exception 처리기를 함께 두면 스프링 MVC가 고르는 처리기가 바뀐다
컨트롤러에서 나온 예외를 @RestControllerAdvice(여러 컨트롤러의 예외 처리기를 모아 두는 클래스)로 처리하는 앱이라면 감싸인 예외가 처리기(@ExceptionHandler 메서드) 선택에도 닿는다. 다음 어드바이스를 더해 같은 요청을 보냈다. 처리기가 문자열을 돌려주므로 응답 상태는 200이고, 본문으로 어느 처리기가 불렸는지 알 수 있다
@RestControllerAdvice
class Post04ExceptionAdvice {
@ExceptionHandler(IllegalStateException.class)
String illegalState(IllegalStateException e) {
return "IllegalStateException 처리: " + e.getMessage();
}
@ExceptionHandler(Exception.class)
String other(Exception e) {
return "Exception 처리: " + e.getClass().getName();
}
}
조건은 셋이고, 로그 각 줄의 맨 앞이 조건 이름이다
app-advice-both: 강의 핸들러, 두 처리기app-advice-both-unwrap: 풀어 던지는 핸들러, 두 처리기app-advice-ise-only: 강의 핸들러,IllegalStateException처리기만
app-advice-both GET /proxy/v1/request?itemId=ex -> 200 추적 로그 6줄 응답 앞 80바이트: Exception 처리: java.lang.reflect.UndeclaredThrowableException app-advice-both-unwrap GET /proxy/v1/request?itemId=ex -> 200 추적 로그 6줄 응답 앞 80바이트: IllegalStateException 처리: 예외 발생! app-advice-ise-only GET /proxy/v1/request?itemId=ex -> 200 추적 로그 6줄 응답 앞 80바이트: IllegalStateException 처리: 예외 발생!
두 처리기가 함께 있을 때 강의 핸들러에서는 Exception 처리기가 UndeclaredThrowableException을 받았고, 풀어 던지면 IllegalStateException 처리기가 불렸다. IllegalStateException 처리기만 두면 강의 핸들러에서도 그 처리기가 불렸다
스프링 MVC는 던져진 예외뿐 아니라 그 안에 감싸인 원인 예외로도 처리기를 찾는다. 다만 여러 처리기가 맞으면 던져진 예외에 맞는 처리기를 원인에 맞는 처리기보다 먼저 고르고, 이 우선순위는 한 컨트롤러나 한 @ControllerAdvice 클래스 안에서 정해진다(Exceptions — Spring Framework 레퍼런스). UndeclaredThrowableException도 Exception이므로 Exception 처리기는 던져진 예외에 맞고, IllegalStateException 처리기는 원인에만 맞는다. 풀어 던지면 던져진 예외가 IllegalStateException이라 두 처리기가 모두 던져진 예외에 맞는데, 같은 절의 설명대로 처리기는 던져진 예외 타입에서 가까운 순서(ExceptionDepthComparator)로 정렬되므로 IllegalStateException 처리기가 불린다. IllegalStateException 처리기만 있으면 맞는 처리기가 그것뿐이라 원인까지 내려가 찾는다. 그래서 처리기 선택이 바뀐 것은 같은 어드바이스에 Exception처럼 감싼 예외까지 받는 넓은 처리기가 함께 있을 때였다. 넓은 처리기를 다른 어드바이스에 두면 어드바이스 사이의 우선순위가 먼저 적용되는데, 그 경우와 Exception 대신 RuntimeException 처리기를 둔 경우는 실행하지 않았다
인터페이스가 없으면 CGLIB가 상속으로 만들고 final 메서드는 가로채지 못한다
JDK 동적 프록시는 인터페이스를 구현해 만들므로, v2처럼 인터페이스 없이 클래스만 있는 대상에는 쓸 수 없다. CGLIB는 바이트코드를 생성해 대상 클래스를 상속한 프록시 클래스를 실행 중에 만드는 라이브러리이고, 지금도 spring-core 안에 다시 패키징되어 들어 있어 의존성을 따로 더하지 않아도 된다(Proxying Mechanisms — Spring Framework 레퍼런스). InvocationHandler 자리에는 MethodInterceptor가 오고, 걸린 시간을 남기는 TimeMethodInterceptor는 다음과 같다
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = methodProxy.invoke(target, args);
// ... 걸린 시간을 계산해 "TimeProxy 종료 resultTime=" 로그를 남긴다
return result;
}
프록시는 Enhancer에 부모 클래스와 인터셉터를 지정해 만든다
ConcreteService target = new ConcreteService(); Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(ConcreteService.class); enhancer.setCallback(new TimeMethodInterceptor(target)); ConcreteService proxy = (ConcreteService)enhancer.create();
상속으로 만드는 만큼 앞 글에서 클래스를 상속해 만든 프록시와 같은 제약이 붙는다. final 클래스, 기본 생성자가 없는 클래스, final 메서드가 있는 클래스를 Enhancer로 감싸 본 결과다. final 메서드를 확인한 클래스는 필드 기본값이 기본값이고 실제 객체에는 대상을 넣었으며, 인터셉터는 불릴 때마다 횟수를 센다
18:08:09.554 [main] Post04Probe : CHECK final 클래스 프록시 생성=java.lang.IllegalArgumentException: Cannot subclass final class hello.advanced.proxy.post04.Post04Probe$FinalService 18:08:09.564 [main] Post04Probe : CHECK 기본 생성자 없음 create()=java.lang.IllegalArgumentException: Superclass has no null constructors but no arguments were given 18:08:09.579 [main] Post04Probe : CHECK 기본 생성자 없음 create(인자 지정) 뒤 call() 결과=대상 18:08:09.584 [main] Post04Probe : CHECK 일반 메서드 call() 결과=대상 18:08:09.584 [main] Post04Probe : CHECK call() 뒤 인터셉터 실행 횟수=1 18:08:09.584 [main] Post04Probe : CHECK final 메서드 finalCall() 결과=기본값 18:08:09.584 [main] Post04Probe : CHECK finalCall() 뒤 인터셉터 실행 횟수=1
final 클래스는 프록시를 만들 때 예외가 났고, 기본 생성자가 없는 클래스는 인자 없는 create()에서 예외가 났다. 강의가 말한 기본 생성자 제약은 인자 없는 create()에 해당하고, 생성자 인자를 넘기는 create(Class[], Object[])로는 만들어졌다. final 메서드는 오류 없이 실행되지만 인터셉터 횟수가 1에서 늘지 않았고, 결과도 실제 객체의 대상이 아니라 프록시 객체 자신의 필드 값인 기본값이다. 오버라이드할 수 없는 메서드라 부모 클래스의 코드가 프록시 객체 위에서 그대로 돈 것으로, 앞 글에서 손으로 상속한 프록시를 두고 설명한 경로가 CGLIB 프록시에서도 그대로 나타났다
스프링 AOP 프록시는 대상 예외를 풀어서 던진다
두 기술을 함께 쓰려면 같은 부가 기능을 InvocationHandler와 MethodInterceptor에 두 번 짜야 하고, 대상에 인터페이스가 있는지 보고 어느 쪽으로 만들지 고르는 일도 남는다. 스프링은 이 둘을 ProxyFactory로 묶는다. 스프링 AOP 프록시는 JDK 방식이든 CGLIB 방식이든 대상을 AopUtils.invokeJoinpointUsingReflection()으로 부른다. 두 방식이 함께 쓰는 ReflectiveMethodInvocation.invokeJoinpoint()가 이 메서드를 부르고(ReflectiveMethodInvocation.java — Spring Framework v7.0.9 소스), CGLIB 프록시는 붙은 어드바이스가 없을 때도 이 메서드로 대상을 바로 부른다(CglibAopProxy.java — Spring Framework v7.0.9 소스). 이 메서드는 InvocationTargetException을 잡아 원래 예외를 던지고, 그 자리의 주석은 이렇게 해야 클라이언트가 인터셉터를 보지 못한다고 적는다(AopUtils.java — Spring Framework v7.0.9 소스)
InvocationHandler를 직접 짜서 method.invoke로 대상을 부른다면, InvocationTargetException을 풀어 던지는 catch가 있는지부터 확인한다. 빠뜨려도 Boot 기본 오류 응답의 상태 코드는 같으므로, 예외 요청을 한 번 보내 추적 로그에 InvocationTargetException이 찍히는지, 예외 처리 어드바이스가 받지 않는다면 서버 오류 로그에 UndeclaredThrowableException이 찍히는지 보면 드러난다. 직접 짠 CGLIB 인터셉터가 앞 절처럼 대상을 MethodProxy.invoke()로 부르면, API 문서상 이 감싸기가 없다(MethodProxy — Spring Framework 7 API). MethodProxy와 AopUtils 경로는 문서와 7.0.9 소스로만 확인했고 예외 경로를 실행하지는 않았다
출처와 범위
인프런 김영한님의 「스프링 핵심 원리 – 고급편」 강의를 듣고, 강의를 따라 만든 예제를 Spring Boot 4.1.1(Spring Framework 7.0.9, Tomcat 11.0.24)과 Java 25로 옮겨 실행하며 썼다. 예외 감싸기와 CGLIB 제약은 테스트 소스에 둔 확인용 클래스를 Gradle을 거치지 않고 java 명령(Corretto 25.0.3)으로 직접 한 번씩 돌려 확인했고, 강의 테스트는 Gradle(테스트 JVM Temurin 25.0.4)로 돌렸다. 앱은 핸들러, 컨트롤러 인식 방법, 예외 처리 어드바이스를 조건에 맞게 바꿔 빌드한 jar를 같은 java로 띄우고, 경로마다 한 번 호출해 추적 로그와 응답 상태를 남겼다(예외 요청은 응답 본문 앞 80바이트도). 2026년 11월 10일 기준 공식 문서 대조, 비교 실험 코드와 스크립트 작성, 원고 정리에 AI(Claude)를 썼다
참고 자료
- Method — Java SE 25 API
- InvocationHandler — Java SE 25 API
- Proxy — Java SE 25 API
- JDK 16 Release Notes
- Spring Framework 6.0 Release Notes
- Exceptions — Spring Framework 레퍼런스
- Proxying Mechanisms — Spring Framework 레퍼런스
- MethodProxy — Spring Framework 7 API
- AopUtils.java — Spring Framework v7.0.9 소스
- ReflectiveMethodInvocation.java — Spring Framework v7.0.9 소스
- CglibAopProxy.java — Spring Framework v7.0.9 소스