동적 프록시

바그다드·2023년 8월 20일
0

지난 포스팅에서 프록시를 이용해 기존 코드의 변경 없이 부가기능을 추가하는 방법에 대해서 알아보았다.
하지만 문제가 있었는데, 기능 적용 대상 클래스의 개수만큼 프록시 클래스를 정의해줘야 한다는 것이다.
결국 동일한 코드의 중복이 그만큼 늘어나게 된다.
이러한 문제를 해결할 수 있는 방법이 바로 동적 프록시이다.
이번 포스팅에서는 동적 프록시에 대해 알아보자.

리플렉션

동적 프록시에 대해 이해하기 위해 먼저 리플렉션에 대해 간단하게 알아보자.
리플렉션은 객체의 메타 정보를 획득하여 호출하는 메서드를 동적으로 변경할 수 있다.
코드로 바로 확인해보자.

@Test
void reflection2() throws Exception {
  	Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
  	Hello target = new Hello();
  	Method methodCallA = classHello.getMethod("callA");
  	dynamicCall(methodCallA, target);
  	Method methodCallB = classHello.getMethod("callB");
  	dynamicCall(methodCallB, target);
}
private void dynamicCall(Method method, Object target) throws Exception {
	log.info("start");
	Object result = method.invoke(target);
	log.info("result={}", result);
}

@Slf4j
static class Hello {
	public String callA() {
		log.info("callA");
		return "A";
	}
	public String callB() {
		log.info("callB");
		return "B";
	}
}
  • Class.forName("패키지 경로")
    패키지 경로에 있는 클래스의 메타정보를 획득한다.
    $는 내부 클래스를 뜻한다.
  • classHello.getMethod("메서드 이름")
    classHello가 가리키는 클래스에서 파라미터로 넘긴 메서드 이름을 가진 메서드의 메타 정보를 가지고 온다.
  • dynamicCall(Method method, Object target)
    메서드의 메타 정보, 실제 인스턴스를 파라미터로 받는 메서드이다.
    인스턴스를 Object타입으로 받고 있기 때문에 모든 인스턴스를 받을 수 있다.
    이때 인스턴스의 클래스와 메서드 정보가 매칭되지 않으면 예외가 발생한다.
  • method.invoke(인스턴스)
    여기서 method는 클래스에서 가져온 메서드의 메타 정보를 가지고 있다. 이 메타 정보를 이용해 파라미터로 넘어온 실제 인스턴스의 메서드를 호출한다.
    예를 들어 위에서는 dynamicCall(methodCallA, target)를 호출했으므로 target의 callA()메서드를 호출한다.

여기서 주목할 것은 메서드를 직접 호출하는 것이 아닌 Method로 호출한다는 것이다. 메서드 이름을 직접 명시하지 않기 때문에 이제 공통 로직을 만들 수 있다.

JDK 동적 프록시

동적 프록시는 리플렉션을 활용해 런타임에 동적으로 프록시 객체를 개발자 대신 생성해준다. 또한 프록시에 실행 로직을 지정할 수 있다.
JDK동적 프록시는 인터페이스를 기반으로 프록시를 생성해주므로 인터페이스가 필수다.
코드로 확인해보자.

동적 프록시 예시

1. 인터페이스 생성

  • 동적 프록시는 인터페이스 기반으로 생성한다 했으므로 먼저 인터페이스부터 생성해주자.
  • 인터페이스 A
public interface AInterface {
    String call();
}
  • 인터페이스 B
public interface BInterface {
    String call();
}

2. 구현 클래스 생성

  • 구현체 A
@Slf4j
public class AImpl implements AInterface{

    @Override
    public String call() {
        log.info("A 호출");
        return "A";
    }
}
  • 구현체 B
@Slf4j
public class BImpl implements BInterface{

    @Override
    public String call() {
        log.info("B 호출");
        return "B";
    }
}

3. 동적 프록시 클래스 생성

  • JDK동적 프록시는 InvocationHandler라는 인터페이스를 구현해서 정의하면 된다.
    InvocationHandler를 확인해보면 아래와 같은 코드가 정의되어 있는 것을 확인할 수 있는데,
    Object proxy : 프록시 자기 자신
    Method method : 호출한 메서드
    Object[] args : 호출한 메서드에 전달할 파라미터
    를 뜻한다.
package java.lang.reflect;

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}
  • 이제 프록시 클래스를 정의해주자
@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);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeProxy종료 resultTime={}",resultTime);
        return result;
    }
}
  • 실제 타겟 객체를 Object로 받고 있으므로 어떤 객체도 타겟으로 받을 수 있다.

4. 테스트 코드 작성

  • 그럼 동적 프록시를 활용해 테스트 코드를 작성해보자.
    @Test
    void dynamicA() {
        AInterface target = new AImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);

        AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(),
                new Class[]{AInterface.class}, handler);

        proxy.call();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
    }

    @Test
    void dynamicB() {
        BInterface target = new BImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);

        BInterface proxy = (BInterface) Proxy.newProxyInstance(BInterface.class.getClassLoader(),
                new Class[]{BInterface.class}, handler);

        proxy.call();
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
    }
  • new TimeInvocationHandler(target)
    동적 프록시에 적용할 부가 기능(핸들러 로직)이다.
  • Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler)
    프록시를 생성하는 로직이다.
    AInterface.class.getClassLoader() : 클래스 로더 정보
    new Class[]{AInterface.class} : 인터페이스, 배열의 형태로 여러 인터페이스를 넣을 수 있다.
    handler : InvocationHandler 구현 클래스, 부가 기능(핸들러 로직)
  • 테스트 결과를 확인해보면


    이처럼 프록시가 생성된 것을 확인할 수 있다.

실행흐름

  1. 클라이언트는 프록시의 call()을 호출한다.
  2. JDK 동적 프록시에서 InvocationHandler.invoke()를 호출한다
    • 여기서는 TimeInvocationHandler가 들어있으므로 TimeInvocationHandler.invoke()를 수행한다.
  3. InvocationHandler에서 내부 로직을 수행하고, method.invoke(target, args)를 수행한다.
    • 이 호출로 실제 target객체의 메서드가 수행되고 메서드의 파라미터로 args가 넘어간다.
  4. AImpl 객체의 call()이 수행된다.
  5. AImpl.call()이 종료되고 TimeInvocationHandler의 나머지 로직을 수행 후, 결과를 반환한다.
  • 위의 코드를 확인해보면 프록시 클래스를 따로 정의하지 않았다. 그럼에도 JDK동적 프록시를 사용해 동적으로 프록시를 만들었고, TimeInvocationHandler라는 부가 기능을 적용하였다.
    덕분에 지난 포스팅에서 확인했던 것처럼 프록시 클래스를 적용 대상의 개수만큼 생성하지 않아도 된다.
    그저 동적 프록시를 이용해 대상을 지정하고, 각각에 적용되는 InvocationHandler를 넣어주기만 하면 된다.

동적 프록시 적용

그럼 이제 로그 추적기에 동적 프록시를 적용해보자.

1. 동적 프록시 클래스 생성

  • InvocationHandler의 구현체를 생성해주자.
public class LogTraceFilterHandler implements InvocationHandler {

    private final Object target;
    private final LogTrace logTrace;
    private final String[] patterns;

    public LogTraceFilterHandler(Object target, LogTrace logTrace, String[] patterns) {
        this.target = target;
        this.logTrace = logTrace;
        this.patterns = patterns;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        // 메서드 이름 필터
        String methodName = method.getName();
        // patterns와 methosName이 매칭되지 않는 경우 타겟 바로 실행
        if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
            return method.invoke(target, args);
        }

        TraceStatus status = null;
        try {
            String message = method.getDeclaringClass().getSimpleName() + "."
                    + method.getName() + "()";
            status = logTrace.begin(message);

            // 로직 호출
            Object result = method.invoke(target, args);
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}
  • LogTraceBasicHandler는 InvocationHandler의 구현체이므로 JDK 동적 프록시에서 사용된다.
  • target : 프록시가 호출할 실제 객체이다.
  • method.getDeclaringClass().getSimpleName() : 메서드가 정의된 클래스 이름을 가지고 온다.
    method.getName() : 메서드 이름을 가지고 온다.
  • if (!PatternMatchUtils.simpleMatch(patterns, methodName))
    주입 받은 url패턴과 메서드 이름이 매칭되지 않는 경우에 부가 기능을 적용하지 않고 실제 객체를 바로 호출한다.

이렇게 하면 동적 프록시 적용을 위한 준비는 끝났다. 프록시 적용을 위해 타겟마다 프록시 클래스를 정의해줬던 것과 달리 하나의 동적 프록시 클래스만 정의해주면 끝난다.

이제 동적 프록시를 사용하도록 설정해주자.

2. 의존성 주입

@Configuration
public class DynamicProxyFilterConfig {

    private static final String[] PATTERNS = {"request*", "order*", "save*"};

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));
        OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
                new Class[]{OrderControllerV1.class},
                new LogTraceFilterHandler(orderControllerV1, logTrace, PATTERNS));
        return proxy;
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderServiceV1 = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
        OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(OrderServiceV1.class.getClassLoader(),
                new Class[]{OrderServiceV1.class},
                new LogTraceFilterHandler(orderServiceV1, logTrace, PATTERNS));
        return proxy;
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1Impl orderRepository = new OrderRepositoryV1Impl();
        OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
                new Class[]{OrderRepositoryV1.class},
                new LogTraceFilterHandler(orderRepository, logTrace, PATTERNS));

        return proxy;
    }
}
  • String[] PATTERNS = {"request", "order", "save*"};
    호출한 메서드가 이 값들과 매칭될 경우에만 부가 로직을 적용한다.
    • 여기서 '*'는 와일드카드로
      XXX : XXX와 정확히 같으면 참
      XXX* : XXX로 시작하면 참
      *XXX : XXX로 끝나면 참
      *XXX* : XXX가 있으면 참
  • 여기서도 마찬가지로 각 타겟은 다른 실제 객체가 아닌 프록시 객체를 주입받는다.
  • 이제 타겟마다 프록시 클래스를 정의할 필요 없이 동적 프록시를 생성해주면 된다.
  • 의존 관계를 살펴보자.

    개발자가 프록시를 직접 생성하는 것이 아니라 동적 프록시가 동적으로 생성해준다.

정리

JDK 동적 프록시는 인터페이스와 리플렉션을 활용해 동적으로 프록시를 생성해준다.
동적 프록시 클래스를 생성하는 방법은 InvocationHanlder 인터페이스를 구현하면 되는데, invoke메서드를 오버라이드하여 부가 로직을 수행하고, 타겟의 로직을 수행할 때는 method.invoke(타겟 객체, args)를 호출하면 된다.
동적 프록시를 빈으로 등록하기 위해서는 Proxy객체를 활용하고,
Proxy.getInstance(클래스 로더, 인터페이스, InvocationHandler구현체)를 호출하면 된다.

  • 이때 getInstance의 반환 타입은 Object이므로 적절한 타입으로 캐스팅해주자.

여기까지 JDK동적 프록시를 활용해 프록시를 여러개 정의할 필요 없이 동적으로 생성하는 방법에 대해서 알아보았다.
하지만 만약 현재 진행하고 있는 프로젝트는 인터페이스를 사용하지 않고 구체 클래스만 사용할 경우 프록시를 어떻게 적용해야 할까?
다음 포스팅에서 알아보자.

출처 : 김영한 - 스프링 핵심 원리 고급편

profile
꾸준히 하자!

0개의 댓글