프록시를 사용해서 부가기능을 추가하려고 하면 대상 클래스 수 만큼 프록시 클래스를 만들어야 하는 단점이 있었다.
하지만 자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어 낼 수 있다.
JDK 동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다.
리플렉션
리플렉션이란?
자바 프로그램이 런타임(실행 중)에 클래스, 메서드, 필드 등의 정보에 접근하고 조작할 수 있도록 해주는 메커니즘을 말한다.
리플렉션 기술을 사용하면 클래스, 메서드의 메타정보를 동적으로 획득하고 코드도 동적으로 호출할 수 있다.
예제를 통해서 알아보자.
리플렉션 예제
@Slf4j
static class Hello {
public String callA(){
log.info("callA");
return "A";
}
public String callB(){
log.info("callB");
return "B";
}
}
@Test
public void reflection0() throws Exception{
Hello target = new Hello();
// 공통 로직1 시작
log.info("start");
final String result1 = target.callA();
log.info("result1 = {} ", result1);
// 공통 로직2 시작
log.info("start");
final String result2 = target.callB();
log.info("result2 = {} ", result2);
}
테스트 메서드 reflection0() 에서 공통 로직 1, 공통 로직2를 호출하는 메서드만 다르고 전체 코드 흐름이 완전히 같다.
여기서 공통 로직1과 공통 로직2를 하나의 메서드로 뽑아서 합칠 수 있을까?
호출하는 부분만 동적으로 처리할 수 있다면 문제를 해결할 수 있다.
요즘은 람다를 사용해서 공통화하는 것도 가능하지만 리플렉션을 사용해서 처리해보자.
@Test
public void reflection1() throws Exception{
// 클래스 메타 정보
final Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
final Hello target = new Hello();
// callA 메서드 정보
final Method methodCallA = classHello.getMethod("callA");
final Object result1 = methodCallA.invoke(target);
log.info("result1 = {} ", result1);
// callB 메서드 정보
final Method methodCallB = classHello.getMethod("callB");
final Object result2 = methodCallB.invoke(target);
log.info("result2 = {} ", result2);
}
Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello"); 여기서 'Hello' 앞에 '$'가 붙은 이유는 내부 클래스 구분을 위해 사용한다. Class.forName( ) 을 이용해서 클래스 메타 정보를 획득한다.
classHello.getMethod("callA"); 는 call 메서드의 메타정보를 획득한다.
methodCallA.invoke(target);는 획득한 메타정보를 바탕으로 실제 인스턴스의 메서드를 호출한다.
그런데 메서드를 직접 호출하면 되지 이렇게 메서드 정보를 획득해서 메서드를 호출하는 이유가 무엇일까?
여기서 중요한점은 클래스나 메서드 정보를 동적으로 변경할 수 있다는 점이다.
@Test
public void reflection2() throws Exception{
// 클래스 정보
final Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
final Hello target = new Hello();
// callA 메서드 정보
final Method methodCallA = classHello.getMethod("callA");
dynamicCall(methodCallA, target);
// callB 메서드 정보
final Method methodCallB = classHello.getMethod("callB");
dynamicCall(methodCallB, target);
}
private void dynamicCall(Method method, Object target) throws Exception{
log.info("start");
final Object result = method.invoke(target);
log.info("result = {} ", result);
}
기존에 직접 메서드를 호출하는 부분이 Method로 추상화 되었다. 덕분에 이제 공통 로직을 만들 수 있다.
주의사항
리플렉션 기술은 런타임에 동작하기 때문에 컴파일 시점에 오류를 잡을 수 없다. 만약 getMethod("callA")안에 들어가는 문자를 실수로 getMethod("callZ")로 작성해도 컴파일 오류가 발생하지 않는다. 하지만 해당 코드를 실행하는 시점에 발생하는 런타임 오류가 발생한다.
JDK 동적 프록시
JDK 동적 프록시는 인터페이스 기반으로 동적으로 프록시 객체를 생성해 준다. 따라서 인터페이스가 필수다.
개발자가 직접 프록시 클래스를 작성할 필요 없이, 런타임에 InvocationHandler를 구현하여 공통 부가기능을 적용할 수 있다.
예제를 통해 JDK 동적 프록시를 알아보자
먼저 실습에 필요한 인터페이스와 구현체를 만들어보자.
public interface AInterface {
String call();
}
// AImpl.java
@Slf4j
public class AImpl implements AInterface{
@Override
public String call() {
log.info("A call");
return "a";
}
}
public interface BInterface {
String call();
}
// BImpl.java
@Slf4j
public class BImpl implements BInterface{
@Override
public String call() {
log.info("B call");
return "B";
}
}
JDK 동적 프록시에 적용할 로직은 InvocationHandler 인터페이스를 구현해서 작성하면 된다.
package java.lang.reflect;
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
이제 InvocationHandler 를 직접 구현해서 동적 프록시를 만들어보자.
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
private final Object target; // 프록시가 호출한 대상, Object로 선언한 이유는 모든 것을 다 받겠다는 의미
public TimeInvocationHandler(final Object target) {
this.target = target;
}
@Override
public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
log.info("TimeProxy start");
final long startTime = System.currentTimeMillis();
final Object result = method.invoke(target, args);
final long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy end resultTime = {}", resultTime);
return result;
}
}
여기서 필드로 선언된 Object target은 동적프록시가 호출할 대상이다.
method.invoke(target, args)는 리플렉션을 사용해서 target 인스턴스의 메서드를 실행한다. 여기서 args는 메서드 호출 시 넘겨줄 인수이다.
이제 테스트를 통해 잘 동작하는지 확인해보자.
TestCode
@Slf4j
public class JdkDynamicProxyTest {
@Test
public void dynamicA() throws Exception{
final AImpl target = new AImpl();
final TimeInvocationHandler handler = new TimeInvocationHandler(target);
final 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());
}
}
실행결과
11:35:19.936 [Test worker] INFO hello.proxy.jdkdynamic.code.TimeInvocationHandler - TimeProxy start
11:35:19.938 [Test worker] INFO hello.proxy.jdkdynamic.code.AImpl - A call
11:35:19.938 [Test worker] INFO hello.proxy.jdkdynamic.code.TimeInvocationHandler - TimeProxy end resultTime = 0
11:35:19.939 [Test worker] INFO hello.proxy.jdkdynamic.JdkDynamicProxyTest - targetClass=class hello.proxy.jdkdynamic.code.AImpl
11:35:19.939 [Test worker] INFO hello.proxy.jdkdynamic.JdkDynamicProxyTest - proxyClass=class jdk.proxy3.$Proxy12
클라이언트는 JDK 동적 프록시의 call( )을 실행한다. 여기서 클라이언트는 테스트 코드라고 생각하면 된다.
그 후 JDK 동적 프록시는 InvocationHandler.invoke( )를 호출한다. TimeInvocationHandler가 구현체로 있으므로 TimeInvocationHandler.invoke( )가 호출된다.
TimeInvocationHandler가 내부 로직 수행 후 method.invoke(target, args)를 호출 해 실제 객체를 호출한다. 여기서 실제 객체는 AImpl이다.
AImpl 인스턴스의 call( )이 실행된다. call( ) 메서드 실행이 끝나면 TimeInvocationHandler로 돌아와 로그를 출력하고 결과를 반환한다.
예제를 보면 AImpl 전용 프록시를 만들지 않고, JDK 동적 프록시를 사용해서 동적으로 만들고 TimeInvovationHandler는 공통으로 사용할 수 있게 만들었다.
즉, 프록시 적용 대상 만큼 프록시 객체를 만들지 않고, 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있다.
결과적으로 단일 책임 원칙도 지킬 수 있게 됐다.
CGLIB(Code Generate Library)
CGLIB란?
CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리다. CGLIB를 사용하면 인터페이스가 없이 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다. CGLIB는 원래 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스코드에 포함했다.
예제를 통해 CGLIB를 알아보자
먼저 구체 클래스인 ConcreteService.java 생성
@Slf4j
public class ConcreteService {
public void call(){
log.info("ConcreteService call");
}
}
JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler를 제공했듯이, CGLIB는 MethodInterceptor를 제공한다.
스프링이 제공하는 MethodInterceptor (직접 생성 X)
public interface MethodInterceptor extends Callback {
Object intercept(Object var1, Method var2, Object[] var3, MethodProxy var4) throws Throwable;
}
매개변수안에 선언되어있는 Object는 CGLIB가 적용될 객체고 Method는 호출된 메서드이다.
Object[ ]는 메서드를 호출하면서 전달된 인수이고, MethodProxy는 메서드 호출에 사용된다.
이제 MethodInterceptor 구현체를 작성해보자.
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
public TimeMethodInterceptor(final Object target) {
this.target = target;
}
@Override
public Object intercept(final Object obj, final Method method, final Object[] args, final MethodProxy methodProxy) throws Throwable {
log.info("TimeProxy start");
final long startTime = System.currentTimeMillis();
final Object result = methodProxy.invoke(target, args);
final long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy end resultTime = {}", resultTime);
return result;
}
}
TimeMethodInterceptor는 MethodInterceptor 를 구현해서 CGLIB 프록시의 실행 로직을 정의한다.
이제 테스트를 진행해보자.
TestCode
@Slf4j
public class CglibTest {
@Test
public void cglib() throws Exception{
// given
final ConcreteService target = new ConcreteService();
// when
final Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteService.class); // 구체클래스를 상속받아서 프록시를 생성
enhancer.setCallback(new TimeMethodInterceptor(target)); // 프록시에 적용할 실제 로직 할당
final ConcreteService proxy = (ConcreteService) enhancer.create(); // 프록시 생성
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
// then
}
}
CGLIB는 Enhancer를 사용해서 프록시를 생성한다.
실행결과
11:17:12.099 [Test worker] INFO hello.proxy.cglib.CglibTest - targetClass=class hello.proxy.common.service.ConcreteService
11:17:12.102 [Test worker] INFO hello.proxy.cglib.CglibTest - proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$25d6b0e3
실행결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다.
클라이언트는 ConcreteService를 호출한다. 여기서 클라이언트는 테스트 코드가 된다.
CGLIB는 ConcreteService를 상속해서 만들어지고 MethodInterceptor를 뒤에서 호출하게 된다.
실제 런타임 환경에서는 클라이언트가 CGLIB Proxy를 호출하게 되고 Proxy는 TimeMethodInterceptor를 호출하고 TimeMethodInterceptor는 실제 Target을 Invoke를 하게 된다.
CGLIB 제약
- CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자 필요
- 클래스에 final 키워드가 붙으면 상속이 불가능해진다. 즉, CGLIB에서는 예외가 발생한다.
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. CGLIB 에서는 프록시 로직이 동작하지 않는다.
REFERENCE
'Spring Framework' 카테고리의 다른 글
[Spring] 스프링 액츄에이터(actuator)로 살펴보는 프로덕션 준비 기능 (1) | 2025.01.13 |
---|---|
[Spring] API 예외 처리 (1) | 2024.11.15 |
[Spring] 예외 처리와 오류 페이지 (0) | 2024.11.12 |
[Spring] Bean Validation (검증) - 2 (0) | 2024.11.04 |
[Spring] Bean Validation (검증) - 1 (4) | 2024.10.31 |