본문 바로가기

JDK·클래스 기반 프록시

JDK 동적 프록시와 클래스 기반 프록시를 실행하고 인터페이스·final·메서드 가시성 제한을 기준으로 방식을 선택합니다.

수동 래퍼의 위임 코드는 메서드마다 반복됩니다.

JDK 동적 프록시는 런타임에 인터페이스 구현 객체를 만들고, 노출 인터페이스의 메서드와 equals·hashCode·toString 호출을 InvocationHandler로 보냅니다. getClass처럼 Object에서 물려받은 나머지 메서드까지 모두 핸들러에 들어가는 것은 아닙니다.

클래스 기반 프록시는 대상 클래스의 하위 클래스를 생성해 인터페이스 없는 빈도 감쌉니다.

둘은 생성 방식이 다르지만 클라이언트가 프록시 참조를 호출해야 어드바이스가 실행된다는 경계는 같습니다.

DYNAMIC PROXY · INTERFACE VS SUBCLASS

노출 계약은 다르지만 프록시를 통과해야 가로챈다

JDK 프록시는 인터페이스 구현을 생성하고 클래스 프록시는 하위 클래스를 생성합니다. 두 방식 모두 최종 프록시 참조로 들어온 외부 호출을 가로챕니다.

두 프록시의 노출 타입과 실제 호출 위임위경로는PostCommand를구현한JDKproxy가InvocationHandler를통해별도Target에위임한다. Target이final이어도Target의하위클래스를만들지않으므로인터페이스호출가능하다. 아래경로는WeeklyReportService하위타입인클래스프록시가인터셉터를통해별도대상에위임한다. finalclass상속/finalmethod재정의/private메서드는그가로채기범위에서제외한다. 두경로는비교사례이지서로이어지는단계가아니다. 화살표는상속이아닌호출위임이다.JDK 프록시PostCommandInvocationHandler대상에 위임별도 Targetfinal도 가능클래스 프록시서비스 하위 타입Interceptor대상에 위임별도 서비스재정의 가능인터페이스로 노출 · Target을 상속하지 않음클래스로 노출 · 상속/재정의 가능한 범위화살표는 호출 위임 · target this 자기 호출은 프록시 우회
두 프록시의 노출 타입과 실제 호출 위임위경로는PostCommand를구현한JDKproxy가InvocationHandler를통해별도Target에위임한다. Target이final이어도Target의하위클래스를만들지않으므로인터페이스호출가능하다. 아래경로는WeeklyReportService하위타입인클래스프록시가인터셉터를통해별도대상에위임한다. finalclass상속/finalmethod재정의/private메서드는그가로채기범위에서제외한다. 두경로는비교사례이지서로이어지는단계가아니다. 화살표는상속이아닌호출위임이다.JDK 프록시PostCommand로 호출InvocationHandler대상에 위임별도 Targetfinal이어도 가능클래스 프록시서비스 하위 타입Interceptor대상에 위임별도 서비스 대상메서드 재정의 가능인터페이스 계약상속 가능한 클래스두 방식의 target this 호출프록시 재진입 아님

JDK 동적 프록시

  1. 인터페이스로 주입·호출

    PostCommand 같은 노출 인터페이스에 의존합니다. 대상 구현 클래스로 cast하지 않습니다.

  2. InvocationHandler로 진입

    Method와 args를 받아 범위를 판단하고 Method.invoke로 대상에 위임합니다.

  3. 실제 원인을 복원

    InvocationTargetException의 cause를 다시 던집니다. 인터페이스가 선언하지 않은 검사 예외는 UndeclaredThrowableException으로 감싸질 수 있습니다.

final 구현 메서드: JDK는 인터페이스를 통한 호출을 가로챌 수 있습니다. 클래스 프록시의 재정의 제한과 구분합니다.

클래스 기반 프록시

  1. 하위 클래스 생성

    대상 클래스 타입으로 노출 가능합니다. 클래스가 상속 가능해야 합니다.

  2. 재정의 가능한 메서드 가로채기

    final·private·접근 불가능한 메서드는 advice가 붙지 않습니다. final 클래스는 이 방식으로 만들 수 없습니다.

  3. 대상 내부 호출은 우회

    target의 this 호출은 생성 프록시로 재진입하지 않습니다. 생성 기술 변경만으로 자기 호출이 해결되지 않습니다.

Object 메서드 정책·반환 타입·예외 계약을 테스트합니다. AOT/native에서는 필요한 프록시·리플렉션 metadata를 실제 빌드로 확인합니다.


InvocationHandler 입력

핸들러는 메서드 이름 문자열만 비교하지 말고 선언 타입, 애노테이션, 시그니처를 사용해 범위를 결정합니다.

Method.invoke가 대상 예외를 InvocationTargetException으로 감싸므로 원래 원인을 꺼내지 않으면 호출자 계약이 달라집니다.

src/main/java/board/dynamic/JdkProxyDemo.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()));
    }
}
JdkProxyDemo 실행
method=register measured=true
id=41
jdkProxy=true

JDK 프록시의 런타임 타입은 Target이 아닙니다.

PostCommand로 주입받아야 하며 구체 클래스를 요구하는 주입 지점은 실패합니다.

좋은 포트 인터페이스가 이미 있다면 이 제약이 아키텍처를 더 명시적으로 만들 수 있습니다.

원 Target은 final 클래스여도 JDK 예제가 성립합니다. 생성된 프록시는 Target의 하위 클래스가 아니라 PostCommand를 구현하고, 별도로 만들어 둔 Target에 위임하기 때문입니다. 반대로 아래 클래스 프록시의 WeeklyReportService는 상속 가능한 클래스이며 호출할 메서드도 재정의할 수 있어야 합니다. 두 그림의 화살표는 호출 위임이고, JDK 프록시가 Target을 상속한다는 뜻이 아닙니다.

원 핸들러는 InvocationTargetException의 cause를 꺼냅니다. register가 만든 IllegalArgumentException은 검사 예외가 아니므로 인터페이스에 throws가 없어도 그 실패를 호출자에게 전달할 수 있습니다. InvocationHandler의 throws 계약은 선언되지 않은 검사 예외가 나오는 경우를 따로 구분합니다.


Object·기본 메서드

toString, equals, hashCode 호출도 핸들러에 들어올 수 있습니다.

모든 메서드를 대상에 리플렉션 호출하면 프록시 동일성이 예상과 달라질 수 있고 로그 포매팅만으로 업무 메트릭이 증가할 수 있습니다.

어드바이스 포인트컷에서 객체 메서드를 제외하거나 명시적으로 처리합니다.

예시 원 Target은 equals를 재정의하지 않습니다. 그 핸들러로 proxy.equals(proxy)를 호출하면 method.invoke의 수신자는 target, 인자는 proxy입니다. 따라서 target.equals(proxy)의 두 객체가 달라 false가 됩니다. proxy.equals(target)는 target.equals(target)로 이어져 true인 반면 target.equals(proxy)는 false입니다. 자기 자신과의 비교와 대칭성까지 깨질 수 있어 원 위임을 일반적인 동일성 구현으로 사용하면 안 됩니다. 이 세 호출은 원 main에서 실행한 출력이 아니라 해당 핸들러의 전달값을 따라 읽은 별도 조건입니다.

JDK Proxy API의 Object 메서드 분류를 보고, equals·hashCode·toString을 명시적으로 다루는 설계와 업무 메서드 측정 범위를 나누세요.

인터페이스 기본 메서드도 프록시 구현과 JDK 버전에 따라 호출 의미를 이해해야 합니다.

Spring AOP의 인터셉터 체인을 사용하면 프레임워크가 세부를 처리하지만 포인트컷 범위는 여전히 검토합니다.

핸들러가 인자 배열을 수정하면 대상 입력이 바뀝니다.

검증 프록시처럼 의도한 경우가 아니면 불변 명령을 전달하고 관찰 어드바이스가 값을 변경하지 않게 합니다.


클래스 기반 프록시 제한

Spring ProxyFactory에 구체 대상을 넣고 setProxyTargetClass(true)를 지정하면 클래스 프록시를 만들 수 있습니다.

생성된 객체는 대상 타입으로 주입될 수 있지만 final 클래스는 상속할 수 없고 final 메서드는 재정의할 수 없습니다.

private 메서드도 외부 조인 지점이 아닙니다.

src/main/java/board/dynamic/ClassProxyDemo.java
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));
    }
}

클래스 프록시가 생성자를 두 번 실행한다고 가정해 부수 효과를 두지 않습니다.

Spring은 Objenesis를 활용할 수 있지만 대상 생성과 의존성 주입은 여전히 정상 빈 생명 주기를 따라야 합니다.


프록시 방식 선택

인터페이스가 있고 클라이언트도 인터페이스로 의존하면 JDK 프록시가 충분합니다.

인터페이스 없는 애플리케이션 서비스나 구체 타입 주입을 유지해야 하면 클래스 프록시가 필요할 수 있습니다.

Boot 설정 하나로 강제하기보다 선택 이유를 기록합니다.

조건JDK 프록시Class 프록시
필요한 계약인터페이스하위 클래스를 만들 수 있는 클래스
주입 타입인터페이스 권장구체 가능
final 메서드인터페이스 호출 가능가로채기 불가
private 자기 호출가로채기 불가가로채기 불가
프록시 타입 형 변환대상 클래스 불가대상 클래스 가능

두 방식 모두 내부 호출을 해결하지 않습니다.

프록시 생성 기술을 바꾸는 것으로 내부 호출 애노테이션이 살아난다고 기대하지 않습니다.


리플렉션·하위 클래스 진단

스택 트레이스에 $Proxy 또는 생성된 하위 클래스 이름이 나타날 수 있습니다.

애플리케이션 로그와 메트릭에는 런타임 클래스 대신 AopUtils.getTargetClass나 안정적인 작업 이름을 사용합니다.

생성된 이름을 허용 목록 키로 저장하면 재시작마다 달라질 수 있습니다.

네이티브 이미지와 AOT 환경에서는 동적 프록시 힌트와 리플렉션 메타데이터가 필요할 수 있습니다.

Spring Boot AOT가 일반 인프라를 생성하더라도 사용자 정의 런타임 인터페이스 목록은 빌드 시점에 알려 주는 편이 안전합니다.

실제 네이티브 간이 검증으로 확인합니다.


직접·프록시 호출 비교

어드바이스 카운터를 만들고 대상에서 직접 한 번, 프록시에서 한 번 호출해 카운터가 정확히 한 번 증가하는지 봅니다.

예외 동일성, 반환 타입, 객체 메서드 제외, final 메서드 경고도 테스트합니다.

프록시 클래스 이름만 검증하면 구현 변경에 취약합니다.


연습 문제

인터페이스가 있는 명령과 인터페이스 없는 보고서 서비스에 같은 시간 측정 인터셉터를 적용하세요.

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 프록시를 구체 대상으로 형 변환하지 않고 노출된 인터페이스로 호출합니다.

독립 확인: 원 PostCommand.register는 검사 예외를 선언하지 않습니다. 별도 InvocationHandler가 register 호출에서 IOException 객체 E를 직접 던진다면 호출자는 무엇을 받나요? 새로운 인터페이스에서 같은 메서드가 throws IOException을 선언한 경우와 비교하세요. 두 경우는 단일 인터페이스이고 다른 감싸기 처리는 없습니다.

풀이 확인

첫 경우는 UndeclaredThrowableException이 E를 원인으로 담아 나옵니다. 둘째는 허용된 검사 예외 E가 전달됩니다. 리플렉션의 InvocationTargetException을 벗기는 단계와 프록시 인터페이스의 throws 규칙을 적용하는 단계는 별개입니다. final 대상이라 JDK 프록시를 못 만든다는 판단과, 클래스 프록시가 final 클래스를 상속하지 못한다는 제한도 구별해야 합니다.