...
Java Dynamic Proxy
자바 프로그래밍의 디자인 패터중 하나인 프록시 패턴은 초기화 지연, 접근 제어, 로깅, 캐싱 등, 기존 대상 원본 객체를 수정 없이 추가 동작 기능들을 가미하고 싶을 때 사용하는 코드 패턴이다. 이 디자인 패턴을 적용하면 개방 폐쇄 원칙(OCP)의 효과를 얻을 수 있어 코드 수정없이 유연하게 확장이 가능하여 유지보수 측면에서 플러스 효과를 얻을 수 있다는 장점이 있다.
하지만 프록시 디자인 패턴은 대상 원본 클래스 수만큼 일일히 프록시 클래스를 하나하나 만들어 줘야하는 치명적인 단점이 존재한다. 즉, 프록시 적용 대상 객체가 100개면 프록시 객체도 100개 만들어줘야 한다는 말이다. 따라서 코드량이 많아지게 되고 중복이 발생하여 코드의 복잡도가 증가한다는 한계점이 존재한다.
바로 이러한 단점들을 보완하여 컴파일 시점이 아닌 런타임 시점에 프록시 클래스를 만들어주는 방식이 자바 가상 머신(JVM)에서 공식적으로 지원하는 동적 프록시(Dynamic Proxy) 기능이다.
동적 프록시는 개발자가 직접 일일히 프록시 객체를 생성하는 것이 아닌, 애플리케이션 실행 도중 java.lang.reflect.Proxy 패키지에서 제공해주는 API를 이용하여 동적으로 프록시 인스턴스를 만들어 등록하는 방법으로서, 자바의 Reflection API 기법을 응용한 연장선의 개념이다. 프록시 패턴의 기본 흐름은 거의 같고, 프록시를 클래스로 직접만들어서 등록하냐 이미 지원하는 api를 이용하여 동적으로 등록하느냐에 따른 차이만 있을 뿐이다.
Dynamic Proxy 구성 요소
우선 동적 프록시 사용법을 알아보기 앞서, Proxy 클래스에서 어떤 방식으로 동적 프록시 인스턴스를 생성하는지 내부 구성을 살펴보는 시간을 먼저 가져보자.
newProxyInstance() 메서드
java.lang.reflect.Proxy 클래스 안을 살펴보면 프록시 객체를 생성하는 메서드인 newProxyInstance() 를 볼수 있다. 이 메서드를 호출하면 따로 프록시 클래스 정의 없이 자동으로 프록시 객체를 등록할 수 있다. 그리고 동적으로 프록시 객체를 생성하기 위해서는 아래와 같이 3가지의 매개변수를 받는다.
public class Proxy implements java.io.Serializable {
// ...
public static Object newProxyInstance(
ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h
) throws IllegalArgumentException {
// ...
}
}
ClassLoader loader- 프록시 클래스를 만들 클래스 로더(Class Loader)
- Proxy 객체가 구현할 Interface에 Class Loader를 얻어오는 것이 일반적
Class<?>[] interfaces- 프록시 클래스가 구현하고자 하는 인터페이스 목록 (배열)
- 메서드를 통해 생성 될 Proxy 객체가 구현할 Interface를 정의한다.
InvocationHandler h- 프록시의 메서드(invoke)가 호출되었을때 실행되는 핸들러 메서드
InvocationHandler 인터페이스
InvocationHandler 인터페이스는 위에서 본 newProxyInstance() 메서드의 3번째 매개변수에 들어갈 핸들러 메서드를 정의하는 함수형 인터페이스이다. 이 인터페이스 코드 구성을 보면 내부에 invoke() 라는 추상메서드 하나만 정의되어있는 걸 볼 수 있다.
invoke() 메서드는 동적 프록시의 메서드가 호출되었을때, 이를 낚아채어 대신 실행되는 메서드이다. 메서드의 파라미터를 통해 어떤 메서드가 실행되었는지 메서드 정보와 메서드에 전달된 인자까지 알수있다.
디자인 패턴으로 프록시를 구성하면 단점이 중복된 메서드 코드 로직이 발생한다는 점인데, 이 invoke() 메서드에 동적으로 등록함으로써 반복된 코드를 줄이게 되는 것이다.
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
Object proxy: 프록시 객체Method method: 호출한 메서드 정보Object[] args: 메서드에 전달된 매개변수 (배열)
Dynamic Proxy 등록 예제
이제 본격적으로 다이나믹 프록시 사용 예제를 살펴보자. 보다 동적 프록시의 효용성 이해를 돕기 위해 디자인 패턴으로 프록시를 구성한 것과 동적 프록시로 프록시를 구성을 한 형태의 차이를 각각 살펴보는 시간을 가져볼 예정이다.
프록시 디자인 패턴 구성
다음 AImpl 와 BImpl 라는 프록시 적용 대상 객체가 있고 이를 상속하는 인터페이스들이 있는걸 볼 수 있다. 그리고 각각 AProxy 와 BProxy를 만들어 구현함으로써 아래 클래스 다이어그램과 같은 형태로 프록시가 구성된다.
interface AInterface {
String call();
}
class AImpl implements AInterface {
@Override
public String call() {
System.out.println("A 호출");
return "a";
}
}
class AProxy implements AInterface {
AInterface subject;
AProxy(AInterface subject) {
this.subject = subject;
}
@Override
public String call() {
System.out.println("TimeProxy 실행");
long startTime = System.nanoTime();
String result = subject.call();
long endTime = System.nanoTime();
long resultTime = endTime - startTime;
System.out.println("TimeProxy 종료 resultTime = " + resultTime);
return result;
}
}
interface BInterface {
String call();
}
class BImpl implements BInterface {
@Override
public String call() {
System.out.println("B 호출");
return "b";
}
}
class BProxy implements BInterface {
BInterface subject;
BProxy(BInterface subject) {
this.subject = subject;
}
@Override
public String call() {
System.out.println("TimeProxy 실행");
long startTime = System.nanoTime();
String result = subject.call();
long endTime = System.nanoTime();
long resultTime = endTime - startTime;
System.out.println("TimeProxy 종료 resultTime = " + resultTime);
return result;
}
}
public class Client {
public static void main(String[] args) {
AInterface proxyA = new AProxy(new AImpl());
proxyA.call();
BInterface proxyB = new BProxy(new BImpl());
proxyB.call();
}
}
하지만 코드를 한눈에 봐도 괜히 프록시 클래스를 정의하느라 코드 길이수가 길어졌을 뿐만 아니라, 프록시로 가미시킨 로깅(Logging) 로직이 중복이 되는 것을 볼수 있을 것이다. 똑같은 로직을 하는 메서드가 있으면 이를 따로 함수로 빼서 매개변수만 적절히 잘 주면 될것 같지만, 객체 지향 프로그래밍의 자바에서는 이렇게 구성하기가 어렵다. 왜냐하면 파이썬이나 자바스크립트와는 다르게 메서드 하나를 정의하려면 반드시 클래스를 만들어주어야 하기 때문이다. (main 메소드 조차 굳이 클래스를 정의해야되니 말 다했다)
동적 프록시 구성
따라서 이러한 객체 지향 프로그래밍의 한계점을 극복하기 위한 수단중 하나가 동적 프록시 기술이라고 보면 된다.
동적 프록시를 이용하면 프록시 클래스를 정의할 필요가 없어진다. 프록시 객체를 동적으로 생성해주는 newProxyInstance() 메서드에 적절한 파라미터만 넣어주면 되기 때문이다. 따라서 기존에 등록한 AProxy, BProxy 클래스를 지우고, InvocationHandler 인터페이스를 구현한 프록시 핸들러 전용 함수형 클래스를 구현한다.
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
// InvocationHandler 프록시 메서드 핸들러를 클래스 필드 변수를 이용해야 하기 때문에 재정의 함
class MyProxyHandler implements InvocationHandler {
private final Object target;
MyProxyHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("TimeProxy 실행");
long startTime = System.nanoTime();
Object result = method.invoke(target, args); // 파라미터로 전달받은 메서드를 invoke로 실행
long endTime = System.nanoTime();
long resultTime = endTime - startTime;
System.out.println("TimeProxy 종료 resultTime = " + resultTime);
return result;
}
}
public class Client {
public static void main(String[] args) {
AInterface proxyA = (AInterface) Proxy.newProxyInstance(
AInterface.class.getClassLoader(),
new Class[]{AInterface.class},
new MyProxyHandler(new AImpl())
);
proxyA.call();
BInterface proxyB = (BInterface) Proxy.newProxyInstance(
BInterface.class.getClassLoader(),
new Class[]{BInterface.class},
new MyProxyHandler(new BImpl())
);
proxyB.call();
}
}
Proxy.newProxyInstance()메서드를 통해 동적 프록시 객체를 만든다.- 이때 매개변수로 프록시의 인터페이스와 핸들러를 등록함에 따라, 인터페이스를 통해 대상 객체를 프록시로 감싸고, 대상 객체의 행위를 핸들러로 감싸게 된다.
- 프록시 메서드
call()을 호출한다. - 그러면 동적 프록시로 등록한 invocationHandler의
invoke()메서드가 중간에 낚아채 대신 실행되게 된다. - invoke 메서드 안에 작성한 로깅 로직이 실행되고,
method.invoke()메서드를 통해 대상 타겟의 실제 객체인 AImpl의call()메서드가 실행된다. - AImpl의 실행이 종료되고 값이 반환된다.
이처럼 프록시 클래스를 미리 정의해서 사용하는 것이 아닌, 어플리케이션이 구동되는 런타임 시점에서 프록시 객체를 동적으로 생성하여 사용하였다. 하지만 이 방법도 단점이 있는데 실행 시간을 보면 알듯이, 아무래도 동적으로 프록시 객체를 런타임으로 생성하고 실행하였으니 약간 느릴수 밖에 없다.
동적 프록시 메서드 필터링
위의 예제에선 타겟 객체의 메서드는 call 하나밖에 없었다. 그럼 만약 메서드가 여러개 있는 타켓을 프록시화 하면 어떻게 될까?
다음은 위의 다이나믹 프록시 코드를 Java8 람다 함수로 재구성한 코드이다.
interface AInterface {
String call();
void print();
void run();
}
class AImpl implements AInterface {
@Override
public String call() {
System.out.println("A 호출");
return "a";
}
@Override
public void print() {
System.out.println("A print @@@@@@@");
}
@Override
public void run() {
System.out.println("A Running !!!!!!!!!");
}
}
public class Client {
public static void main(String[] arguments) {
AInterface proxyA = (AInterface) Proxy.newProxyInstance(
AInterface.class.getClassLoader(),
new Class[]{AInterface.class},
(proxy, method, args) -> { // 람다 함수
Object target = new AImpl();
System.out.println("TimeProxy 실행");
long startTime = System.nanoTime();
Object result = method.invoke(target, args); // 파라미터로 전달받은 메서드를 invoke로 실행
long endTime = System.nanoTime();
long resultTime = endTime - startTime;
System.out.println("TimeProxy 종료 resultTime = " + resultTime);
return result;
}
);
proxyA.call();
proxyA.print();
proxyA.run();
}
}
실행 결과를보면 대상 객체의 모든 메서드를 호출할때마다 프록시 핸들러가 실행됨을 볼 수 있다.
그럼 만일 call() 메서드만 호출될때만 프록시 핸들러가 호출되도록 구성하고 싶으면 어떻게 해야 할까? 조금 수고스럽겠지만 핸들러 파라미터로 전달되는 두번째 인자 method 매개변수의 메서드명을 조건문으로 검사해서 문자열 필터링하는 수 밖에 없다.
public class Client {
public static void main(String[] arguments) {
AInterface proxyA = (AInterface) Proxy.newProxyInstance(
AInterface.class.getClassLoader(),
new Class[]{AInterface.class},
(proxy, method, args) -> {
Object target = new AImpl();
// 실행한 메서드가 call 일 경우 로깅 기능을 가미하여 리턴
if(method.getName().equals("call")) {
System.out.println("TimeProxy 실행");
long startTime = System.nanoTime();
Object result = method.invoke(target, args); // 파라미터로 전달받은 메서드를 invoke로 실행
long endTime = System.nanoTime();
long resultTime = endTime - startTime;
System.out.println("TimeProxy 종료 resultTime = " + resultTime);
return result;
}
// 만일 메서드가 run이나 print 일경우 로깅 기능을 가미하지않고 그냥 그대로 원본 대상 객체의 메서드를 실행하고 리턴하도록 지정
return method.invoke(target, args);
}
);
proxyA.call();
proxyA.print();
proxyA.run();
}
}
코드가 조금 지저분 해졌지만, 원하는대로 call 메서드에만 프록시가 적용됨을 확인 할 수 있다.
Dynamic Proxy 제약 사항
지금까지 동적 프록시 구현 및 응용을 다뤄보았다. 아주 약간의 퍼포먼스를 희생하고 자유롭게 프록시를 다이나믹하게 등록할 수 있지만, 여기에 추가로 한가지 제약사항이 존재한다. 동적 프록시에 타켓을 등록할때 타입을 클래스가 아닌 무조건 인터페이스를 파라미터로 넣어야 된다는 점이다. 인터페이스를 기반으로 프록시를 동적으로 만들어주기 때문에, 인터페이스가 필수이기 때문이다.
동적 프록시 코드를 AInterface가 아닌 구현 클래스 AImpl로 모두 바꿔보자. 에러가 뜨지 않는거보니 컴파일단에선 이러한 제약 사항을 잡지 못하는걸 볼 수 있다.
그러나 코드를 실행해보면 다음과 같이 Exception이 만나게 된다.
즉, 자바에서 newProxyInstance()를 이용해 동적 프록시 객체를 만들때 Class 기반으로는 Proxy 객체를 생성할 수 없다는 말이다. 하지만 클래스의 확장성을 고려할 필요가 없거나 한가지 책임만 분명하게 하는 경우 굳이 인터페이스를 등록해 사용하지 않는 겨우도 있다. 프록시 때문에 굳이 일일히 인터페이스를 구현해야 하는 것도 결국은 디자인 패턴의 한계점의 회귀이다.
그럼 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 없는 것일까?
자바스크립트와 파이썬에도 정말 다양하고 뛰어난 라이브러리가 있듯이 자바에서도 이름 있는 외부 라이브러리가 많다. 그중 CGLIB라는 바이트코드를 조작하여 동적 프록시 기술을 응용하는 라이브러리를 이용해 JDK를 이용한 방법보다 더욱 편하게 동적 프록시를 생성할 수 있다.
CGLIB 라이브러리 소개
CGLIB(Code Generator Library)는 JDK의 Dynamic Proxy와는 달리 인터페이스가 아닌 클래스를 대상으로 바이트코드를 조작해서 프록시 생성할 수 있는 라이브러리이다. 오히려 Dynamic Proxy 비해 성능이 좋으며, 그 효용성을 입증받아 스프링 프레임워크에서 기본으로 내장되어 있다.
스프링 프레임워크에서 Bean을 등록할 때 Spring AOP를 이용하여 등록을 하는데, Bean으로 등록하려는 기본적으로 객체가 Interface를 하나라도 구현하고 있으면 Dynamic Proxy를 이용하고 Interface를 구현하고 있지 않으면 CGLIB 라이브러리를 이용한다.
CGLIB 설치 방법
만일 스프링 프로젝트가 아닌 일반 자바 프로젝트일경우 외부 라이브러리 다운 & 적용이 필요하다. 아래 링크에서 jar 파일을 다운 받고 인텔리제이 라이브러리 등록 방법 게시글을 참고하여 프로젝트에 추가해본다.
import net.sf.cglib.proxy.*; // cglib 임포트
CGLIB 프록시 사용법
동적 프록시를 해봤다면 CGLIB도 원리는 비슷해서 어렵지 않게 다룰수 있을 것이다. 단, 프록시 등록 방법이 약간 다르니 알아보자.
동적 프록시에서는 newProxyInstance() 로 프록시 객체를 만들고 InvocationHandler 인터페이스로 프록시 핸들러를 등록하였다. CGLIB 에서는 Enhancer 객체로 프록시 객체를 만들며 MethodInterceptor 인터페이스로 프록시 핸들러를 등록한다.
// 프록시 핸들러
class MyProxyInterceptor implements MethodInterceptor {
private final Object target;
MyProxyInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercept(
Object o,
Method method,
Object[] args,
MethodProxy methodProxy
) throws Throwable {
System.out.println("TimeProxy 실행");
long startTime = System.nanoTime();
Object result = method.invoke(target, args); // 파라미터로 전달받은 메서드를 invoke로 실행
long endTime = System.nanoTime();
long resultTime = endTime - startTime;
System.out.println("TimeProxy 종료 resultTime = " + resultTime);
return result;
}
}
// 프록시를 적용할 대상 타켓
class Subject {
public void call() {
System.out.println("서비스 호출");
}
}
public class Client {
public static void main(String[] arguments) {
// 1. 프록시 등록 (CGLIB는 Enhancer를 사용해서 프록시를 등록한다)
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Subject.class); // CGLIB는 구체 클래스를 상속 받아서 프록시를 생성하기 때문에 상혹할 구체 클래스를 지정
enhancer.setCallback(new MyProxyInterceptor(new Subject())); // 프록시 핸들러 할당
// 2. 프록시 생성
Subject proxy = (Subject) enhancer.create(); // setSuperclass() 에서 지정한 클래스를 상속 받아서 프록시가 만들어진다.
// 3. 프록시 호출
proxy.call();
}
}
- Enhancer 객체 생성
- CGLIB는 타겟 객체를 상속하여 프록시화 하기 때문에 상속할 슈퍼 클래스를 등록한다
- 프록시 핸들러 등록
- create 메소드로 프록시 객체를 생성하고 메소드를 호출한다.
코드를 보면 알수 있듯이, CGLIB는 대상 타켓 클래스를 상속(extends)하여 프록시를 만들기 때문에 Enhancer 객체를 통해 슈퍼 클래스와 핸들러(콜백)을 등록함을 볼 수 있다.
CGLIB 심플하게 (람다식)
MethodInterceptor 인터페이스도 결국 함수형 인터페이스이니 람다식으로 줄일수 있으며, Enhancer 객체도 따로 static 메서드로 create(타겟, 핸들러) 형태를 지원하니, 다음과 같이 심플하게 구성이 가능하다.
public class Client {
public static void main(String[] arguments) {
Subject proxy = (Subject) Enhancer.create(Subject.class, (MethodInterceptor) (o, method, args, methodProxy) -> {
Subject target = new Subject();
System.out.println("TimeProxy 실행");
long startTime = System.nanoTime();
Object result = method.invoke(target, args); // 파라미터로 전달받은 메서드를 invoke로 실행
long endTime = System.nanoTime();
long resultTime = endTime - startTime;
System.out.println("TimeProxy 종료 resultTime = " + resultTime);
return result;
});
proxy.call();
}
}
CGLIB 주의 사항
이렇게 보면 인터페이스 기반일 때는 Dynamic Proxy를 사용하고, 클래스 기반일 때는 CGLIB를 사용하면 되겠지만, 이 라이브러리도 제약사항이 존재한다.
우선 CGLIB는 기본적으로 클래스 상속(extends)을 통해 프록시 구현이 되기 때문에, 타겟 클래스가 상속이 불가능할때는 당연히 프록시 등록이 불가능하다. 또한 메서드에 final 키워드가 붙게되면 그 메서드를 오버라이딩하여 사용 할수 없게되어 결과적으로 프록시 메서드 로직이 작동되지 않는다. 정리하자면 프록시 대상 객체는 상속에 있어 제한이 있으면 안된다는 것이다. 대충 다음 정도만을 조심하면 될 것이다.
- 클래스와 메소드에 final 키워드 적용
- 추상 클래스(abstract class)
- 클래스의 생성자를 private화 하여 생성자를 제한할 경우
CGLIB 적용이 안되는 예시
실제로 위의 샘플 코드에서 대상 객체의 메소드를 final 화 해보자. 그리고 실행하면 어떠한 예외(Exception)은 발생하지는 않겠지만 대신에 프록시가 적용되지 않음을 볼 수 있다. CGLIB는 타겟을 상속하고 타겟의 메소드를 오버라이딩하여 프록시 핸들링을 하기 때문에 오버라이딩이 불가능한 final 메서드는 프록시화가 되지 않게 된다.
class Subject {
public final void call() {
System.out.println("서비스 호출");
}
}
# 참고자료
인프런 - 더 자바, 코드를 조작하는 다양한 방법
https://docs.oracle.com/javase/8/docs/api/java/lang/reflect/Proxy.html
https://docs.oracle.com/javase/8/docs/technotes/guides/reflection/proxy.html
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.