평소에 "스프링에서는 인터페이스를 사용하는 다이나믹 프록시와 클래스만을 이용하는 cglib 프록시를 사용했다~ 그리고 최신버전에서는 cglib만 사용한다~" 라고만 알고있지 프록시를 직접 만들어본적이 없었습니다. 그래서 이번 기회에 정리하며 만들어보겠습니다.
프록시는 흔히 프록시 패턴으로 불립니다. 그러면 프록시 패턴은 무엇일까요?
프록시 패턴은 디자인 패턴 중 구조 패턴에 해당하는 패턴입니다. 프록시 패턴은 대상 객체의 접근을 제어하거나, 대상 객체의 실행 전/후에 특정 행위를 추가할 수 있는 패턴입니다. 코드를 통해 좀 더 자세히 살펴보겠습니다.

프록시 패턴은 위 그림과 같은 구조를 가지고 있습니다.
Client는 Subject 인터페이스를 의존하며, 해당 Subject를 RealSubject와 Proxy가 구현하고 있습니다. Client의 요청은 Proxy를 거쳐 RealSubject의 메소드를 호출하게 됩니다.
위 구조를 특정 메소드에 트랜잭션 기능을 추가하는 예를 만들어보겠습니다.
public interface CustomerService { // subject
void create();
}
Client가 호출한 Subject 인터페이스입니다.
public class DefaultCustomerService { // RealSubject
public void create() {
System.out.println("create()");
}
}
Subject 인터페이스를 구현한 RealSubject를 정의합니다.
public class TransactionCustomerServiceProxy implements CustomerService{ // Proxy
private final DefaultCustomerService customerService; // RealSubject
@Override
public void create() {
try{
DatabaseUtils.startTransaction(connection.get());
customerService.create();
DatabaseUtils.endTransaction(connection.get(), true);
}catch (SQLException e){
...
}
}
}
Subject를 구현하며 RealSubject를 참조하는 Proxy 객체를 정의했습니다.
또한 RealSubject의 메소드를 호출하기전에 트랜잭션을 시작하고, 종료하는 로직을 추가했습니다.
Proxy의 인스턴스를 생성하여 create()를 호출하면 다음과 같은 출력를 얻을 수 있습니다.
Terminal>
Getting connection
Transaction started
create()
Transaction ended
간단하게 예를들어봤습니다. 하지만 위 코드를 만들어보면서 "매번 트랜잭션을 직접 정의해야하나?", "다른 클래스에도 트랜잭션을 적용하고싶은데... 매번 프록시 클래스를 만들어야할까...", "메소드 추가할때마다.. 프록시 클래스에 계속 추가해줘야하네...?" 생각이 들었고, 아 직접 만드는게 너무너무 불편하다는 생각이 들었습니다. 그러면 이러한 문제를 어떻게 해결할 수 있을까요?
이 문제를 Java의 DynamicProxy 기술을 통해 해결할 수 있습니다.
Dynamic Proxy는 Runtime에 프록시를 생성하며, 프로시 메소드를 조작하는 메커니즘을 제공합니다.
자바에서는 리플렉션을 통해서 Dynamic Proxy를 구현하고 있습니다.
리플렉션이란?(Reflection)
리플렉션은 런타임에 프로그램의 정보를 조회할 수 있는 메커니즘입니다.
리플렉션을 통해 직접 클래스나 객체를 생성할 수 있고, 클래스에 정의된 메소드를 찾아 실행할 수 있습니다.
Dynamic Proxy는 자바에서 제공하는 Proxy 클래스를 통해 생성할 수 있습니다.
Proxy 클래스에서 제공하는 프록시 생성 메소드는 다음과 같습니다.
public static Object newProxyInstance(
ClassLoader loader, // 1
Class<?>[] interfaces, // 2
InvocationHandler h // 3
) throws IllegalArgumentException
Proxy 객체를 정의하기 위한 Class Loader를 정의합니다.newProxyInstance()를 통해 생성 될 Proxy 객체가 구현할 인터페이스를 정의합니다.우선 앞서 프록시를 생성할 때, InvocationHandler를 정의해야 했습니다. 트랜잭션을 제공하는 MyTransactionInvocationHandler를 정의해보겠습니다.
public class MyTransactionInvocationHandler implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
var transcationOptional = getTransactionalMethod(getOverriddenMethod(method));
if (transcationOptional.isPresent()) {
return handleTransactionalMethod(method, args);
}
return invokeMethod(method, args);
}
private Object handleTransactionalMethod(Method method, Object[] args) {
try {
DatabaseUtils.startTransaction(connection.get());
return invokeMethod(method, args);
} catch (SQLException | RuntimeException e) {
...
}
}
}
MyTransactionInvocationHandler는 @MyTransactional 애노테이션이 있는 메소드에 대해 트랜잭션을 시작하며, 애노테이션이 없는 경우네는 트랜잭션을 시작하지 않습니다.
이제 MyTranscationInvocationHandler를 통해 프록시를 생성해보겠습니다.
var customerService = new DefaultCustomerService();
CustomerService proxy = (CustomerService) Proxy
.newProxyInstance(customerService.getClass().getClassLoader(),
customerSerivce.getClass().getInterfaces(),
new MyTransactionInvocationHandler());
proxy.create();
Terminal>
Getting connection
Transaction started
create()
Transaction ended
이처럼 원하는 행위를 InvocationHandler로 정의한 후 Proxy 클래스를 통해 프록시를 만드는 과정이 이전보다 훨씬 간단해졌습니다. 하지만 여전히 불편함이 존재합니다.Dynamic Proxy를 만들기 위해서는 결국 인터페이스가 필요하니까요....
이러한 문제를 CGLIB를 통해 해결할 수 있습니다.
다음 글을 통해
CGLIB를 소개하며, 이 기술을 통해 더 개선된 빈 컨테이너를 가져오겠습니다.
Dynamic Proxy를 공부하면서 문득, "잘하면... 빈 컨테이너 만들 수 있을 것 같은데?" 라는 생각이 들었습니다. 그래서 빈 컨테이너 비슷하게 만들어봤습니다.
원본 코드는 https://github.com/tlarbals824/Proxy 에서 확인할 수 있습니다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {}
@Component 애노테이션의 경우 빈 등록 대상이 되는 클래스를 식별하기 위해 사용합니다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Invoke {
// target annotation
Class<? extends Annotation> value();
}
@Invoke 애노테이션은 InvokeHandler을 등록하는 과정에서 사용합니다. 이때 value()에 지정된 애노테이션이 대상 클래스의 메소드에 선언되어 있으면, 해당 클래스에 InvokeHandler가 적용된 프록시가 생성됩니다.
InvocationHandler를 구현하며 프록시 대상 객체를 필드로 가지는 CustomInvocationHandler를 정의했습니다.
public abstract class CustomInvocationHandler implements InvocationHandler {
protected Object target;
protected Method getOverriddenMethod(Method method) throws NoSuchMethodException {
return ProxyExtractUtils.getOriginalObject(target)
.getClass()
.getDeclaredMethod(method.getName(), method.getParameterTypes());
}
}
CustomInvocationHandler의 target 필드가 프록시일 수 있기 때문에 처음 프록시 생성 시 사용된 객체의 메소드를 찾기 위해 getOverriddenMethod()를 정의했습니다.
클래스 조회를 쉽게하기 위해 reflections 라이브러리를 사용했습니다.
먼저, 패키지 경로를 통해 @Component를 가지는 클래스들을 조회한 후, 해당 클래스들의 인스턴스를 생성했습니다. 그런 다음, InvocationHandler의 대상이 되는 클래스가 있다면 프록시를 만들어 저장했습니다.
또한, CustomInvocationHandler를 구현하며, @Invoke 애노테이션이 정의된 InvocationHandler 구현체들을 Map<Annotation, CustomInvocationHandler>에 저장했습니다.
public class DynamicProxyFactory {
private List<MyCustomProxy> beanRegistry;
private Map<Class<? extends Annotation>, Class<? extends InvocationHandler>> invocationHandlerMap = new HashMap<>();
public DynamicProxyFactory(Package packageToScan) {
Reflections reflections = new Reflections(packageToScan.getName());
Set<Class<?>> componentClasses = reflections.getTypesAnnotatedWith(Component.class);
Set<Class<?>> invokeClasses = reflections.getTypesAnnotatedWith(Invoke.class);
configInvokeHandler(invokeClasses);
List<?> beans = instantiateBeans(componentClasses);
beanRegistry = beans.stream()
.map(bean -> {
try {
return createProxy(bean);
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException |
IllegalAccessException e) {
throw new RuntimeException("Could not create proxy for bean " + bean.getClass().getName(), e);
}
})
.toList();
}
}
빈 컨테이너를 초기화하는 과정에서 intantiateBeans()와 createProxy() 메소드가 사용되는 것을 확인할 수 있습니다. 이 메소드들을 하나씩 살펴보겠습니다.
private List<?> instantiateBeans(Set<Class<?>> annotatedClasses) {
return annotatedClasses.stream()
.map(this::instantiateClass)
.toList();
}
private Object instantiateClass(Class<?> componentClass) {
try {
return componentClass.newInstance();
} catch (InstantiationException | IllegalAccessException e) {
throw new RuntimeException("Could not instantiate class " + componentClass.getName());
}
}
instantiateBeans() 메소드는 입력된 클래스들의 인스턴스를 생성합니다. 각 클래스의 인스턴스는 instantiateClass() 메소드를 통해 생성됩니다.
인스턴스를 생성할 때는 편의를 위해 기본 생성자를 사용했습니다.
이제 인스턴스 생성이 완료되어, 프록시를 만들 준비가 되었습니다.
private MyCustomProxy createProxy(Object bean) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Object proxy = bean;
List<Class<?>> interfaces = new ArrayList<>(List.of(bean.getClass().getInterfaces()));
for (var entry : invocationHandlerMap.entrySet()) {
for (var method : bean.getClass().getDeclaredMethods()) {
if (method.isAnnotationPresent(entry.getKey())) {
InvocationHandler handler = entry.getValue().getConstructor(Object.class).newInstance(proxy);
proxy = Proxy.newProxyInstance(
bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
handler);
interfaces.add(entry.getValue());
break;
}
}
}
return new MyCustomProxy(interfaces, proxy);
}
createProxy() 메소드는 입력된 빈의 선언된 메소드 중 특정 애노테이션이 존재하는 경우, 해당 애노테이션의 InvocationHandler를 찾아 프록시를 생성합니다. 프록시 생성 과정에서 여러 InvocationHandler가 동시에 적용될 수 있기 때문에, proxy를 매번 갱신하는 방식으로 구현되었습니다.
우선 Dynamic Proxy만을 사용하여 구현하다보니, 인터페이스 정의 과정이 매우매우 불편했습니다... 하지만, 스프링에서 사용하는 빈 컨테이너에 대해 좀 더 이해할 수 있었고, 특히 하나의 객체에 대해 여러 프록시를 적용하는 과정이 매우 재밌고 유익했습니다.
Proxy 클래스를 통해 Dynamic Proxy를 만들 수 있습니다.Dynamic Proxy는 인터페이스를 정의하지 않으면 프록시를 만들 수 없습니다.Dynamic Proxy는 InvocationHandler를 통해 행위를 추가할 수 있습니다.Dynamic Proxy 대신 CGLIB를 활용하면 인터페이스 없이 프록시를 만들 수 있습니다.참고
https://medium.com/@spac.valentin/java-dynamic-proxy-mechanism-and-how-spring-is-using-it-93756fc707d5
https://medium.com/swlh/what-is-proxy-pattern-64ff58a8bc00
https://www.youtube.com/watch?v=pvE4pwyzkpE