JDK·클래스 기반 프록시
JDK 동적 프록시와 클래스 기반 프록시를 실행하고 인터페이스·final·메서드 가시성 제한을 기준으로 방식을 선택합니다.
수동 래퍼의 위임 코드는 메서드마다 반복됩니다. JDK 동적 프록시(dynamic proxy)는 런타임에 인터페이스 구현 객체를 만들고, 노출한 인터페이스의 메서드와 equals, hashCode, toString 호출을 InvocationHandler로 보냅니다. getClass처럼 Object에서 물려받은 나머지 메서드까지 모두 핸들러에 들어가는 것은 아닙니다.
클래스 기반 프록시(class-based proxy)는 대상 클래스의 하위 클래스를 생성하므로 인터페이스가 없는 빈도 감쌀 수 있습니다. 두 방식은 만드는 방법이 다르지만, 클라이언트가 프록시 참조를 호출해야만 어드바이스(advice)가 실행된다는 점은 같습니다.
DYNAMIC PROXY · INTERFACE VS SUBCLASS
노출 계약은 다르지만 프록시를 통과해야 가로챈다
JDK 프록시는 인터페이스 구현을 생성하고 클래스 프록시는 하위 클래스를 생성합니다. 두 방식 모두 최종 프록시 참조로 들어온 외부 호출을 가로챕니다.
JDK 동적 프록시
- 인터페이스로 주입·호출
PostCommand 같은 노출 인터페이스에 의존합니다. 대상 구현 클래스로 cast하지 않습니다.
- InvocationHandler로 진입
Method와 args를 받아 범위를 판단하고 Method.invoke로 대상에 위임합니다.
- 실제 원인을 복원
InvocationTargetException의 cause를 다시 던집니다. 인터페이스가 선언하지 않은 검사 예외는 UndeclaredThrowableException으로 감싸질 수 있습니다.
final 구현 메서드: JDK는 인터페이스를 통한 호출을 가로챌 수 있습니다. 클래스 프록시의 재정의 제한과 구분합니다.
클래스 기반 프록시
- 하위 클래스 생성
대상 클래스 타입으로 노출 가능합니다. 클래스가 상속 가능해야 합니다.
- 재정의 가능한 메서드 가로채기
final·private·접근 불가능한 메서드는 advice가 붙지 않습니다. final 클래스는 이 방식으로 만들 수 없습니다.
- 대상 내부 호출은 우회
target의 this 호출은 생성 프록시로 재진입하지 않습니다. 생성 기술 변경만으로 자기 호출이 해결되지 않습니다.
Object 메서드 정책·반환 타입·예외 계약을 테스트합니다. AOT/native에서는 필요한 프록시·리플렉션 metadata를 실제 빌드로 확인합니다.
그림의 위쪽 경로는 PostCommand를 구현한 JDK 프록시가 InvocationHandler를 거쳐 별도의 Target에 위임하는 모습입니다. 프록시가 Target의 하위 클래스를 만들지 않으므로 Target이 final이어도 인터페이스를 통한 호출은 가로챌 수 있습니다. 아래쪽 경로는 WeeklyReportService의 하위 타입인 클래스 프록시가 인터셉터를 거쳐 별도의 대상에 위임하는 모습이며, final 클래스의 상속, final 메서드의 재정의, private 메서드는 가로채기 범위에서 빠집니다. 두 경로는 나란히 비교하는 사례이고, 화살표는 상속이 아니라 호출의 위임입니다. 맨 아래 칸은 두 방식 모두 대상 안에서 this로 부르는 호출이 프록시로 다시 들어오지 않는다는 점을 보여 줍니다.
InvocationHandler의 입력
핸들러는 메서드 이름 문자열만 비교하지 말고, 선언 타입, 애노테이션, 시그니처로 적용 범위를 정합니다. Method.invoke는 대상이 던진 예외를 InvocationTargetException으로 감싸므로, 원래 원인을 꺼내지 않으면 호출자가 받는 예외가 달라집니다. 리플렉션으로 메서드를 호출하는 방법과 이 감싸기는 Java 교재의 리플렉션으로 클래스 정보 읽고 호출하기에서 다룹니다.
package board.dynamic;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
public final class JdkProxyDemo {
interface PostCommand {
long register(String title);
}
static final class Target implements PostCommand {
@Override
public long register(String title) {
if (title.isBlank()) {
throw new IllegalArgumentException("blank title");
}
return 41L;
}
}
public static void main(String[] args) {
var target = new Target();
PostCommand proxy = (PostCommand) Proxy.newProxyInstance(
PostCommand.class.getClassLoader(),
new Class<?>[]{PostCommand.class},
(object, method, arguments) -> {
long start = System.nanoTime();
try {
return method.invoke(target, arguments);
} catch (InvocationTargetException failure) {
throw failure.getCause();
} finally {
System.out.println("method=" + method.getName()
+ " measured=" + (System.nanoTime() >= start));
}
});
System.out.println("id=" + proxy.register("dynamic proxy"));
System.out.println("jdkProxy=" + Proxy.isProxyClass(proxy.getClass()));
}
}register가 정상 반환하면 핸들러의 finally가 측정 줄을 먼저 출력하고, 그다음 main이 반환값과 프록시 여부를 출력합니다. finally는 핸들러가 값을 돌려주기 직전에 실행되고, main의 println은 프록시 호출이 끝나 값을 받은 뒤에야 문자열을 만들기 때문입니다. measured=true는 두 System.nanoTime() 값을 비교한 결과일 뿐 걸린 시간을 출력하지 않으므로, 실행할 때마다 같은 줄이 나옵니다.
method=register measured=true
id=41
jdkProxy=trueJDK 프록시의 런타임 타입은 Target이 아닙니다. 따라서 PostCommand 타입으로 주입받아야 하며, 구체 클래스를 요구하는 주입 지점은 실패합니다. 좋은 포트 인터페이스가 이미 있다면 이 제약이 오히려 의존 방향을 더 분명하게 만듭니다.
예제의 Target은 final 클래스여도 문제가 없습니다. 생성된 프록시는 Target의 하위 클래스가 아니라 PostCommand를 구현하고, 따로 만들어 둔 Target에 위임하기 때문입니다. 반대로 아래 클래스 프록시의 WeeklyReportService는 상속할 수 있는 클래스여야 하고, 가로챌 메서드도 재정의할 수 있어야 합니다.
핸들러는 InvocationTargetException의 getCause()를 꺼내 다시 던집니다. register가 만든 IllegalArgumentException은 검사 예외가 아니므로, 인터페이스에 throws가 없어도 호출자에게 그대로 전달됩니다. 인터페이스가 선언하지 않은 검사 예외가 핸들러에서 나오는 경우는 InvocationHandler.invoke의 설명에서 따로 다루며, 이 경우는 연습 문제에서 계산합니다.
Object 메서드와 기본 메서드
toString, equals, hashCode 호출도 핸들러에 들어옵니다. 모든 메서드를 대상에 리플렉션으로 위임하면 프록시의 동일성이 예상과 달라질 수 있고, 로그에 객체를 출력하기만 해도 업무 메트릭이 늘 수 있습니다. 어드바이스의 적용 범위에서 객체 메서드를 빼거나, 명시적으로 따로 처리합니다. JDK Proxy API는 어떤 Object 메서드가 핸들러로 전달되는지 정리하고 있습니다.
인터페이스의 기본 메서드(default method)도 핸들러로 들어오며, Java 16부터는 InvocationHandler.invokeDefault로 그 기본 구현을 호출할 수 있습니다. Spring AOP의 인터셉터 체인을 쓰면 이런 세부는 프레임워크가 처리하지만, 어떤 메서드에 적용할지는 여전히 직접 검토합니다.
핸들러가 인자 배열을 수정하면 대상이 받는 입력이 바뀝니다. 검증 프록시처럼 그것이 목적인 경우가 아니라면 불변 명령을 넘기고, 기록만 하는 어드바이스는 값을 바꾸지 않게 합니다.
클래스 기반 프록시의 제한
Spring의 ProxyFactory에 구체 대상을 넣고 setProxyTargetClass(true)를 지정하면 클래스 프록시를 만듭니다. 생성된 객체는 대상 타입으로 주입할 수 있지만, final 클래스는 상속할 수 없고 final 메서드는 재정의할 수 없습니다. private 메서드도 바깥에서 가로챌 수 있는 조인 포인트(join point)가 아닙니다.
package board.dynamic;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;
public final class ClassProxyDemo {
public static class WeeklyReportService {
public int publishedPostCount(long authorId) {
return authorId == 41L ? 12 : 0;
}
}
public static void main(String[] args) {
var factory = new ProxyFactory(new WeeklyReportService());
factory.setProxyTargetClass(true);
factory.addAdvice((MethodInterceptor) invocation -> {
System.out.println("before=" + invocation.getMethod().getName());
Object result = invocation.proceed();
System.out.println("after=" + result);
return result;
});
WeeklyReportService proxy = (WeeklyReportService) factory.getProxy();
System.out.println("postCount=" + proxy.publishedPostCount(41L));
System.out.println("aopProxy=" + AopUtils.isAopProxy(proxy));
}
}인터셉터가 proceed() 앞뒤로 한 줄씩 출력하므로, 손으로 따라가면 before=publishedPostCount, after=12, postCount=12, aopProxy=true 순서로 출력됩니다.
Spring은 Objenesis를 써서 생성자를 호출하지 않고 프록시 인스턴스를 만들려고 합니다. 하지만 환경에 따라 프록시를 만들 때 대상 클래스의 생성자가 한 번 더 실행될 수 있다고 보고, 생성자에는 부수 효과를 두지 않습니다. 대상 객체의 생성과 의존성 주입은 여전히 일반적인 빈 생명 주기를 따릅니다.
프록시 방식 선택
인터페이스가 있고 클라이언트도 인터페이스에 의존한다면 JDK 프록시로 충분합니다. 인터페이스가 없는 애플리케이션 서비스이거나 구체 타입으로 주입받아야 한다면 클래스 프록시가 필요할 수 있습니다. Spring Boot는 spring.aop.proxy-target-class의 기본값이 true여서, 인터페이스가 있어도 자동 구성된 AOP 프록시를 클래스 기반으로 만듭니다. 이 설정 하나에 맡겨 두기보다, 어떤 빈이 어떤 방식을 쓰는지와 그 이유를 기록합니다.
| 조건 | JDK 프록시 | 클래스 프록시 |
|---|---|---|
| 대상이 갖춰야 할 것 | 인터페이스 | 하위 클래스를 만들 수 있는 클래스 |
| 주입 타입 | 인터페이스 권장 | 구체 타입 가능 |
구현 클래스의 final 메서드 | 인터페이스로 호출하면 가로챔 | 가로챌 수 없음 |
private 자기 호출 | 가로챌 수 없음 | 가로챌 수 없음 |
| 대상 클래스로 형 변환 | 불가 | 가능 |
두 방식 모두 자기 호출 문제를 해결하지 않습니다. 프록시를 만드는 기술을 바꾼다고 해서, 같은 객체 안에서 부른 메서드의 애노테이션이 살아나지는 않습니다.
더 깊이: 전역 설정은 하나로 합쳐지고, 7.0부터는 빈마다 고를 수 있습니다
프록시 방식의 설정은 애노테이션마다 따로 적용되지 않습니다. Spring 레퍼런스의 프록시 메커니즘에 따르면 @EnableAspectJAutoProxy, @EnableTransactionManagement 같은 구성 애노테이션의 proxyTargetClass는 하나의 자동 프록시 생성기로 합쳐지고, 그중 가장 강한 설정, 즉 어느 한 곳이라도 true면 클래스 기반 프록시가 모두에 적용됩니다. 7.0부터는 @EnableAsync 같은 개별 프록시 처리기도 이 전역 기본값을 똑같이 따릅니다. 그래서 트랜잭션 설정에서만 인터페이스 프록시를 원한다고 적어도, 다른 구성이 클래스 프록시를 요구하면 결과는 클래스 프록시가 됩니다.
대신 Spring Framework 7.0에는 빈 하나의 방식을 고르는 @Proxyable이 생겼습니다. @Bean 메서드나 @Component 클래스에 @Proxyable(INTERFACES)나 @Proxyable(TARGET_CLASS)를 붙이면 전역 기본값보다 그 선택이 우선하고, @Proxyable(interfaces = ...)로 프록시가 노출할 인터페이스를 일부로 좁힐 수도 있습니다. 이 애노테이션은 그 빈이 실제로 자동 프록시의 대상이 될 때만 효과가 있습니다. 앞에서 말한 대로 어떤 빈이 어떤 방식을 쓰는지 기록해 두고 싶다면, 이제는 그 선택을 설정 파일의 전역 값 대신 해당 빈의 선언에 직접 남길 수 있습니다.
리플렉션과 하위 클래스 진단
스택 트레이스에는 $Proxy나 생성된 하위 클래스의 이름이 나타날 수 있습니다. 애플리케이션 로그와 메트릭에는 런타임 클래스 이름 대신 AopUtils.getTargetClass의 결과나 안정적인 작업 이름을 씁니다. 생성된 이름은 재시작할 때마다 달라질 수 있으므로 허용 목록의 키로 저장하지 않습니다.
네이티브 이미지(native image)와 AOT(ahead-of-time) 환경에서는 동적 프록시 힌트와 리플렉션 메타데이터가 필요할 수 있습니다. Spring Boot의 AOT 처리가 일반적인 인프라의 힌트를 생성하더라도, 사용자가 런타임에 직접 만드는 프록시의 인터페이스 목록은 빌드 시점에 알려 주는 편이 안전하며, 실제 네이티브 빌드로 확인합니다.
직접 호출과 프록시 호출 비교
어드바이스에 카운터를 두고, 대상을 직접 한 번, 프록시를 통해 한 번 호출해 카운터가 정확히 한 번만 늘어나는지 봅니다. 예외의 동일성, 반환 타입, 객체 메서드 제외, final 메서드 경고도 함께 테스트합니다. 프록시 클래스의 이름만 검증하면 구현이 바뀔 때 쉽게 깨집니다.
정리
- JDK 동적 프록시는 인터페이스 구현 객체를 만들어 호출을
InvocationHandler로 보내며, 대상 클래스가final이어도 쓸 수 있습니다. - 클래스 기반 프록시는 하위 클래스를 만들므로 구체 타입으로 주입할 수 있지만,
final클래스와final·private메서드는 가로채지 못합니다. - 핸들러는
InvocationTargetException에서 원래 원인을 꺼내 던지고,equals·hashCode·toString의 처리 방식을 따로 정합니다. - Spring Boot는 기본으로 클래스 기반 AOP 프록시를 만들며, 어느 방식이든 대상 내부의 자기 호출은 가로채지 않습니다.
연습 문제
1. 같은 인터셉터를 두 프록시에
인터페이스가 있는 명령과 인터페이스가 없는 보고서 서비스에 같은 시간 측정 인터셉터를 적용하세요. ProxyFactory가 선택한 프록시 종류, 대상 클래스, 어드바이스 개수를 출력하고, 보고서 메서드가 final이면 가로채지 못하는 위험을 설명하세요.
해설 보기
어드바이스 인스턴스 하나를 두 팩토리에서 재사용할 수 있지만, 호출마다 바뀌는 상태를 필드에 두면 동시성 문제가 생깁니다. 카운터가 필요하다면 스레드 안전한 카운터나 미터를 씁니다.
package board.dynamic;
import java.util.concurrent.atomic.AtomicInteger;
import org.aopalliance.intercept.MethodInterceptor;
public final class CountingAdvice implements MethodInterceptor {
private final AtomicInteger calls = new AtomicInteger();
@Override
public Object invoke(org.aopalliance.intercept.MethodInvocation invocation)
throws Throwable {
calls.incrementAndGet();
return invocation.proceed();
}
public int callCount() {
return calls.get();
}
}테스트는 JDK 프록시를 구체 대상으로 형 변환하지 않고, 노출된 인터페이스로 호출합니다. 보고서 메서드가 final이면 클래스 프록시가 그 메서드를 재정의하지 못합니다. 그래서 호출은 인터셉터도 대상도 거치지 않고, 프록시 인스턴스가 물려받은 메서드로 실행됩니다. 프록시 인스턴스의 필드는 초기화되지 않았을 수 있으므로, 그 메서드가 주입된 필드를 쓰면 NullPointerException이 나기도 합니다. 필드를 쓰지 않는 메서드라면 오류 없이 측정만 빠지므로, 카운터가 늘었는지를 테스트로 확인해야 이 누락을 발견할 수 있습니다.
2. 선언되지 않은 검사 예외
예제의 PostCommand.register는 검사 예외를 선언하지 않습니다. 별도의 InvocationHandler가 register 호출에서 IOException 객체 E를 직접 던진다면 호출자는 무엇을 받나요? 새 인터페이스에서 같은 메서드가 throws IOException을 선언한 경우와 비교하세요. 두 경우 모두 인터페이스는 하나이고 다른 감싸기 처리는 없습니다.
해설 보기
첫 경우에는 호출자가 E를 원인으로 담은 UndeclaredThrowableException을 받습니다. 둘째 경우에는 선언으로 허용된 검사 예외이므로 E가 그대로 전달됩니다.
리플렉션의 InvocationTargetException을 벗기는 단계와, 프록시 인터페이스의 throws 규칙이 적용되는 단계는 서로 다른 단계입니다. 또 대상이 final이라 JDK 프록시를 못 만든다고 오해하지 말고, final 클래스를 상속하지 못하는 것은 클래스 프록시의 제한이라는 점도 구별합니다.
3. equals를 대상에 넘기면
JdkProxyDemo의 핸들러를 그대로 쓰고, Target은 equals를 재정의하지 않습니다. proxy.equals(proxy), proxy.equals(target), target.equals(proxy)의 결과를 각각 구하세요. 이 세 호출은 예제의 main에 없는 별도 조건입니다.
해설 보기
proxy.equals(proxy)는 method.invoke의 수신자가 target, 인자가 proxy이므로 target.equals(proxy)가 되고, 두 객체가 달라 false입니다. proxy.equals(target)는 target.equals(target)로 이어져 true이고, target.equals(proxy)는 프록시를 거치지 않는 Object.equals라 false입니다.
자기 자신과 비교해도 false가 나오고, 두 객체의 비교 결과가 방향에 따라 달라 대칭성도 깨집니다. 그러므로 이 위임을 일반적인 동일성 구현으로 쓰면 안 되며, 객체 메서드는 핸들러에서 명시적으로 처리하거나 측정 범위에서 뺍니다.
다음 글 ProxyFactory 구성에서는 Spring의 ProxyFactory가 인터페이스 유무와 설정에 따라 두 방식 가운데 하나를 고르는 규칙과, 어드바이스·포인트컷·어드바이저를 조립하는 방법을 다룹니다.