1. 개요
이전 포스팅에서 직접 프록시를 생성하면서 원본 코드에 영향을 주지 않고 부가 기능을 수행할 수 있도록 공부해봤다.
하지만 프록시를 적용해야하는 클래스가 수백개로 늘어난다면, 프록시 클래스를 그에 맞게 생성해줘야하는 번거로움이 존재했다.
이번 시간에는 단 하나의 클래스를 생성해 모든 클래스에 프록시를 적용할 수 있는 *동적 프록시 기술*에 대해 알아볼 것이다.
참고로 동적 프록시 기술은 런타임에 동적으로 클래스 및 메서드를 생성하기 때문에 *리플렉션*을 활용한다. 본 포스팅에서는 리플렉션에 대해서 자세히 다루지는 않는다.
2. JDK 동적 프록시
JDK 동적 프록시는 자바 언어에서 기본으로 제공하는 기능이다. *주로 인터페이스 기반의 동적 프록시 생성에 사용된다.*
* 후에 기술할 CGLIB가 구체 클래스 기반의 동적 프록시 생성에 주로 사용된다.
JDK 동적 프록시에 적용할 로직은 `InvocationHandler` 인터페이스를 구현하여 작성하면 된다.
package java.lang.reflect;
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
실제로 예제를 구현해보자. 단순히 호출 정보를 로그로 남기고, 실행 시간을 측정하는 프록시를 만들 것이다.
@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();
long endTime = System.currentTimeMillis();
Object result = method.invoke(target, args);
long resultTime = endTime - startTime;
log.info("TimeProxy 종료, resultTime={}", resultTime);
return result;
}
}
- 프록시이기 때문에 필드에 호출할 `target`이 자리잡고 있다. 이제 이 하나의 클래스로 모든 동적 프록시를 생성할 것이기 때문에 타입은 `Object`로 받는 모습이다.
- `invoke()`메서드를 구현하면 된다. 부가 기능을 작성하고 인수로 넘어온 메서드를 실행하면 된다. `method.invoke()`
- 메서드 invoke에 필요한 args도 넘어오므로 넣어주면 된다.
이제 테스트 코드를 작성해보자.
@Slf4j
public class JdkDynamicProxyTest {
@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("proxyClass={}", proxy.getClass());
}
}
`InvocationHandler`의 구현 클래스를 만들었다면 실제로 동적 프록시를 생성할 차례이다.
`Proxy` 클래스는 자바 리플렉션 패키지에 속해있는 클래스이다. 해당 클래스의 프록시 생성 메서드인 `newProxyInstance()`를 활용해 프록시 객체를 생성하면 된다.
인수로는 `target`이 될 인터페이스의 클래스 로더, 실제 사용되는 인터페이스, 그리고 부가 기능으로 실행할 핸들러를 넘겨주면 된다.
반환타입으로 Object가 생성되는데 실제 실행할 타입에 맞춰 *캐스팅이 가능*하다.
위 테스트 실행 결과이다.
프록시 기능을 잘 수행한 모습이고, 한 가지 주의깊게 볼 점은 프록시 클래스의 패키지 명이다.
JDK 동적 프록시를 활용하면 `com.sun.proxy.$ProxyXX`의 형식으로 프록시 객체가 생성된다.
실제 런타임 객체 의존 관계는 아래와 같다.
`Proxy`클래스를 활용해 동적 프록시 객체를 생성하는 방법을 알아봤으니 실제 애플리케이션에 적용하는 것도 어렵지 않을 것이다.
먼저 핸들러를 생성해보자.
public class LogTraceFilterHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
private final String[] pattern;
public LogTraceFilterHandler(Object target, LogTrace logTrace, String[] pattern) {
this.target = target;
this.logTrace = logTrace;
this.pattern = pattern;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 메서드 이름 필터
String methodName = method.getName();
if (!PatternMatchUtils.simpleMatch(pattern, methodName)) {
return method.invoke(target, args);
}
TraceStatus status = null;
try {
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
status = logTrace.begin(message);
// 로직 호출
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
로그 출력을 위한 부가 기능을 `invoke()`메서드를 오버라이딩해 잘 구현했다. 요구사항 중 특정 메서드는 로그 출력을 하지 않는 부분이 있기 때문에 해당 기능을 위한 필터 역할 역시 구현했다.
이제 이 하나의 핸들러로 컨트롤러부터 리포지토리까지, 모든 동적 프록시를 생성할 수 있다. Config 파일을 확인해보자.
@Configuration
public class DynamicProxyFilterConfig {
private static final String[] PATTERNS = {"request*", "order*", "save*"};
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));
return (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceFilterHandler(orderControllerV1, logTrace, PATTERNS));
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1 orderServiceV1 = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
return (OrderServiceV1) Proxy.newProxyInstance(OrderServiceV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class},
new LogTraceFilterHandler(orderServiceV1, logTrace, PATTERNS));
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1 orderRepositoryV1 = new OrderRepositoryV1Impl();
OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class},
new LogTraceFilterHandler(orderRepositoryV1, logTrace, PATTERNS));
return proxy;
}
}
기본적으로 각 계층의 인터페이스를 호출할 때 리턴 타입으로 프록시 객체를 반환하는 모습이다. 이는 당연하게도 스프링 컨테이너에 실제 target이 아닌 프록시 객체가 등록된다는 의미이다.
프록시 객체를 생성하는 과정 역시 이전처럼 `Proxy.newInstance()`를 사용하고있다.
JDK 동적 프록시는 *인터페이스를 통한 프록시 객체 생성이라는 한계점을 가진다.*
만약 구체 클래스를 직접 동적 프록시로 생성하고 싶다면 아래에 소개할 *CGLIB*라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야한다.
3. CGLIB
CGLIB는 뭘까?
- Code Generator Library라는 외부 라이브러리이다.
- 인터페이스 없이도 *구체 클래스만으로 동적 프록시를 생성할 수 있다.*
- 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함시켰다. 따라서 별도의 라이브러리를 추가하지 않아도 사용할 수 있다.
- 참고로 이 CGLIB를 우리가 직접 사용하는 경우는 거의 없다. 이후 설명할 스프링의 `ProxyFactory`라는 것이 이 기술을 편리하게 사용하게 도와주기 때문에 대략 개념만 잡으면 된다.
예제 코드를 통해 살펴보자. JDK 동적 프록시를 사용할 때 `InvocationHandler`를 제공했듯이, CGLIB는 `MethodInterceptor`라는 인터페이스를 제공한다. 우리는 내부 `intercept`메서드를 구현하면 된다.
package org.springframework.cglib.proxy;
public interface MethodInterceptor extends Callback {
Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
참고로 `intercept`메서드의 파라미터로 `MethodProxy`를 제공해주는데, 단순히 `Method`를 사용해 target을 호출해도 되지만 CGLIB는 성능상 `MethodProxy`를 사용하는 것을 권장한다.
실제로 구현체를 만들어보자.
public class TimeMethodInterceptor implements MethodInterceptor {
public TimeMethodInterceptor(Object target) {
this.target = target;
}
private final Object target;
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
long endTime = System.currentTimeMillis();
Object result = methodProxy.invoke(target, args);
long resultTime = endTime - startTime;
log.info("TimeProxy 종료, resultTime={}", resultTime);
return result;
}
}
JDK 동적 프록시에서 `InvocationHandler`를 구현할 때와 거의 비슷한 모양이다.
테스트 코드를 작성해보자.
public class CglibTest {
@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();
}
}
JDK 동적 프록시의 경우 `Proxy.newInstance()`를 통해 프록시 객체를 생성했다.
CGLIB는 `Enhancer`라는 내부 기술을 활용해 target이 될 클래스 정보를 설정하고, `MethodInterceptor`를 설정하며 프록시 객체를 생성해낸다.
마찬가지로 Object 타입으로 프록시 객체가 생성되지만 캐스팅이 가능하다!
실행 결과를 살펴보자.
프록시 객체의 클래스 정보가 `$$EnhancerByCGLIB%%XXXX`형태로 출력되는 모습이다.
지금까지 스프링을 공부하며 프록시 객체의 클래스 정보를 확인해보면 이와 비슷한 모양이었는데, 뒤에서 프록시에 대해 더 깊이있게 학습하면 많은 의문이 해결될 것 같다.
실제 런타임 의존 관계는 아래와 같다.
CGLIB는 제약이 있다.
- 구체 클래스 기반의 프록시이기 때문에 상속을 사용한다.
- 부모 클래스의 생성자를 체크해야한다.
- CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다.
- 클래스에 final 키워드가 붙으면 상속이 불가능하다.
- CGLIB에서는 예외가 발생한다.
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다.
- CGLIB에서는 프록시 로직이 동작하지 않는다.
- 부모 클래스의 생성자를 체크해야한다.
사실 현재까지 예제로 만든 CGLIB 프록시는 애플리케이션에 적용해보기엔 몇 가지 제약이 있다.
기본 생성자를 추가해야하고, 의존관계를 `setter`를 통해 주입해야하는 등 인데 뒤에 학습할 `ProxyFactory`를 통해 이런 단점을 편리하게 해결할 수 있다! 우선 추가적으로 학습을 쭉 이어나가보자.
출처 : 인프런, 김영한의 스프링 핵심 원리 - 고급편
'Spring' 카테고리의 다른 글
[Spring] 빈 후처리기, BeanPostProcessor (2) | 2024.03.06 |
---|---|
[Spring] 동적 프록시의 추상화, ProxyFactory (1) | 2024.03.06 |
[Spring] 프록시 패턴과 데코레이터 패턴 (0) | 2024.03.04 |
[Spring] 스프링의 템플릿 콜백 패턴 (0) | 2024.03.03 |
[Spring] 필드 동기화와 ThreadLocal (0) | 2024.03.01 |