대포적인 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 를 생성하는지, 실제 예제와 함께 알아본다.