디자인 패턴 - 동적 프록시(Dynamic Proxy). 동적 프록시 직접 구현해보고 Retrofit 의 create 다시 보기
이전 '디자인 패턴 프록시 패턴 글' 에서 Proxy pattern(프록시 패턴)에 대해 알아 보았습니다.
그리고 'Retrofit 알아보기 (2) - create 뜯어보기' 에서도 프록시 패턴을 사용하는 부분이 나왔었죠.
https://sh1mj1-log.tistory.com/169
https://sh1mj1-log.tistory.com/168
사실 Retrofit 에서는 동적 프록시를 사용하는 것으로 구현되어 있습니다.
그런데 동적 프록시는 무엇이고, 왜 필요한 것일까요?
기존 프록시 패턴의 문제점
프록시는 타겟 코드의 수정 없이 접근 제어 혹은 부가 기능을 추가하기 위해서 주로 사용된다고 했었죠?
하지만 이전 글에서도 말했듯, 프록시 패턴에서는 단점이 존재합니다.
프록시 패턴을 사용하기 위해서는 대상 클래스의 수 만큼의 프록시 클래스를 하나하나 만들어 주어야 합니다.
그러면 그 안에 반복되는 코드도 그만큼 늘어나겠죠?
문제점을 쉽게 보기 위해서 직접 코드로 봅시다.
문제점 ⓵ 인터페이스를 구현한 프록시 클래스를 직접 만들어야 함.
`ServiceInterface`
public interface ServiceInterface {
String hello(String name);
String helloWorld(String name);
String hiWorld(String name);
}
위 `ServiceInterface` 에 대한 프록시를 만드려면 `ServiceInterface` 를 구현하는 프록시 클래스를 만들어주어야 합니다.
그런데 프록시를 사용하기 위해서는 대상 클래스의 수만큼의 프록시 클래스를 하나하나 만들어주어야 합니다.
예를 들어서 리턴되는 `String` 의 모든 문자를 대문자로 만드는 프록시와 소문자로 만드는 프록시를 만들고 싶다고 합시다.
그렇다면 두 가지 프록시 클래스를 직접 만들어 주어야 하는 것이죠.
` LowerProxy`
public class LowerProxy implements ServiceInterface {
Service service;
public LowerProxy(ServiceInterface serviceInterface) {
this.serviceTarget = serviceInterface;
}
@Override
....
....
}
`UpperProxy`
public class UpperProxy implements ServiceInterface {
Service service;
public LowerProxy(ServiceInterface serviceInterface) {
this.serviceTarget = serviceInterface;
}
@Override
....
....
}
위처럼 하나하나 만들어 주어야 하는 문제가 있습니다.
문제점 ⓶ 프록시 클래스 메소드 내에 중복이 발생
이번에는 `UpperProxy` 클래스를 자세히 구현해봅시다.
public class UpperProxy implements ServiceInterface {
ServiceInterface service;
@Override
public String hello(String name) {
return service.hello(name).toUpperCase();
}
@Override
public String helloWorld(String name) {
return service.helloWorld(name).toUpperCase();
}
@Override
public String hiWorld(String name) {
return service.hiWorld(name).toUpperCase();
}
}
위 클래스의 세 메서드 `hello` , `helloWorld`, `hiWorld` 모두 `service` 에서 리턴하는 `String` 을 대문자로 바꾸어 주는 똑같은 일을 합니다.
이렇게 같은 일을 할 때의 코드 중복이 발생한다는 것도 문제가 됩니다.
이 문제점들을 동적 프록시를 사용해서 해결할 수 있습니다.
동적 프록시(Dynamic Proxy)
동적 프록시는 컴파일 시점이 아닌, 런타임 시점에 프록시 클래스를 만들어주는 방식입니다.
동적 프록시를 만들기 위해 어떻게 코드를 작성해야 하는지, API 를 사용하는 코드부터 먼저 봅시다.
ServiceInterface serviceA = (ServiceInterface) Proxy.newProxyInstance(
ServiceInterface.class.getClassLoader(),
new Class[]{ServiceInterface.class},
new UppercaseHandler(new ServiceA())
);
일단은 이런식으로 사용하면 됩니다.
그렇다면 위 코드가 어떤 것을 의미하는지를 알아봐야겠죠?
newProxyInstance()
`Java.lang.reflect.Proxy` 클래스의 `newProxyInstance()` 메서드를 이용합니다.
@CallerSensitive
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h) throws IllegalArgumentException
- 패러미터
- `ClassLoader loader` : (동적) 프록시 클래스를 만들 클래스 로더.
interface 에서 클래스 로더를 얻어오는 것이 일반적임. - `Class<?>[] interfaces` : 프록시 클래스가 구현할 인터페이스 목록(배열).
메서드를 통해서 생성될 Proxy 객체가 구현할 interface 를 정의함. (어떤 인터페이스에 대해서 프록시를 만들 것인지) - `InvocationHandler h`: 프록시의 메서드(`invoke`)가 호출되었을 때 실행될 핸들러.
`InvocationHandler` 인터페이스의 구현체임.
- `ClassLoader loader` : (동적) 프록시 클래스를 만들 클래스 로더.
- 리턴
- 메서드 호출을 지정된 invocation handler 에 보내는 인터페이스의 프록스 클래스 인스턴스를 리턴.
여기서 클래스 로더(`Class Loader`)는 간단히 말하면 Java 애플리케이션에서 클래스 파일을 로드하고 해당 클래스를 메모리에 로드하는 역할을 하는 핵심 컴포넌트입니다. 클래스 로더는 JVM 내에서 동작하며 클래스 파일을 로딩하는 과정을 관리합니다.
그렇다면 이제 `InvocationHandler` 가 궁금하실텐데요. 바로 알아봅시다.
InvocationHandler
`InvocationHandler` 는 `invoke` 라는 메서드 하나만 가지고 있는 인터페이스입니다. 이러한 인터페이스를 함수형 인터페이스라고 하죠.
`invoke()` 메서드는 동적으로 생성될 프록시의 어떤 메서드든 호출되었을 때 호출되는 메서드로, 여기서 어떤 메서드에 기능을 확장(추가)할지 결정할 수 있습니다. 또 추가될 기능을 구현할 수 있습니다.
즉, 클라이언트가 `hello()` 메서드, `sayHello()` 등 어떤 메서드를 호출하든, `invoke()` 메서드가 호출되고, 클라이언트에서 어떤 메서드를 호출했는지에 대한 정보와 메서드에 전달한 인자는 `invoke()` 메서드의 인자로 전달되는 것입니다.
`UppercaseHandler` 클래스를 만들어봅시다.
`UppercaseHandler`
public class UppercaseHandler implements InvocationHandler {
Object target;
public UppercaseHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object ret = method.invoke(target, args);
if (ret instanceof String && method.getName().startsWith("hello"))
return ((String) ret).toUpperCase();
else
return ret;
}
}
`invoke` 메서드의 설명
- 파라미터
- `Object proxy` : 메서드가 호출된 프록시 객체.
이 프록시 객체를 통해서 실제 메서드 호출을 수행하거나, 추가 동작 처리 가능. - `Method method` : 호출한 메서드에 대한 `java.lang.reflect.Method` 객체
이 객체로서 호출된 메서드의 이름, 리턴 타입, 패러미터 정보를 얻을 수 있음. - `Object[] args` : 호출된 메서드에 전달된 패러미터(인수) 배열.
메서드에 전달된 인수를 확인하고, 이를 기반으로 원하는 동작을 수행할 수 있음.
- `Object proxy` : 메서드가 호출된 프록시 객체.
위에서 만든 `ServiceInterface` 와 `UppercaseHandler` 로 테스트를 해봅시다.
위 `UppercaseHandler` 에서 나오는 것처럼 우리는 `hello` 로 시작하는 메서드만 대문자로 바꿀 것입니다.
`ServiceA` & `ServiceB`
public class ServiceA implements ServiceInterface{
@Override
public String hello(String name) {
return "A hello " + name;
}
@Override
public String helloWorld(String name) {
return "A hello World " + name;
}
@Override
public String hiWorld(String name) {
return "A hi World " + name;
}
}
public class ServiceB implements ServiceInterface{
@Override
public String hello(String name) {
return "B hello " + name;
}
@Override
public String helloWorld(String name) {
return "B hello World " + name;
}
@Override
public String hiWorld(String name) {
return "B hi World " + name;
}
}
`ServiceATest` & `ServiceBTest`
class ServiceATest {
ServiceInterface serviceInterface = (ServiceInterface) Proxy.newProxyInstance(
ServiceInterface.class.getClassLoader(),
new Class[]{ServiceInterface.class},
new UppercaseHandler(new ServiceA())
);
@Test
void hello() {
System.out.println(serviceInterface.hello("shimji"));
}
@Test
void helloWorld() {
System.out.println(serviceInterface.helloWorld("shimji"));
}
@Test
void hiWorld() {
System.out.println(serviceInterface.hiWorld("shimji"));
}
}
class ServiceBTest {
ServiceInterface serviceInterface = (ServiceInterface) Proxy.newProxyInstance(
ServiceInterface.class.getClassLoader(),
new Class[]{ServiceInterface.class},
new UppercaseHandler(new ServiceB())
);
@Test
void hello() {
System.out.println(serviceInterface.hello("shimji"));
}
@Test
void helloWorld() {
System.out.println(serviceInterface.helloWorld("shimji"));
}
@Test
void hiWorld() {
System.out.println(serviceInterface.hiWorld("shimji"));
}
}
테스트 코드의 메서드 내부를 보면 완전히 코드가 같은 것을 알 수 있습니다.
여기서는 `Proxy.newProxyInstance` 라는 코드에서의 패러미터 `UppercaseHandler` 의 패러미터만 다른 것을 볼 수 있습니다.
클라이언트에서 정해주는 이 패러미터에 따라서 실제 어떤 서비스 구현체의 메서드를 실행할지 결정해줍니다.
테스트 실행 결과
결론적으로 우리는 프록시 클래스를 따로 만들지 않고, 동적으로 프록시를 생성 하도록 만들었습니다.
인터페이스를 직접 구현하여 프록시 클래스를 일일이 만들어야 했던 ⓵번 문제를 해결한 것입니다.
만약 그래야 한다면 인터페이스의 수많은 메서드를 프록시 클래스에서 오버라이드 해야 했을 텐데 말이죠.
게다가 프록시 클래스의 메서드 내에서 동일한 동작을 했을 때 코드 중복이 생기던 ⓶번 문제를 해결 했습니다.
`UppercaseHandler` 라는 클래스에서 인터페이스 메서드의 정보에 대해서 검사를 한 후, 그에 따른 동작을 추가한 것을 확인할 수 있습니다.
클래스 의존성 구조
의존성 구조는 아래처럼 생성되게 됩니다.
위 런타임 의존 관계에서 보이는 것처럼 프록시 객체는 런타임에 알아서 생성됩니다. 바로 reflection API 가 해결해주는 것이죠.
정리하자면 아래와 같습니다.
- 클라이언트가 메시지를 전송할 때는 메시지 수신자가 프록시 객체인지 혹은 서비스 구현체인지 모름.
- 동적으로 만들어진 프록시 객체는 사용자가 요청한 메서드의 정보와 메서드의 인자를
`InvocationHandler` 구현체(`UppercaseHandler`)의 `invoke()` 메서드의 인자로 전달해서 호출함. - `InvocationHandler` 의 `invoke()` 메서드에서 서비스 타겟의 어떤 메서드에 적용할지 검사,
메서드에 따라 타겟의 원래 메서드를 호출해서 결과를 담아둔 후에 추가 기능을 적용해서 리턴하거나 결과를 그냥 리턴.
여기서는 `ServiceInterface` 인터페이스를 구현한 프록시 클래스를 직접 만들지 않아도 되어서 번거로운 작업이 없어지며 의존성도 줄였지만, `UppercaseHandler` 라는 `InvocationHandler`를 구현한 클래스를 만들었습니다.
InvocationHandler 를 구현한 클래스도 만들기 싫은데..
`InvocationHandler` 를 구현한 클래스를 따로 작성하지 않아도 되는 방법도 있습니다.
앞에서 말했듯이 `InvocationHandler` 는 함수형 인터페이스라고 했죠?
자바에서는 이러한 함수형 인터페이스를 익명 객체로 만들어서 다른 함수의 인자로 넣을 수 있습니다. 즉, 따로 클래스를 만들지 않아도 된다는 말입니다.
이번에는 `LowercaseHandler` 의 역할을 하는 handler 를 만들어서 테스트해보겠습니다.
`ServiceCTest`
class ServiceCTest {
ServiceInterface serviceInterface = (ServiceInterface) Proxy.newProxyInstance(
ServiceInterface.class.getClassLoader(),
new Class[]{ServiceInterface.class},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
proxy = new ServiceA();
Object ret = method.invoke(proxy, args);
if (ret instanceof String && method.getName().startsWith("hello"))
return ((String) ret).toLowerCase();
else
return ret;
}
}
);
@Test
void hello() {
System.out.println(serviceInterface.hello("SHIMJI"));
}
@Test
void helloWorld() {
System.out.println(serviceInterface.helloWorld("SHIMJI"));
}
@Test
void hiWorld() {
System.out.println(serviceInterface.hiWorld("SHIMJI"));
}
}
이런 식으로 익명 객체를 만들어서 사용할 수도 있습니다. 혹은 람다를 사용할 수도 있지요.
class ServiceCTest {
ServiceInterface serviceInterface = (ServiceInterface) Proxy.newProxyInstance(
ServiceInterface.class.getClassLoader(),
new Class[]{ServiceInterface.class},
(proxy, method, args) -> {
// ...
}
);
물론 익명 객체나 람다는 생성자를 가질 수 없습니다. 그래서 위 코드에서는 `invoke` 메서드 내부에서 `proxy = new ServiceA()` 와 같이 `proxy` 를 지정해주었습니다.
상황에 따라서는 이 handler 구현체가 꼭 생성자를 가져야 할 때도 있습니다. 이러한 경우에는 따로 클래스르 만들어주는 것이 유리하겠죠.
Retrofit 의 create 메서드 내에서의 사용.
이전 글에서 Retrofit 의 create 메서드를 뜯어보았습니다.
이 때 위에서의 `InvocationHandler` 와 `invoke` 메서드가 나왔습니다.
public <T> T create(final Class<T> service) {
validateServiceInterface(service);
return (T)
Proxy.newProxyInstance(
service.getClassLoader(),
new Class<?>[] {service},
new InvocationHandler() {
private final Platform platform = Platform.get();
private final Object[] emptyArgs = new Object[0];
@Override
public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
// If the method is a method from Object then defer to normal invocation.
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
args = args != null ? args : emptyArgs;
return platform.isDefaultMethod(method)
? platform.invokeDefaultMethod(method, service, proxy, args)
: loadServiceMethod(method).invoke(args);
}
});
}
이제는 위 코드가 어떻게 동작하는 것인지에 대해 충분히 이해가 갈 것입니다!!
Retrofit 의 create 함수 관련해서 더 깊게 알아보려면 https://sh1mj1-log.tistory.com/168 이 글에서 볼 수 있습니다.
정리
- 프록시 패턴의 단점을 동적 프록시를 사용하는 것으로 어느정도 해결할 수 있다.
- 동적 프록시를 사용할 때는 `InvocationHandler` 를 구현한 클래스를 만들어서 `invoke` 메서드를 필요에 맞게 오버라이드해야 한다.
- `InvocationHandler` 는 `invoke` 메서드만 가지고 있는 인터페이스며, 이런 인터페이스를 함수형 인터페이스라고 한다.
- 함수형 인터페이스를 사용할 때는 익명 객체를 만들어 사용하거나 람다 식으로도 사용할 수 있다.
이 때는 생성자를 만들 수 없다. - Retrofit 의 `create` 함수는 동적 프록시를 이용한다.
참고 링크
https://live-everyday.tistory.com/217
https://gong-story.tistory.com/22
https://www.charlezz.com/?p=759