JDK 기반 동적 프록시로 메서드 인터셉션 구현하기

Java는 런타임 시점에 프록시 객체를 동적으로 생성할 수 있는 강력한 리플렉션 기능을 제공하며, 그 중 java.lang.reflect.ProxyInvocationHandler를 활용한 JDK 동적 프록시는 인터페이스 기반의 프록시 구현에 널리 사용된다. 이 방식은 별도의 서드파티 라이브러리 없이 순수 JDK만으로 AOP와 유사한 기능을 구현할 수 있어, 로깅, 트랜잭션 제어, 접근 권한 검사 등 다양한 횡단 관심사(cross-cutting concerns) 처리에 적합하다.

동작 원리: 인터페이스 기반의 프록시 생성

JDK 동적 프록시는 반드시 인터페이스를 구현한 클래스에 대해서만 작동한다. 프록시 객체는 지정된 인터페이스를 상속받아 런타임에 바이트코드로 생성되며, 모든 메서드 호출은 사전에 정의한 핸들러로 위임된다. 만약 대상 클래스가 인터페이스를 구현하지 않았다면 IllegalArgumentException이 발생하므로, 그럴 경우 CGLIB나 ASM과 같은 바이트코드 조작 라이브러리를 사용해야 한다.

주요 구성 요소

1. InvocationHandler
프록시 객체의 메서드 호출이 전달되는 핵심 인터페이스로, 다음과 같은 메서드를 정의한다:

Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
  • proxy: 현재 프록시 인스턴스.
  • method: 호출된 실제 메서드의 리플렉션 객체.
  • args: 전달된 인자 배열.

이 메서드 내에서 원본 메서드 실행 전후에 커스텀 로직(예: 로그 출력, 성능 측정)을 삽입할 수 있다.

2. Proxy.newProxyInstance()
실제 프록시 인스턴스를 생성하는 정적 메서드로, 다음 세 가지 인자를 요구한다:

  • ClassLoader: 프록시 클래스를 로드할 클래스 로더 (보통 대상 객체의 클래스 로더 사용).
  • Interfaces: 프록시가 구현할 인터페이스 배열.
  • InvocationHandler: 메서드 호출을 처리할 핸들러 인스턴스.

실습 예제: 로깅 기능 주입

다음은 사용자 관리 서비스에 로그를 자동으로 삽입하는 프록시 구현이다.

1. 인터페이스 및 구현체 정의

public interface UserManagement {
    void register(String username);
    String findUserById(Long id);
}

public class UserManagementImpl implements UserManagement {
    public void register(String username) {
        System.out.println("등록 중: " + username);
    }

    public String findUserById(Long id) {
        return "User-" + id;
    }
}

2. InvocationHandler 구현

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

public class LoggingHandler implements InvocationHandler {
    private final Object target;

    public LoggingHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String methodName = method.getName();
        System.out.println("[LOG] 시작: " + methodName);

        if (args != null && args.length > 0) {
            for (Object arg : args) {
                System.out.println("[LOG] 입력 값: " + arg);
            }
        }

        long startTime = System.currentTimeMillis();
        Object result = method.invoke(target, args);
        long duration = System.currentTimeMillis() - startTime;

        System.out.println("[LOG] 완료: " + methodName + " (실행 시간: " + duration + "ms)");
        if (result != null) {
            System.out.println("[LOG] 반환 값: " + result);
        }

        return result;
    }
}

3. 프록시 생성 및 테스트

public class ProxyExample {
    public static void main(String[] args) {
        UserManagement target = new UserManagementImpl();
        LoggingHandler handler = new LoggingHandler(target);

        UserManagement proxy = (UserManagement) Proxy.newProxyInstance(
            target.getClass().getClassLoader(),
            target.getClass().getInterfaces(),
            handler
        );

        proxy.register("alice");
        String user = proxy.findUserById(100L);
        System.out.println("조회 결과: " + user);
    }
}

4. 실행 결과

[LOG] 시작: register
[LOG] 입력 값: alice
등록 중: alice
[LOG] 완료: register (실행 시간: 2ms)
[LOG] 시작: findUserById
[LOG] 입력 값: 100
[LOG] 완료: findUserById (실행 시간: 1ms)
[LOG] 반환 값: User-100
조회 결과: User-100

내부 동작 방식

런타임에 생성된 프록시 클래스는 아래와 유사한 구조를 갖는다. JVM은 요청된 인터페이스를 구현한 클래스를 동적으로 만들고, 각 메서드는 InvocationHandlerinvoke 메서드를 통해 라우팅된다.

// 개념적 코드 (실제 바이트코드 아님)
public class $Proxy1 extends Proxy implements UserManagement {
    private InvocationHandler handler;

    public $Proxy1(InvocationHandler h) {
        super(h);
        this.handler = h;
    }

    public void register(String username) {
        handler.invoke(this, registerMethod, new Object[]{username});
    }

    public String findUserById(Long id) {
        return (String) handler.invoke(this, findUserByIdMethod, new Object[]{id});
    }
}

성능 및 제약 사항

  • 생성된 프록시 클래스 이름은 $Proxy0, $Proxy1 형식으로 자동 부여된다.
  • 모든 호출이 리플렉션을 거치므로 직접 호출보다 약간 느리지만, 일반적인 애플리케이션에서는 무시 가능한 수준이다.
  • 인터페이스에 선언되지 않은 메서드는 프록시로 감쌀 수 없다.
  • final 클래스나 static 메서드는 프록시화할 수 없다.

JDK 프록시 vs CGLIB 비교

구분 JDK 동적 프록시 CGLIB
기반 방식 인터페이스 구현 서브클래싱 (자식 클래스 생성)
대상 제약 인터페이스 필수 인터페이스 불필요, final 클래스 제외
성능 리플렉션 사용, 다소 느림 메서드 오버라이드, 상대적으로 빠름
의존성 JDK 내장 외부 라이브러리 필요

주요 활용 분야

  • 관점 지향 프로그래밍(AOP): Spring의 @Transactional, @Cacheable 어노테이션 기반 기능.
  • 원격 프로시저 호출(RPC): 클라이언트에서 서버 메서드를 마치 로컬처럼 호출.
  • 보안 제어: 특정 메서드 호출 전 인증/권한 확인.
  • 모니터링: 메서드별 응답 시간, 호출 빈도 수집.

이처럼 JDK 동적 프록시는 설계 패턴 중 프록시 패턴을 실용적으로 구현할 수 있게 해주며, 비즈니스 로직과 인프라 로직의 분리를 가능하게 하여 유지보수성을 크게 향상시킨다.

태그: JDK 동적 프록시 Java 리플렉션 InvocationHandler Proxy.newProxyInstance AOP

6월 12일 00:45에 게시됨