개발놀이터

동적 프록시 본문

Spring/Spring

동적 프록시

마늘냄새폴폴 2022. 1. 18. 18:52

이 포스팅은 인프런 김영한 님의 스프링 핵심 원리 고급 편을 보고 각색한 포스팅입니다. 자세한 내용은 강의를 확인해주세요

 

지금까지 프록시를 적용하기 위해 적용 대상의 숫자 만큼 많은 프록시 클래스를 만들었다. 적용 대상이 100개면 프록시 클래스도 100개를 만들었다. 

 

그런데 앞서 살펴본 것과 같이 프록시 클래스의 기본 코드와 흐름은 거의 같고, 프록시를 어떤 대상에 적용하는가 정도만 차이가 있었다. 쉽게 얘기해서 프록시의 로직은 같은데, 적용 대상만 차이가 있는 것이다. 

 

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

 

 

동적 프록시엔 JDK 동적 프록시와 CGLIB를 사용한 프록시 두개가 있다. 지금부터 차근차근 하나씩 알아보자

 

 

JDK 동적 프록시

JDK 동적 프록시는 인터페이스가 있어야 적용할 수 있다. 또한, 구현하기 위해서는 InvocationHandler의 인터페이스를 구현해야한다. 

 

TimeInvocationHandler.java

@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는 메서드 호출시 넘겨줄 인수이다.

 

그런데 잠깐 리플렉션이 뭐지?

 

쉽게 말해서 리플렉션이란 동적으로 메서드를 호출할 수 있게 만들어주는 기술이다. 예를 들어서 다음과 같은 코드가 있다고 가정해보자

 

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

        //공통 로직1 시작
        log.info("start");
        String result1 = target.callA();  //호출하는 메서드가 다름
        log.info("result={}", result1);
        //공통 로직1 종료

        //공통 로직1 시작
        log.info("start");
        String result2 = target.callB();  //호출하는 메서드가 다름
        log.info("result={}", result2);
        //공통 로직1 종료
    }

공통 로직 사이에 callA와 callB가 있다. 이 공통 로직을 메서드로 뽑고싶은 생각이 들것이다. 하지만 호출하는 메서드가 서로 다르기 때문에 메서드로 뽑는것이 쉽지않다. 이때 등장하는 것이 바로 자바언어차원에서 제공하는 리플렉션이라는 기술이다.

 

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

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

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

    }

이렇게하면 공통 로직을 메서드로 뽑아낼 수 있다. 

 

이게 바로 리플렉션이다.

 

 

이제 이어서 JDK 동적 프록시에 대해 자세히 알아보자

 

우선 앞서 설명했듯이 JDK 동적 프록시는 인터페이가 있어야 한다. 인터페이스와 그 구현체를 먼저 만들어보자

 

AInterface.java

public interface AInterface {
    String call();
}

AImpl.java

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

이제 테스트코드로 JDK 동적 프록시가 잘 실행되는지 확인해보겠다.

 

    @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("targetClass={}", proxy.getClass());
    }

Proxy.newProxyInstance는 클래스 로더 정보, 인터페이스, 그리고 핸들러 로직을 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다. 

 

그냥 이런 코드가 있구나 하고 넘어가면 된다.

실행결과

잘 보면 프록시가 잘 실행된것을 볼 수 있다.

 

프록시의 클래스 정보를 보면 proxyClass=class com.sun.proxy.$Proxy1 이부분이 동적으로 생성된 프록시 클래스 정보이다. 이것은 우리가 만든 클래스가 아니라 JDK 동적 프록시가 이름 그대로 동적으로 만들어준 프록시이다. 이 프록시는 TimeInvocationHandler 로직을 실행한다.

 

실행순서는 다음과 같다.

1. 클라이언트는 JDK 동적 프록시의 call()을 실행한다.

2. JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다. TimeInvocationHandler가 구현체로 있으므로 TimeInvocationHandler가 호출된다.

3. TimeInvocationHandler가 내부 로직을 수행하고, method,invoke(target, args)를 호출해서 target인 실제 객체를 호출한다.

4. AImpl 인스턴스의 call()이 실행된다.

5. AImpl 인스턴스의 call()이 실행이 끝나면 TimeInvocationHandler로 응답이 돌아온다. 시간 로그를 출력하고 결과를 반환한다.

 

이를 그림으로 표현하면 다음과 같다.

 

JDK 동적 프록시의 한계

JDK 동적 프록시는 인터페이스가 필수이다. 그렇다면 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까? 

 

이것은 일반적인 방법으론 어렵고 CGLIB라는 바이트코드를 조작하는 특별할 라이브러리를 사용해야한다.

 

CGLIB

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

 

참고로 우리가 CGLIB를 직접 사용하는 경우는 거의 없다. 이후에 설명할 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용할 수 있게 도와주기 때문에, 너무 깊이있게 파기보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다.

 

 

 

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

 

TimeMethodInterceptor.java

@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 methodProxy) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();

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

        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("TimeProxy 종료 resultTime = {}", resultTime);

        return result;
    }
}

TimeMethodInterceptor는 MethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다.

 

JDK 동적 프록시를 설명할 때 예제와 거의 같은 코드이다. 

 

이제 테스트코드로 잘 작동하나 확인해보자

 

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

다시 말하지만 우리는 CGLIB를 사용할 일이 더이상 없다. 테스트 코드에서 사용한 코드는 그냥 이런 코드가 있구나 하고 생각하고 넘어가면 된다. 

 

실행결과

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

 

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

 

참고로 다음은 JDK 동적 프록시가 생성한 클래스 이름이다.

 

proxyClass=class com.sun.proxy.$Proxy1

 

정리

남은 문제는 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우엔 CGLIB를 적용해야 하는지에 대한 것이다. 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해서 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 하는지도 문제이다.

 

특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되었으면 한다.

 

이 문제들을 해결해주기 위해 다음 포스팅에선 ProxyFactory에 대해서 살펴보겠다.