Spring(고급) - 동적 프록시 기술

Kwon Yongho·2023년 6월 5일
0

Spring

목록 보기
23/37
post-thumbnail

동적 프록시 기술

  1. 리플렉션
  2. JDK 동적 프록시 - 예제 코드
  3. JDK 동적 프록시 - 적용1
  4. JDK 동적 프록시 - 적용2
  5. CGLIB

1. 리플렉션

지금까지는 프록시를 이용해서 로그 추적기라는 부가 기능을 적용 할 수 있었다. 하지만 문제는 대상 클래스 수 만큼 프록시 클래스를 만들어야 한다는 점이다.

자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있다. 즉 위에 문제를 해결 할 수 있다는 소리다.

  • JDK 동적 프록시를 이해하기 위해서는 자바의 리플렉션 기술을 먼저 이해해야 한다.
  • 리플렉션 기술을 사용하면 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.

먼저 JDK 동적 프록시를 이해하기 위한 최소한의 리플렉션 기술을 알아보자

ReflectionTest

package hello.proxy.pureproxy.proxy.jdkdynamic;

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

@Slf4j
public class ReflectionTest {

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

        log.info("start");
        String result1 = target.callA(); // A
        log.info("result={}", result1);

        log.info("start");
        String result2 = target.callB(); // B
        log.info("result={}", result2);

    }

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

  • 여기서 공통 로직1과 공통 로직 2를 하나의 메서드로 뽑아서 합칠 수 있을까?
  • 호출하는 메서드인 target.callA(), target.callB()이 부분만 동적으로 처리할 수 있다면 문제를
    해결할 수 있을 듯 하다.

ReflectionTest - 코드 추가

    @Test
    void reflection() throws Exception {

        Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();
        
        // callA 메서드 정보
        Method methodCallA = classHello.getMethod("callA");
        Object result1 = methodCallA.invoke(target);
        log.info("result1={}", result1);

        // callB 메서드 정보
        Method methodCallB= classHello.getMethod("callB");
        Object result2 = methodCallA.invoke(target);
        log.info("result2={}", result2);

    }

  • Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello"): 클래스 메타정보를 획득한다. 참고로 내부 클래스는 구분을 위해 $를 사용한다.
  • classHello.getMethod("call"): 해당 클래스의 call메서드 메타정보를 획득한다.
  • methodCallA.invoke(target): 획득한 메서드 메타정보로 실제 인스턴스의 메서드를 호출한다.
  • 기존의 callA(), callB()메서드를 직접 호출하는 부분이 Method로 대체되었다. 덕분에 이제 공통 로직을 만들 수 있게 되었다.

ReflectionTest - 코드 추가

    @Test
    void reflection2() throws Exception {

        Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
        Hello target = new Hello();

        // callA 메서드 정보
        Method methodCallA = classHello.getMethod("callA");
        dynamicCall(methodCallA, target);

        // callB 메서드 정보
        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);

    }

  • dynamicCall(Method method, Object target)
    • Method method: 첫 번째 파라미터는 호출할 메서드 정보가 넘어온다.
    • Object target: 실제 실행할 인스턴스 정보가 넘어온다.

주의

  • 리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다. 하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.
  • getMethod("callB")안에 들어가는 문자를 실수로 getMethod("callZ")로 작성해도 컴파일 오류가 발생하지 않는다.

그러므로 리플렉션은 일반적으로 사용하면 안된다.
프레임워크 개발이나 또는 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.

2. JDK 동적 프록시 - 예제 코드

동적 프록시 기술을 사용하면 개발자가 직접 프록시 클래스를 만들지 않아도 된다. 이름 그대로 프록시 객체를 동적으로 런타임에 개발자 대신 만들어준다. 그리고 동적 프록시에 원하는 실행 로직을 지정할 수 있다.

AInterface

package hello.proxy.jdkdynamic.code;

public interface AInterface {
    String call();
}

AImpl

package hello.proxy.jdkdynamic.code;

import lombok.extern.slf4j.Slf4j;

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

똑같은 코드로 B도 만들었다.

JDK 동적 프록시에 적용할 로직은 InvocationHandler인터페이스를 구현해서 작성하면 된다.

JDK 동적 프록시가 제공하는 InvocationHandler

package java.lang.reflect;
public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable;
}

제공되는 파라미터는 다음과 같다.

  • Object proxy: 프록시 자신
  • Method method: 호출한 메서드
  • Object[] args: 메서드를 호출할 때 전달한 인수

TimeInvocationHandler

package hello.proxy.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 실행");
        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;
    }
    
    
}
  • TimeInvocationHandlerInvocationHandler인터페이스를 구현한다. 이렇게해서 JDK 동적 프록시에 적용할 공통 로직을 개발할 수 있다.
  • Object target: 동적 프록시가 호출할 대상
  • method.invoke(target, args): 리플렉션을 사용해서 target인스턴스의 메서드를 실행한다. args는 메서드 호출시 넘겨줄 인수이다.

JdkDynamicProxyTest

package hello.proxy.jdkdynamic;

import hello.proxy.jdkdynamic.code.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import java.lang.reflect.Proxy;

@Slf4j
public class JdkDynamicProxyTest {

    @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());
    }
}
  • Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[] {AInterface.class}, handler)
    • 클래스 로더 정보, 인터페이스, 그리고 핸들러 로직을 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.

  • proxyClass=class com.sun.proxy.$Proxy9
    • 동적으로 생성된 프록시 클래스 정보이다.
    • 이것은 우리가 만든 클래스가 아니라 JDK 동적 프록시가 이름 그대로 동적으로 만들어준 프록시이다.

실행 순서

  1. 클라이언트는 JDK 동적 프록시의 call()을 실행한다.
  2. JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다. TimeInvocationHandler가 구현체로 있으로 TimeInvocationHandler.invoke()가 호출된다.
  3. TimeInvocationHandler가 내부 로직을 수행하고, method.invoke(target, args)를 호출해서 target인 실제 객체(AImpl)를 호출한다.
  4. AImpl인스턴스의 call()이 실행된다.
  5. AImpl인스턴스의 call()의 실행이 끝나면 TimeInvocationHandler로 응답이 돌아온다. 시간 로그를 출력하고 결과를 반환한다.

JDK 동적 프록시 없이 직접 프록시를 만들어서 사용할 때와 JDK 동적 프록시를 사용할 때의 차이를 그림으로 비교해보자.

JDK 동적 프록시 도입 전 - 직접 프록시 생성

JDK 동적 프록시 도입 후

3. JDK 동적 프록시 - 적용1

JDK 동적 프록시는 인터페이스가 필수이기 때문에 V1 애플리케이션에만 적용할 수 있다.

LogTraceBasicHandler

package hello.proxy.config.v2_dynamicproxy.handler;

import hello.proxy.trace.TraceStatus;
import hello.proxy.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 {
            // 메타 정보 활용
            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;
        }
    }
}
  • LogTraceBasicHandlerInvocationHandler인터페이스를 구현해서 JDK 동적 프록시에서 사용된다.

수동 빈 등록
DynamicProxyBasicConfig

package hello.proxy.config.v2_dynamicproxy;

import hello.proxy.app.v1.*;
import hello.proxy.config.v2_dynamicproxy.handler.LogTraceBasicHandler;
import hello.proxy.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 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
        OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
                        new Class[]{OrderControllerV1.class},
                        new LogTraceBasicHandler(orderController, logTrace));
        return proxy;
    }
    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
        OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(OrderServiceV1.class.getClassLoader(),
                        new Class[]{OrderServiceV1.class},
                        new LogTraceBasicHandler(orderService, logTrace));
        return proxy;
    }
    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
        OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
                        new Class[]{OrderRepositoryV1.class},
                        new LogTraceBasicHandler(orderRepository, logTrace));
        return proxy;
    }
}
  • 이전에는 프록시 클래스를 직접 개발했지만, 이제는 JDK 동적 프록시 기술을 사용해서 각각의 Controller, Service, Repository에 맞는 동적 프록시를 생성해주면 된다.

로그 정상 작동

다시 한번 그림으로 확인해보면

4. JDK 동적 프록시 - 적용2

위 적용 사례에서는 no-log일 경우에도 로그가 남는 문제가 있다. 특정 조건을 만족할 때만 로그를 남기는 기능을 개발해보자.

package hello.proxy.config.v2_dynamicproxy.handler;

import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.util.PatternMatchUtils;

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

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 {
            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;
        }
    }
}
  • 스프링이 제공하는 PatternMatchUtils.simpleMatch(..)를 사용하면 단순한 매칭 로직을 쉽게 적용할 수 있다.
    • xxx : xxx가 정확히 매칭되면 참
    • xxx* : xxx로 시작하면 참
    • *xxx : xxx로 끝나면 참
    • *xxx* : xxx가 있으면 참

DynamicProxyFilterConfig

package hello.proxy.config.v2_dynamicproxy;

import hello.proxy.app.v1.*;
import hello.proxy.config.v2_dynamicproxy.handler.LogTraceFilterHandler;
import hello.proxy.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 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
        OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
                        new Class[]{OrderControllerV1.class},
                        new LogTraceFilterHandler(orderController, logTrace, PATTERNS)
        );
        return proxy;
    }
    @Bean
    public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
        OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(OrderServiceV1.class.getClassLoader(),
                        new Class[]{OrderServiceV1.class},
                        new LogTraceFilterHandler(orderService, logTrace, PATTERNS));
        return proxy;
    }
    @Bean
    public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
        OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
                        new Class[]{OrderRepositoryV1.class},
                        new LogTraceFilterHandler(orderRepository, logTrace, PATTERNS));
        return proxy;
    }
}
  • public static final String[] PATTERNS = {"request*", "order*", "save*"};
    • 적용할 패턴이다. request, order, save로 시작하는 메서드에 로그가 남는다.

localhost:8080/v1/no-log 실행 시

  • 로그가 안남는 것을 볼 수 있다.

(참고)DynamicProxyBasicConfig.class no-log 실행 시

  • 로그가 남는다.

JDK 동적 프록시 - 한계

  • JDK 동적 프록시는 인터페이스가 필수이다.
  • 그렇다면 V2 같은 인터페이스가 없고 클래스만 있는 경우는 어떻게 동적 프록시를 적용 할까?
  • CGLIB라는 바이트코드를 조작하는 라이브러리를 사용해야한다.

5. CGLIB

CGLIB: Code Generator Library

  • CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다.
  • CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다.
  • CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다.

테스트 코드로 간단히 알아보자.

  • 인터페이스와 구현이 있는 서비스 클래스 - ServiceInterface, ServiceImpl
  • 구체 클래스만 있는 서비스 클래스 - ConcreteService

CGLIB는 MethodInterceptor를 제공한다.

MethodInterceptor

public interface MethodInterceptor extends Callback {
    Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
  • obj : CGLIB가 적용된 객체
  • method : 호출된 메서드
  • args : 메서드를 호출하면서 전달된 인수
  • proxy : 메서드 호출에 사용

TimeMethodInterceptor

package hello.proxy.cglib.code;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
    private final Object target;

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

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();

        Object result = proxy.invoke(target, args);
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료 resultTime={}", resultTime);
        return result;
    }
}
  • TimeMethodInterceptorMethodInterceptor인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다.
  • JDK 동적 프록시를 설명할 때 예제와 거의 같다.

CglibTest

package hello.proxy.cglib;

import hello.proxy.cglib.code.TimeMethodInterceptor;
import hello.proxy.common.service.ConcreteService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.cglib.proxy.Enhancer;

@Slf4j
public class CglibTest {
    
    @Test
    void cglib() {
        ConcreteService target = new ConcreteService(); // 인터페이스 없는 클래스
        
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ConcreteService.class);
        enhancer.setCallback(new TimeMethodInterceptor(target));
        ConcreteService proxy = (ConcreteService)enhancer.create();
        
        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());
        proxy.call();
    }
}
  • Enhancer: CGLIB는 Enhancer를 사용해서 프록시를 생성한다.
  • enhancer.setSuperclass(ConcreteService.class): CGLIB는 구체 클래스를 상속 받아서 프록시를 생성할 수 있다. 어떤 구체 클래스를 상속 받을지 지정한다.
  • enhancer.setCallback(new TimeMethodInterceptor(target)): 프록시에 적용할 실행 로직을 할당한다.
  • enhancer.create(): 프록시를 생성한다.
  • CGLIB는 구체 클래스를 상속(extends)해서 프록시를 만든다.

업로드중..

참고
김영한: 스프링 핵심 원리 - 고급편(인프런)
Github - https://github.com/b2b2004/Spring_ex

0개의 댓글