본문 바로가기

BackEnd

[Java, Spring] 리플렉션을 활용한 프록시 동적 생성

반응형

해당 게시물은 김영한 강사님의 스프링 핵심원리 고급편 을 학습 후 정리한 내용이다.

 

스프링 핵심 원리 - 고급편 - 인프런 | 강의

스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., - 강의 소개 | 인프런

www.inflearn.com

탬플릿 메서드 패턴, 프록시 패턴을 통해 다음의 효과를 거두었다.

  • 변하지 않는 부분의 추상화
  • 변하는 부분에서 변하는 부분의 분리

프록시 패턴의 문제점은 100개의 클래스 > 100개의 프록시라는 개발 공수의 증가였다.

사실 프록시 로직은 거의 유사한데, 변하는 부분만 동적으로 생성하면 프록시가 하나만 있으면 되지 않을까?

호출하는 메서드인 target.callA() , target.callB() 이 부분만 동적으로 처리할 수 있다면 문제를 해결할 수 있을 듯 하다.

@Slf4j
public class ReflectionTest {
    @Test
    void reflection0() {
        Hello target = new Hello(); // 공통 로직1 시작
        log.info("start");
        String result1 = target.callA(); // 호출하는 메서드가 다름
        log.info("result={}", result1); // 공통 로직1 종료
        // 공통 로직2 시작 log.info("start");
        String result2 = target.callB(); // 호출하는 메서드가 다름 log.info("result={}", result2);
        // 공통 로직2 종료 }
        @Slf4j
        static class Hello {
            public String callA() {
                log.info("callA");
                return "A";
            }

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

이럴 때 사용하는 기술이 바로 리플렉션이다.
리플렉션은 클래스나 메서드의 메타정보를 사용해서 동적으로 호출하는 메서드를 변경할 수 있다. 

참고: 람다를 사용해서 공통화 하는 것도 가능하다. 람다를 사용하기 어려운 상황이라 가정하자. 
    @Test
    void reflection1() 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 = methodCallB.invoke(target);
        log.info("result2={}", result2);
    }
  • Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello") :
    • 클래스 메타정보를 획득한다.
    • 내부 클래스는 구분을 위해 $ 를 사용한다.
  • classHello.getMethod("call")
    • 해당 클래스의 call 메서드 메타정보를 획득한다.
  • methodCallA.invoke(target) 
    • 획득한 메서드 메타정보로 실제 인스턴스의 메서드를 호출한다.
      • methodCallA Hello 클래스의 callA() 이라는 메서드 메타정보이다.
    • methodCallA.invoke(인스턴스) 를 호출하면서 인스턴스를 넘겨주면 해당 인스턴스의 callA() 메서드를 찾아서 실행한다.
      • 여기서는 target 의 callA() 메서드를 호출한다.

이처럼 메서드 정보를 획득해서 메서드를 호출하면 클래스나 메서드 정보를 동적으로 변경할 수 있다.
기존의 callA() , callB() 메서드를 직접 호출하는 부분이 Method 로 대체되었다. 덕분에 이제 공통 로직을 만들 수 있게 되었다.

    @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);
    }
  • dynamicCall(Method method, Object target)
    • 공통 로직1, 공통 로직2를 한번에 처리할 수 있는 통합된 공통 처리 로직이다.
    • 첫번째 파라미터는 호출할 메서드 정보가 넘어온다.
      • 직접 메서드 이름을 호출하는 대신 Method라는 메타정보를 통해서 호출할 메서드 정보가 동적으로 제공된다.
    • 두번째 파라미터로 실제 메서드를 실행할 인스턴스 정보가 넘어온다.
      • 타입이 Object라는 것은 어떠한 인스턴스도 받을 수 있다는 것이다.
      • method.invoke(target)를 사용할 때 호출할 클래스와 메서드 정보가 다르면 예외가 발생한다.

서로 다른 메서드 호출(target.callA() , target.callB() 코드)을

리플렉션을 사용해 Method라는 메타정보로 추상화하여 공통로직을 만들었다.

리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다.

하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.

만약 클래스에 callA가 없는데 callA를 쓰거나 오타를 넣어도 컴파일 시점엔 오류가 발생하지 않는다.

코드를 실행해 봐야 오류가 있는지 알 수 있다.

가장 좋은 오류는 개발자가 즉시 확인할 수 있는 컴파일 오류이고,
가장 무서운 오류는 사용자가 직접 실행할 때 발생하는 런타임 오류다.

 

따라서 리플렉션은 일반적으로 사용하면 안된다.

지금까지 프로그래밍 언어가 발달하면서 타입 정보를 기반으로 컴파일 시점에 오류를 잡아준 덕분에 개발자가 편리하게 일할 수 있었다.

리플렉션은 그것에 역행하는 방식이다.

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


JDK 동적 프록시

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

먼저 자바 언어가 기본으로 제공하는 JDK 동적 프록시를 알아보자.
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어준다.
따라서 인터페이스가 필수이다.

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

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable;
}
  • Object proxy : 프록시 자신
  • Method method : 호출한 메서드
  • Object[] args : 메서드를 호출할 때 전달한 인수

아래와 같은 인터페이스 및 구현체가 있다고 하자.

public interface AInterface {
    String call();
}

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

public interface BInterface {
    String call();
}

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

 아래와 같이 구현한다.

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

아래와 같이 JDK 동적 프록시를 사용한다.

@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());
    }
}
  • new TimeInvocationHandler(target) :
    • 동적 프록시에 적용할 핸들러 로직이다.
  • (BInterface) Proxy.newProxyInstance(BInterface.class.getClassLoader(), new Class[] { BInterface.class }, handler);
    • 동적 프록시는 java.lang.reflect.Proxy 를 통해서 생성할 수 있다.
    • 클래스 로더 정보, 인터페이스, 그리고 핸들러 로직을 넣어주면 된다.
      • 클래스는 여러 인터페이스를 구현할 수 있기에 인터페이스 정보를 넣어줘야 한다.
    • 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.

 

실행 순서 그림

실행 순서

  • 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 로 응답이 돌아온다. 시간 로그를 출력하고 결과를 반환한다.

timeInvocationHandler 하나의 클래스만 만들었는데, A와 B를 위한 프록시가 각각 생성되었다.

  proxyClass=class com.sun.proxy.$Proxy1 //dynamicA
  proxyClass=class com.sun.proxy.$Proxy2 //dynamicB

그리고 클라이언트는 interface의 call 메서드만 호출했을 뿐인데, 마법같은 일이 일어났다.

프록시 핸들러의 invoke를 호출한 것과 같은 일이 일어난 것이다.

JDK 프록시가 인터페이스를 사용해 프록시의 메서드 호출을 핸들러의 invoke 호출로 바꿔주는 역할을 수행하였다.

JDK 동적 프록시 기술 덕분에 적용 대상 만큼 프록시 객체를 만들지 않아도 된다.
그리고 같은 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있다.
만약 적용 대상이 100개여도 동적 프록시를 통해서 생성하고, 각각 필요한 InvocationHandler 만 만들어서 넣어주면 된다.

결과적으로 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고,
부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.

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

JDK 동적 프록시 도입 후

점선(동적프록시)를 개발자가 구현할 필요가 없어졌다.

동적 프록시 도입 전 런타임 객체 의존 관계
JDK 동적 프록시 도입 후 런타임 객체 의존 관계

 

기존 애플리케이션에 적용

로그 적용을 위한 InvocationHandler 구현 클래스를 만든다.

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;
        }
    }
}
  • LogTraceBasicHandler
    • InvocationHandler 인터페이스를 구현해서 JDK 동적 프록시에서 사용된다.
  • private final Object target
    •  프록시가 호출할 대상이다.
  • String message = method.getDeclaringClass().getSimpleName() + "." ...
    • LogTrace 에 사용할 메시지이다.
    • 프록시를 직접 개발할 때는 "OrderController.request()" 와 같이 프록시마다 호출되는 클래스와 메서드 이름을 직접 남겼다.
    • 이제는 Method 를 통해서 호출되는 메서드 정보와 클래스 정보를 동적으로 확인할 수 있기 때문에 이 정보를 사용하면 된다.

동적 프록시를 사용하도록 수동 빈 등록을 설정한다.

@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 에 맞는 동적 프록시를 생성해주면 된다.
  • LogTraceBasicHandler
    • 동적 프록시를 만들더라도 LogTrace 를 출력하는 로직은 모두 같기 때문에 프록시는 모두 LogTraceBasicHandler 를 사용한다.
     

클래스 의존 관계 변화

런타임 객체 의존 관계 변화

 

기능 추가 : 메서드 명 필터링

핸들러의 생성자에 patterns 파라미터를 추가하고, invoke에 필터링 로직을 추가한다.

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;
        }
    }
}

Config 파일에서 프록시를 생성할 때 파라미터를 넘겨준다.

부가 기능이 변경되었음에도, 비즈니스 로직 코드는 전혀 건드리지 않았다.

    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;
    }

JDK 동적 프록시의 한계

JDK 동적 프록시는 인터페이스가 필수이다.
클래스만 있는 경우는 CGLIB이라는 바이트코드를 조작하는 특별한 라이브러리를 사용한다.

CGLIB - 소개

CGLIB: Code Generator Library

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

CGLIB를 직접 사용하는 경우는 거의 없다.

이후에 설명할 스프링의 ProxyFactory 라는 것이 이 기술을 편리하게 사용하게 도와주기 때문이다.

너무 깊이있게 파기 보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다.

다양한 상황을 설명하기 위해서 먼저 공통으로 사용할 예제 코드를 만들어보자.

  • 인터페이스와 구현이 있는 서비스 클래스 - ServiceInterface , ServiceImpl
  • 구체 클래스만 있는 서비스 클래스 - ConcreteService
public interface ServiceInterface {
    void save();

    void find();
}

@Slf4j
public class ServiceImpl implements ServiceInterface {
    @Override
    public void save() {
        log.info("save 호출");
    }

    @Override
    public void find() {
        log.info("find 호출");
    }
}

@Slf4j
public class ConcreteService {
    public void call() {
        log.info("ConcreteService 호출");
    }
}

CGLIB 코드

JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler 를 제공했듯이,
CGLIB는 MethodInterceptor 를 제공한다.

package org.springframework.cglib.proxy;

public interface MethodInterceptor extends Callback {
    Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
  • obj : CGLIB가 적용된 객체
  • method : 호출된 메서드
  • args : 메서드를 호출하면서 전달된 인수
  • proxy : 메서드 호출에 사용
@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;
    }
}
  • TimeMethodInterceptor
    • MethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다.
    • JDK 동적 프록시를 설명할 때 예제와 거의 같은 코드이다.
  • Object target : 프록시가 호출할 실제 대상
  • proxy.invoke(target, args) : 실제 대상을 동적으로 호출한다.
    • method 를 사용해도 되지만, CGLIB는 성능상 MethodProxy proxy 를 사용하는 것을 권장한다.

실제 실행 코드

@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();
    }
}
JDK 동적 프록시는 인터페이스를 구현(implement)해서 프록시를 만든다.
CGLIB는 구체 클래스를 상속 (extends)해서 프록시를 만든다.

ConcreteService 는 인터페이스가 없는 구체 클래스이다. 여기에 CGLIB를 사용해서 프록시를 만든다.

  • Enhancer : CGLIB는 Enhancer 를 사용해서 프록시를 생성한다.
  • enhancer.setSuperclass(ConcreteService.class) : CGLIB는 구체 클래스를 상속 받아서 프록시를 생성할 수 있다. 어떤 구체 클래스를 상속 받을지 지정한다.
  • enhancer.setCallback(new TimeMethodInterceptor(target))
    • 프록시에 적용할 실행 로직을 할당한다.
  • enhancer.create() :
    • 프록시를 생성한다
    • enhancer.setSuperclass(ConcreteService.class) 에서 지정한 클래스를 상속 받아서 프록시가 만들어진다.

 

CGLIB가 생성한 프록시 클래스 이름

CGLIB를 통해서 생성된 클래스의 이름은 다음과 같다.

  • ConcreteService$$EnhancerByCGLIB$$25d6b0e3

CGLIB가 동적으로 생성하는 클래스 이름은 다음과 같은 규칙으로 생성된다.

대상클래스$$EnhancerByCGLIB$$임의코드

다음은 JDK Proxy가 생성한 클래스 이름이다.

proxyClass=class com.sun.proxy.$Proxy1

그림으로 정리

CGLIB의 한계

클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.

  • 부모 클래스의 생성자를 체크해야 한다.
    • CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다.
  • 클래스에 final 키워드가 붙으면 상속이 불가능하다.
    • CGLIB에서는 예외가 발생한다.
  • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다.
    • CGLIB에서는 프록시 로직이 동작하지 않는다.

ProxyFactory를 사용하면 이런 제약을 극복할 수 있다.

반응형