본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
12장 : 스레드 로컬·템플릿·프록시

JDK·클래스 기반 프록시

InvocationHandler가 인터페이스 호출 정보를 받는 흐름과 Spring ProxyFactory의 하위 클래스 프록시를 비교하고, 인터페이스 노출·final·메서드 가시성·구체 형 변환·리플렉션 예외를 기준으로 방식을 선택합니다.

수동 래퍼의 위임 코드는 메서드마다 반복됩니다. JDK 동적 프록시는 런타임에 인터페이스 구현 객체를 만들고 모든 호출을 InvocationHandler 하나로 보냅니다. 클래스 기반 프록시는 대상 클래스의 하위 클래스를 생성해 인터페이스 없는 빈도 감쌉니다. 둘은 생성 방식이 다르지만 클라이언트가 프록시 참조를 호출해야 어드바이스가 실행된다는 경계는 같습니다.


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로 주입받아야 하며 구체 클래스를 요구하는 주입 지점은 실패합니다. 좋은 포트 인터페이스가 이미 있다면 이 제약이 아키텍처를 더 명시적으로 만들 수 있습니다.

Object·기본 메서드

toString, equals, hashCode 호출도 핸들러에 들어올 수 있습니다. 모든 메서드를 대상에 리플렉션 호출하면 프록시 동일성이 예상과 달라질 수 있고 로그 포매팅만으로 업무 메트릭이 증가할 수 있습니다. 어드바이스 포인트컷에서 객체 메서드를 제외하거나 명시적으로 처리합니다.

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