Proxy에 대한 이해 – 1. JDK Proxy

대포적인 Dynamic Proxy library 에 2가지가 존재한다. CGLib ( Code Generator Library ) 과, JDK Proxy 가 그것이다.
조금만 검색하면 이 둘은 Interface 가 존재 하는지, 존재하지 않는지 여부로 사용 여부가 나뉘어짐을 알 수 있다. 이 글은 왜 interface 의 차이로 인한 proxy 생성에 차이가 생기는지, 그렇다면 그로 인해 어떤 차이점이 생기게 되는지, 자세한 Proxy 에 대한 개념에 대해 정리한다.

Proxy 패턴

Proxy 패턴은 특정 기능을 사용하기 위한 대리인 ( proxy )을 구성하고 프록시를 통해 대신 실행하게 하는 것을 말한다. 이렇게 하면 Real Subject 는 자신의 기능에만 집중할 수 있고, 그 외에 부가적인 기능을 제공하거나, 반복적으로 작성되는 기능을 Proxy 에게 위임해서 처리하게 할 수도 있다. 이렇게 설계하면 객체지향 프로그래밍의 원칙중 단일 책임 원칙을 잘 지킬수 있다는 장점도 있다.
가장 직관적으로 Transaction 과 같은 기능을 생각하면 이해하기 쉽다.

public Entity save(Entity entity){
    try{
        this.databaseClient.save(entity);
        this.databaseClient.commit();
    }catch( SQLException e ){
        this.databaseClient.rollback();
    }
}

Transaction 은 분명 기술적으로 중요한 요소이지만, 도메인 로직은 아니다. 그러나 Transaction 은 많이 사용되고 반복적으로 사용되는 코드이기도 하다. 이런 기능들이 Proxy 에게 위임하여 사용하기가 좋은 기능이다.

JDK proxy

JDK Dynamic proxy 는 Interface가 존재하는 경우에만 Proxy 를 만들 수 있는데, 객체 자체가 아닌 Interface와 Java Reflection API 를 이용해서 Proxy 객체를 생성한다.
이 JDK Proxy 는 Interface 에 대한 검증로직을 거친 뒤, ProxyFactory에 의해 실제 구현하려는 Interface와 InvocationHandler 를 포함해서 Proxy 객체를 생성한다.
이게 JDK Proxy 의 핵심이다. 이제 이 문장을 하나하나씩 이해해보자.

우선 InvocationHandler는 동적으로 생성된 프록시 객체의 메소드가 호출되면 동작하는 메소드를 정의하는 interface 이다. 즉 실제 객체에 접근하기 이전에 수행할 코드를 작성하는 부분을 말한다. Java.lang.reflect 의 하위 패키지로 존재하는데, 실제로 보자.

package java.lang.reflect;

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

invoke( ) 라는 하나의 메소드만 가지고 있으며, 이 인터페이스의 구현체는 각각, 또는 전체의 메소드의 확장 기능을 구현할 수 있고, 호출된 메소드 정보와 입력값을 파라미터로 받는다.
즉, 프록시 대상이 되는 인터페이스 각각의 메소드에 사용될 확장기능을 구현하게 된다.

이렇게 Handler 를 구현하면, java.lang.reflect 의 Proxy 를 이용해서 프록시 객체를 만든다. 한번 코드를 보자.

package java.lang.reflect;

public class Proxy implements Serializable {
    private static final long serialVersionUID = -2222568056686623797L;

    private static final Class<?>[] constructorParams = { InvocationHandler.class };

    private static final ClassLoaderValue<Constructor<?>> proxyCache = new ClassLoaderValue<>();

    protected InvocationHandler h;

    ...

    @CallerSensitive
    public static Object newProxyInstance(ClassLoader loader,
                                          Class<?>[] interfaces,
                                          InvocationHandler h) {
        Objects.requireNonNull(h);

        final Class<?> caller = System.getSecurityManager() == null
                                    ? null
                                    : Reflection.getCallerClass();

        Constructor<?> cons = getProxyConstructor(caller, loader, interfaces);

        return newProxyInstance(caller, cons, h);
    }

    private static Object newProxyInstance(Class<?> caller, // null if no SecurityManager
                                           Constructor<?> cons,
                                           InvocationHandler h) {
        /*
         * Invoke its constructor with the designated invocation handler.
         */
        try {
            if (caller != null) {
                checkNewProxyPermission(caller, cons.getDeclaringClass());
            }

            return cons.newInstance(new Object[]{h});
        } catch (IllegalAccessException | InstantiationException e) {
            throw new InternalError(e.toString(), e);
        } catch (InvocationTargetException e) {
            Throwable t = e.getCause();
            if (t instanceof RuntimeException) {
                throw (RuntimeException) t;
            } else {
                throw new InternalError(t.toString(), t);
            }
        }
    }

프록시를 생성하기 위해서 requireNonNull( Incovationhandler h ) 를 요구하고 있고, 그 파라미터로 Interface 를 받는다. 그래서, 이 JDK Proxy 는 반드시 Interface 를 필요로하고, 원본 객체로의 실행 진입점이 되는 InvocationHandler 에서 어떤 함수를 실행시켰는지, 각 함수에 따라서 어떻게 동작해야 할 지에 대한 로직이나 올바르지 않은 타겟이 주어진 경우에 대한 검증로직이 포함되어야 하는 이유가 여기에 있다.
JDK Proxy 는 Interface 에 대한 Proxy 만 만들어 주기 때문에, 실제 Object를 잘못 주입하는 경우가 생겨날 수도 있다. 또, 원본 객체에 여러 기능이 포함되어 있어, 다양한 확장기능이 필요할 수도 있기 때문에 그렇다.

예제 – Spring 에서

대표적으로 Proxy 패턴이 사용되는 곳은 Spring AOP 일 것이다. 그렇다면 Spring AOP 에서 JDK Proxy 를 어떻게 구현하고 있는지를 확인해보자.
이 코드는 Spring AOP에서 구현하는 JdkDynamicAopProxy 클래스의 일부분이다.

@Override
public Object getProxy(@Nullable ClassLoader classLoader) {
    if (logger.isTraceEnabled()) {
        logger.trace("Creating JDK dynamic proxy: " + this.advised.getTargetSource());
    }
    return Proxy.newProxyInstance(determineClassLoader(classLoader), this.cache.proxiedInterfaces, this);
}

...

@Override
@Nullable
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Object oldProxy = null;
    boolean setProxyContext = false;

    TargetSource targetSource = this.advised.targetSource;
    Object target = null;

    try {
        if (!this.cache.equalsDefined && AopUtils.isEqualsMethod(method)) {
            // The target does not implement the equals(Object) method itself.
            return equals(args[0]);
        }
        else if (!this.cache.hashCodeDefined && AopUtils.isHashCodeMethod(method)) {
            // The target does not implement the hashCode() method itself.
            return hashCode();
        }
        else if (method.getDeclaringClass() == DecoratingProxy.class) {
            // There is only getDecoratedClass() declared -> dispatch to proxy config.
            return AopProxyUtils.ultimateTargetClass(this.advised);
        }
        else if (!this.advised.opaque && method.getDeclaringClass().isInterface() &&
                method.getDeclaringClass().isAssignableFrom(Advised.class)) {
            // Service invocations on ProxyConfig with the proxy config...
            return AopUtils.invokeJoinpointUsingReflection(this.advised, method, args);
        }
 
        Object retVal;

        if (this.advised.exposeProxy) {
            // Make invocation available if necessary.
            oldProxy = AopContext.setCurrentProxy(proxy);
            setProxyContext = true;
        }

        // Get as late as possible to minimize the time we "own" the target,
        // in case it comes from a pool.
        target = targetSource.getTarget();
        Class<?> targetClass = (target != null ? target.getClass() : null);

        // Get the interception chain for this method.
        List<Object> chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);

        // Check whether we have any advice. If we don't, we can fall back on direct
        // reflective invocation of the target, and avoid creating a MethodInvocation.
        if (chain.isEmpty()) {
            // We can skip creating a MethodInvocation: just invoke the target directly
            // Note that the final invoker must be an InvokerInterceptor so we know it does
            // nothing but a reflective operation on the target, and no hot swapping or fancy proxying.
            Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
            retVal = AopUtils.invokeJoinpointUsingReflection(target, method, argsToUse);
        }
        else {
            // We need to create a method invocation...
            MethodInvocation invocation =
                    new ReflectiveMethodInvocation(proxy, target, method, args, targetClass, chain);
            // Proceed to the joinpoint through the interceptor chain.
            retVal = invocation.proceed();
        }

        // Massage return value if necessary.
        Class<?> returnType = method.getReturnType();
        if (retVal != null && retVal == target &&
                returnType != Object.class && returnType.isInstance(proxy) &&
                !RawTargetAccess.class.isAssignableFrom(method.getDeclaringClass())) {
            // Special case: it returned "this" and the return type of the method
            // is type-compatible. Note that we can't help if the target sets
            // a reference to itself in another returned object.
            retVal = proxy;
        }
        else if (retVal == null && returnType != void.class && returnType.isPrimitive()) {
            throw new AopInvocationException(
                    "Null return value from advice does not match primitive return type for: " + method);
        }
        if (coroutinesReactorPresent && KotlinDetector.isSuspendingFunction(method)) {
            return COROUTINES_FLOW_CLASS_NAME.equals(new MethodParameter(method, -1).getParameterType().getName()) ?
                    CoroutinesUtils.asFlow(retVal) : CoroutinesUtils.awaitSingleOrNull(retVal, args[args.length - 1]);
        }
        return retVal;
    }
    finally {
        if (target != null && !targetSource.isStatic()) {
            // Must have come from TargetSource.
            targetSource.releaseTarget(target);
        }
        if (setProxyContext) {
            // Restore old proxy.
            AopContext.setCurrentProxy(oldProxy);
        }
    }
}

원래 객체와 Proxy 객체 간에 일관성을 유지하기 위해 equals 와 hashCode method 를 호출하는 경우에 원본객체의 equals 와 hashCode 를 호출하게 하고, 3번째 else if 문에서는 호출 함수가 DecoratingProxy 인터페이스의 구현함수인 getDecoratedClass() 인 경우를 처리해주고 있다. ( 주석에도 나와있지만 getDecoratedClass 함수 딱 하나만을 가지고 있는 인터페이스이고, 이 인터페이스의 역할은 원본 객체의 class 함수를 가져오는 역할을 한다. 이 기능을 활용하기 위해 AopUtils 함수를 활용하고 있는데, ultimateTargetClass 함수는 최종 타겟 클래스를 찾아내는 기능을 구현하고 있다. )
이후에는 targetSource.getTarget() 을 통해 비즈니스로직이 존재하는 대상 객체를 가져오고 getInterceptorsAndDynamicInterceptionAdvice(method, targetClass) 를 통해 대상 Method, 혹은 Class 전체에 적용된 Advice 들을 모두 가져와서 Chain 을 구성하고, Chain이 존재하지 않으면 ( 어떤 Advice 도 없으면 ) 그냥 원본 Class 를 바로 수행하고, 여러 Advice 들이 있다면 ReflectiveMethodInvocation 를 통해 Advice 를 수행하고 결과값을 돌려주는 형식으로 사용하고 있다.

위에서 살펴본 것 처럼, Proxy 객체를 이용해서 AOP 의 Advice 를 잘 사용하고 있는 것을 알 수 있다.

장점과 단점

JDK Proxy 는 Proxy 패턴을 Java의 Reflection 을 이용해서 잘 구현해냈다. 프록시 객체를 직접 만들어 줄 필요가 없이, 알아서 만들어주는 장점이 있다. 또한 차후에 작성할 ByteCode 를 조작하는것 보다는 덜 복잡하고, 코드레벨에서 이해하기 좋다. 또한, 별다른 별도의 라이브러리가 없어도 Proxy 를 만들수 있다.
하지만 단점이 Interface 가 없으면 Proxy 를 생성할 수 없기 때문에, Interface 가 없는 객체는 프록시를 적용할 수가 없다는 것과 내부적으로 Java Reflection API 라는 비싼 Cost 의 API 를 사용하기 때문에 성능에 문제가 될 수 있다는 점이다. 그리고 별도의 Library 가 없어도 Proxy를 만들 수 있다는 건 간단한 수준에서만 그렇고, 복잡한것들이 포함되면 SpringContainer 나, 기타 다른 라이브러리가 필요하게 된다.

그래서 Interface 가 없는 객체에 대해서도 Proxy 객체를 생성할 수 있게 Byte코드를 조작하는 CGLib 을 이용해서 주어진 객체를 직접 조작하는 방식이 등장했다. 다음 글에서는 이 CGLib 을 이용해서 어떻게 Proxy 를 생성하는지, 실제 예제와 함께 알아본다.

Leave a Comment