Spring Advanced

Proxy/Decorator Pattern 2 (동적 프록시)

cwchoiit 2023. 12. 14. 10:48
728x90
반응형
SMALL

https://cwchoiit.tistory.com/78

 

Proxy/Decorator Pattern

이제 스프링에서 굉장히 자주 사용되는 프록시와 데코레이터 패턴을 정리해 보자. 이 프록시 패턴을 이해하니 스프링이 어떻게 내가 만들어서 컴포넌트 스캔 대상에 넣은 클래스를 프록시로 주

cwchoiit.tistory.com

이 전 포스팅에서 프록시 패턴을 공부했는데, 문제가 여전히 있었다. 문제는 프록시 클래스를 일일이 다 하나씩 만들어 줘야 하는것. 다시 말해 프록시로 만들어 줄 클래스가 100개면 프록시 클래스도 100개가 있어야 한다는 것. 이 문제를 해결하기 위해 동적 프록시를 만들어서 단 하나의 프록시 클래스로 여러개의 클래스를 프록시화 할 수 있다.

 

728x90
SMALL

JDK 동적 프록시 (Reflection)

JAVA에서 제공해주는 Reflection 기술을 이용해서 동적 프록시를 생성해보자.

 

우선 Reflection을 이해하기 위해 예제 코드를 보자.

 

ReflectionTest.java

package com.example.advanced.jdkdynamic;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

@Slf4j
public class ReflectionTest {

    @Test
    void reflection0() {
        Hello target = new Hello();

        log.info("Start");
        String result1 = target.callA();
        log.info("result1={}", result1);

        log.info("Start");
        String result2 = target.callB();
        log.info("result2={}", result2);
    }

    static class Hello {
        public String callA() {
            log.info("callA");
            return "A";
        }

        public String callB() {
            log.info("callB");
            return "B";
        }
    }
}

 

Hello 라는 클래스에는 메서드 두 개가 있다. callA(), callB().

그리고 이 두개의 메서드를 호출하기 위해 일반적으로 Hello 객체를 만들어서 객체의 메서드를 호출할 것이다.

그 예시가 'reflection0()'이다.

 

그럼, 메서드와 객체를 주면 동적으로 그 객체의 메서드를 그때 그때 실행할 수 있게 할 수 있을까? 그 방법이 'Reflection'이다.

    @Test
    void reflection1() throws Exception {
        Class<?> classHello = Class.forName("com.example.advanced.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();
        Method methodCallA = classHello.getMethod("callA");
        Method methodCallB = classHello.getMethod("callB");

        dynamicCall(methodCallA, target);
        dynamicCall(methodCallB, target);
    }

    private void dynamicCall(Method method, Object target) throws InvocationTargetException, IllegalAccessException {
        log.info("Start");
        Object result = method.invoke(target);
        log.info("result = {}", result);
    }

 

위 코드를 보자. Class.forName()은 문자열로 클래스를 가져오는 방법이다. 그렇게 해당 클래스를 가져오면 그 클래스의 메서드를 Method 타입으로 가져올 수 있다. 그 부분이 'classHello.getMethod()'이다. 그렇게 'callA()'와 'callB()'를 가져온 후 'dynamicCall()'을 호출하는데 이 코드를 보자.

 

전달 받은 Method 객체가 가지고 있는 'invoke()'는 이 메서드를 실행하는 메서드이다. 그리고 파라미터에는 어떤 객체인지를 넘겨준다. 즉, 넘겨준 객체의 현재 메서드를 실행하는 것이다. 이렇게 객체와 메서드를 전달하면 동적으로 해당 객체의 메서드를 실행할 수 있다. 

 

이것을 'Reflection'이라고 한다. 이 방법을 이용해 동적 프록시를 만들 수 있다.

 

동적 프록시 만들기

JDK 동적 프록시는 한가지 제약이 있다. 반드시 인터페이스가 있어야 한다. 반드시.

그래서 인터페이스를 두 개 만들고 그 인터페이스를 구현한 구현 클래스 두 개, 동적 프록시 클래스 한 개를 만들어 보겠다. 

 

AInterface.java

package com.example.advanced.jdkdynamic.code;

public interface AInterface {

    String call();
}

 

BInterface.java

package com.example.advanced.jdkdynamic.code;

public interface BInterface {

    String call();
}

 

AImpl.java

package com.example.advanced.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class AImpl implements AInterface {
    @Override
    public String call() {
        log.info("A 호출");
        return "a";
    }
}

 

BImpl.java

package com.example.advanced.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class BImpl implements BInterface {
    @Override
    public String call() {
        log.info("B 호출");
        return "b";
    }
}

 

아주 간단하게 A, B 인터페이스를 만들고 그것들을 구현한 AImpl, BImpl 클래스를 만들었다.

이제 동적 프록시를 만들어 보자.

 

InvocationHandler

JDK 동적 프록시를 만드려면 'java.lang.reflect.InvocationHandler'를 구현해야 한다.

 

TimeInvocationHandler.java

package com.example.advanced.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

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

/**
 * 동적 프록시를 위한 클래스
 * */
@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 Start");

        long startTime = System.currentTimeMillis();

        Object result = method.invoke(target, args);

        long endTime = System.currentTimeMillis();

        log.info("TimeProxy End. ResultTime = {}ms", endTime - startTime);
        return result;
    }
}

 

이 또한 프록시로 사용될 것이므로 실제 객체를 'target'으로 받아야 한다. 그리고 이 클래스의 핵심인 'invoke()' 메서드를 보자.

이 메서드가 동적으로 전달받는 메서드를 실제 객체로부터 호출하는 부분이다.

 

우선 이렇게만 보고 실제 어떻게 이 동적 프록시를 만들고 사용하는지를 먼저 보자.

JdkDynamicProxyTest.java

package com.example.advanced.jdkdynamic;

import com.example.advanced.jdkdynamic.code.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import java.lang.reflect.Proxy;

/**
 * ⭐JDK 동적 프록시는 인터페이스가 필수이다.
 * */
@Slf4j
public class JdkDynamicProxyTest {

    @Test
    void dynamicA() {
        // AInterface 로 프록시를 만들고자 함.
        AInterface target = new AImpl();

        // JDK 를 이용해서 동적 프록시를 만드려면 InvocationHandler 를 상속받은 클래스가 필요하다.
        // 그 클래스는 target(프록시를 만들려는 실제 객체)을 생성자로부터 받는다.
        TimeInvocationHandler handler = new TimeInvocationHandler(target);

        // 동적 프록시를 생성 Reflection Proxy (JDK)
        AInterface proxy = (AInterface) Proxy
                .newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);

        // 동적 프록시(AInterface 를 구현한 클래스의)가 가진 메서드 call() 호출.
        // 이렇게 프록시가 자기가 가진 메서드를 호출하면 무조건 newProxyInstance 에서 전달한 handler 의 invoke 를 실행
        // invoke 는 (Object proxy, Method method, Object[] args) 이러한 파라미터를 가지는데 저 가운데 method 가 이 경우엔 call() 이 된다.
        proxy.call();
        log.info("targetClass = {}", target.getClass());
        log.info("proxyClass = {}", proxy.getClass());
    }

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

        // 다른 구현 클래스의 프록시를 만들때도 같은 TimeInvocationHandler 를 사용할 수 있음 => 동적 프록시
        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());
    }
}

 

우선 TimeInvocationHandler를 생성하자. 이 녀석은 파라미터로 실제 객체를 받는다. 이 때 실제 객체가 내가 프록시로 만들고자 하는 클래스가 된다. 그리고 'java.lang.reflect.Proxy'의 newProxyInstance()를 호출한다. 이 메서드는 세 가지 파라미터를 받는데 첫번째는 프록시로 만들 인터페이스의 ClassLoader, 두번째는 해당 인터페이스를 가지는 Class 배열, 세번째는 핸들러이다. 

 

이 모양새가 마치 템플릿처럼 동일하기 때문에 그냥 이렇게 쓰는구나를 알면 된다. 그렇게 만들어지면 반환 타입이 Object 이기 때문에 원하는 인터페이스로 형변환이 가능하다. 이러면 동적 프록시가 만들어진다. 이렇게 만들어진 동적 프록시의 타입에 따라 가지는 메서드를 호출하면 그게 위에서 만든 핸들러의 'invoke()' 메서드의 파라미터인 Method로 들어간다. 

 

아래 코드를 보자.

@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());
}

 

나는 지금 BInterface를 가지고 프록시를 만들고자 한다. 그래서 실제 객체 target을 생성한다.

이제 프록시를 만들기 위해 만들었던 InvocationHandler를 상속받은 TimeInvocationHandler 객체를 만들고 그 객체에게 실제 객체인 target을 넘겨준다. 이제 Proxy.newProxyInstance()를 호출해서 세가지 파라미터를 넣으면 프록시는 완성된다. 이 녀석의 반환 타입은 Object 이기 때문에 내가 원하는 BInterface로 형변환할 수 있다. 이 때 BInterface가 가지는 메서드 call()을 호출하면 handler의 invoke()가 호출된다. invoke()에 파라미터 중 Method는 저 call()이 되는 것이다.

 

이제 실제 프로젝트 코드에 적용해보자.

 

 

실제 프로젝트 코드에 적용하기

 

마찬가지로 핸들러 하나를 만들면 된다.

 

LogTraceBasicHandler.java

package com.example.advanced.app.proxy.config.v1_proxy.v2_dynamicproxy.handler;

import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;

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

public class LogTraceBasicHandler implements InvocationHandler {

    private final Object target;
    private final LogTrace logTrace;

    public LogTraceBasicHandler(Object target, LogTrace logTrace) {
        this.target = target;
        this.logTrace = logTrace;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        TraceStatus status = null;

        try {
            // Ex) "OrderController.request()"
            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;
        }
    }
}

 

필요한 실제 객체와 LogTrace 객체를 주입받는다. 그 외 내용은 전부 이전과 동일하다.

그리고 이제 이 핸들러를 이용해서 빈으로 프록시를 등록하자.

DynamicProxyBasicConfig.java

package com.example.advanced.app.proxy.config.v1_proxy.v2_dynamicproxy;

import com.example.advanced.app.proxy.config.v1_proxy.v2_dynamicproxy.handler.LogTraceBasicHandler;
import com.example.advanced.app.proxy.v1.OrderRepositoryV1;
import com.example.advanced.app.proxy.v1.OrderRepositoryV1Impl;
import com.example.advanced.app.proxy.v1.OrderServiceV1;
import com.example.advanced.app.proxy.v1.OrderServiceV1Impl;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1Impl;
import com.example.advanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.lang.reflect.Proxy;

@Configuration
public class DynamicProxyBasicConfig {

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));

        return (OrderControllerV1) Proxy.newProxyInstance(
                orderControllerV1.getClass().getClassLoader(),
                new Class[]{OrderControllerV1.class},
                new LogTraceBasicHandler(orderControllerV1, logTrace));
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderServiceV1 = new OrderServiceV1Impl(orderRepositoryV1(logTrace));

        return (OrderServiceV1) Proxy.newProxyInstance(
                orderServiceV1.getClass().getClassLoader(),
                new Class[]{OrderServiceV1.class},
                new LogTraceBasicHandler(orderServiceV1, logTrace));
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();

        return (OrderRepositoryV1) Proxy.newProxyInstance(
                orderRepository.getClass().getClassLoader(),
                new Class[]{OrderRepositoryV1.class},
                new LogTraceBasicHandler(orderRepository, logTrace));
    }
}

 

프록시를 만들어내는 코드 역시 위와 동일하다. 이렇게 실제 코드에도 적용해보았다. 이 동적 프록시의 단점이라고 한다면 인터페이스가 반드시 존재해야 한다는 것이다. 구체 클래스에는 적용이 불가하다.

 

이제 이렇게 만든 동적 프록시로 실제 클라이언트가 요청을 보내보자. 요청을 보내면 요청 시간에 대한 시간이 로그로 남아야 한다.

결과

2023-12-14T11:17:50.287+09:00  INFO 25388 --- [nio-8080-exec-4] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2995120d] OrderControllerV1.request()
2023-12-14T11:17:50.292+09:00  INFO 25388 --- [nio-8080-exec-4] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2995120d] |-->OrderServiceV1.orderItem()
2023-12-14T11:17:50.293+09:00  INFO 25388 --- [nio-8080-exec-4] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2995120d] |   |-->OrderRepositoryV1.save()
2023-12-14T11:17:51.294+09:00  INFO 25388 --- [nio-8080-exec-4] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2995120d] |   |<--OrderRepositoryV1.save() spentTime=1001ms
2023-12-14T11:17:51.295+09:00  INFO 25388 --- [nio-8080-exec-4] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2995120d] |<--OrderServiceV1.orderItem() spentTime=1004ms
2023-12-14T11:17:51.296+09:00  INFO 25388 --- [nio-8080-exec-4] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2995120d] OrderControllerV1.request() spentTime=1009ms

 

원하던대로 소요 시간에 대한 로그가 잘 남았다. 하지만 문제가 있다. 어떤 문제냐면, /v1/no-log로 요청했을 땐 로그가 남지 않길 원하는 경우에도 로그가 남는다. 왜냐하면 우리의 동적 프록시에는 no-log를 걸러내는 부분이 없기 때문이다. 이를 간단히 해결할 수 있다. 필터링을 사용해서 말이다.

 

LogTraceFilterHandler.java

package com.example.advanced.app.proxy.config.v1_proxy.v2_dynamicproxy.handler;

import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;
import org.springframework.util.PatternMatchUtils;

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

/**
 * LogTraceBasicHandler 의 문제는 로그가 남으면 안되는 /v1/no-log 로 호출해도 로그가 남는다.
 * 왜냐하면 이 동적 프록시가 모든 메서드에 적용되기 때문에. 그것을 해결하는 Handler.
 * */
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();
        if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
            return method.invoke(target, args);
        }

        TraceStatus status = null;

        try {
            // Ex) "OrderController.request()"
            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;
        }
    }
}

 

이 필터 기능이 추가된 동적 프록시는 생성자에서 파라미터로 'patterns'를 추가적으로 받는다. 이 패턴에 부합하는 요청만 로그기능을 추가해주고 부합하지 않는 경우엔 그냥 바로 실제 객체의 결과만을 리턴한다. 그 부분이 다음 부분이다.

String methodName = method.getName();
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
    return method.invoke(target, args);
}

 

invoke()의 파라미터로 넘어오는 method의 이름을 받는다. 그 이름이 patterns에 일치하지 않으면 로그 기능은 추가하지 않는 코드이다.

PatternMatchUtils는 스프링에서 제공해주는 유틸리티성 클래스이다. 

 

이제 이 Filter기능이 있는 동적 프록시로 다시 빈을 등록해보자.

 

DynamicProxyFilterConfig.java

package com.example.advanced.app.proxy.config.v1_proxy.v2_dynamicproxy;

import com.example.advanced.app.proxy.config.v1_proxy.v2_dynamicproxy.handler.LogTraceFilterHandler;
import com.example.advanced.app.proxy.v1.OrderRepositoryV1;
import com.example.advanced.app.proxy.v1.OrderRepositoryV1Impl;
import com.example.advanced.app.proxy.v1.OrderServiceV1;
import com.example.advanced.app.proxy.v1.OrderServiceV1Impl;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1Impl;
import com.example.advanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.lang.reflect.Proxy;

@Configuration
public class DynamicProxyFilterConfig {

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

    @Bean
    public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));

        return (OrderControllerV1) Proxy.newProxyInstance(
                orderControllerV1.getClass().getClassLoader(),
                new Class[]{OrderControllerV1.class},
                new LogTraceFilterHandler(orderControllerV1, logTrace, PATTERNS));
    }

    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderServiceV1 = new OrderServiceV1Impl(orderRepositoryV1(logTrace));

        return (OrderServiceV1) Proxy.newProxyInstance(
                orderServiceV1.getClass().getClassLoader(),
                new Class[]{OrderServiceV1.class},
                new LogTraceFilterHandler(orderServiceV1, logTrace, PATTERNS));
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();

        return (OrderRepositoryV1) Proxy.newProxyInstance(
                orderRepository.getClass().getClassLoader(),
                new Class[]{OrderRepositoryV1.class},
                new LogTraceFilterHandler(orderRepository, logTrace, PATTERNS));
    }


}

 

패턴이 추가됐다. no-log는 패턴에 들어가지 않는다. 이렇게 동적 프록시를 빈으로 등록하면 이제 /v1/no-log 로 요청해도 로그는 남지 않는다.

 

결론

JDK 동적 프록시를 이용해서 프록시를 일일이 다 만드는게 아니라 하나만 만들어서 필요한 클래스마다 프록시를 입혀봤다. 이 JDK 동적 프록시는 인터페이스가 필수이기 때문에 인터페이스가 없으면 안된다는 제약이 있다. 그래서 구체 클래스에는 적용하지 못한다는 단점이 있다. 이를 해결하기 위해서는 CGLIB라는 기술을 사용하면 된다.

728x90
반응형
LIST

'Spring Advanced' 카테고리의 다른 글

Advisor, Advice, Pointcut  (0) 2023.12.15
스프링이 지원하는 ProxyFactory  (0) 2023.12.14
Proxy/Decorator Pattern  (0) 2023.12.13
Strategy Pattern  (2) 2023.12.12
ThreadLocal을 이용해 동시성 문제 해결하기  (0) 2023.12.12