기존 데코레이터 패턴과 프록시 패턴의 문제
JDK 동적 프록시와 CGLIB 둘 다 데코레이터 패턴 혹은 프록시 패턴을 사용하는데 있어서 동적으로 수행해주는 도구입니다.
기존에 데코레이터 패턴과 프록시 패턴의 가장 큰 문제는 해당 패턴을 적용하기 위해서는 해당 패턴이 사용되어야 할 코드를 재사용하지 않고, 또 해당 템플릿을 사용하기 위해 패턴을 사용해야 할 클래스들을 생성해주어야 한다는 큰 문제가 있었습니다.
예를 들어서, 해당 패턴을 적용할 클래스가 100개라면 패턴을 적용하기 위한 클래스를 100개를 만들어주어야 한다는 것이지요. 만약 프록시 체인으로 구성되어 있다면, 200개, 300개 몇 개 까지 늘어날 지 알 수가 없습니다.
이를 해결하기 위해서 자바에서 제공해주는 JDK 동적 프록시와 외부 라이브러리인 CGLIB를 사용하여 패턴을 적용하기 위한 코드를 1개만 생성해주어도 적용 할 수 있다는 큰 장점이 있는데, 언제 JDK 동적 프록시를 사용해야되고 CGLIB를 사용해야하는지, 어떻게 적용되는지에 대해서 한 번 알아보는 시간을 가져봅시다.
리플렉션
먼저 JDK 동적 프록시와 CGLIB는 둘 다 리플렉션을 사용하여 동적으로 패턴을 적용해 줄 수 있도록 해줍니다.
참고로, 동적이라는 부분은 런타임시에 적용된다고 이해하셔야 합니다. 반대로 정적은 컴파일 단계에서 정해지는 것입니다.
먼저 리플렉션이 뭐하는 녀석인지 한 번 살펴보도록 합시다.
구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API
정의는 다음과 같습니다.
즉, 리플렉션을 사용하면 자바 프로그램 내부이 클래스와 필드 메서드의 속성등을 조회하거나 수정 할 수 있게 되는 것입니다.
이때, 클래스를 조회하고 수정하는데 사용하는 클래스는 Class 이고, 메서드는 Method 입니다. Class가 우리가 클래스 파일을 만들 때 사용하는 Class가 아닌, 실제로 Class라고 선언 된 클래스가 있습니다. 객체로서 사용가능하다는 것입니다.
그럼 어떻게 클래스를 불러오는지에 대해서 먼저 알아보겠습니다.
리플렉션으로 클래스 가져오기
// 1. 클래스가 로딩이 된 이후에 가져오는 방법
Class<Hello> hello = Hello.class;
// 2. 이미 선언된 인스턴스를 활용해 가져오는 방법
// 모든 인스턴스는 Object를 상속받고 getClass 메서드를 사용 할 수 있습니다.
Hello hello = new Hello();
Class<? extends Hello> helloClass = hello.getClass();
// 3. 직접 경로와 클래스명을 적어줘서 가져오는 방법
Class helloClass = Class.forName("test.reflection.Hello");
위와 같이 3가지 방법을 사용 할 수 있습니다.
클래스를 가져왔다면 사용하기 위한 메서드도 가져올 수 있어야 되겠죠? 메서드를 불러오는 방법도 한 번 살펴봅시다.
리플렉션으로 메서드 가져오기
Class<Hello> classHello = Hello.class;
// 1. 선언된 전체 메소드 가져오기
for(Method method : classHello.getDeclaredMethods()){
System.out.println(method); // 경로 까지 전부
System.out.println(method.getName()); // 메서드명 만
}
// 2. 특정 메서드 가져오기
Method method = classHello.getMethod("메서드명");
이와 같은 방식을 사용해서 메서드 또한 가져올 수 있습니다.
이 외에도 상위 클래스, 인터페이스, 생성자 또한 가져올 수 있습니다.
엄청 많은 것들을 제공하고 있으니 공식문서를 한 번 참고해보시면 좋을 것 같습니다.
이제 리플렉션이 어떤 것을 하는 녀석인지 우리는 이해를 할 수 있게 되었습니다. 그런데 여기서 한 가지 의문이 생길 수 있습니다.
아니 그래서 이걸 이용해서 어떻게 동적으로 프록시를 적용한다는 거지?
라는 생각이 머리에 떠오를 수 있습니다.
우리는 이때, 객체지향 언어의 목표가 무엇인지에 대해서 알 필요가 있습니다. 객체를 이용해 메시지를 전달하자.
즉, 클래스를 객체로 바라볼 수 있게 되었기때문에 우리는 클래스 혹은 메서드 객체를 통해서 메시지를 전달하고, 이에 맞게 응답 할 수 있도록 해줄 수 있게 된 것입니다. 그럼 먼저 JDK 동적 프록시 부터 살펴보도록 하겠습니다.
JDK 동적 프록시
JDK 동적 프록시는 데코레이터 패턴 및 프록시 패턴을 위해 클래스를 추가 생성하지 않고, 동적으로 패턴을 적용할 수 있게 해주는 방식입니다.
하지만 한 가지 중요한 조건이 있다면 JDK 동적 프록시는 Interface를 기반으로 Proxy를 생성합니다.
따라서 생성하기 위해서는 인터페이스가 꼭 존재해야합니다.
그럼 인터페이스가 없었다면 생성해주어야 하나요?
굉장히 좋은 질문입니다. JDK 동적 프록시 말고도 CGLIB라는 방식을 위에서 얘기했었는데요. CGLIB는 바이트 코드를 조작해서 클래스를 상속받아서 프록시를 생성 할 수 있도록 해줍니다. 따라서 인터페이스 없이 구상만 존재해도 프록시를 생성 할 수 있습니다.
그럼 그냥 CGLIB를 사용하는 것이 좋을 것 같아요
실제로도 CGLIB를 사용하는 것이 더 편해보이기는 합니다. 그러나, 상속을 사용하기 때문에 클래스가 final 일 경우, 메서드가 final 일 경우, 조상 클래스의 생성자 문제 등등 고려해야 하는 부분이 많습니다.
그럼 JDK 동적 프록시를 어떻게 사용하는지에 대해서 살펴보도록 합시다.
JDK 동적 프록시의 사용
JDK 동적 프록시는 InvocationHandler 인터페이스를 구현해서 사용합니다.
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
InvocationHandler의 모양은 위와 같습니다.
파라미터의 역할을 한 번 살펴보면 다음과 같습니다.
- proxy : 메서드가 호출된 프록시 인스턴스
- method : 프록시 인스턴스에서 호출된 인터페이스 메서드에 해당하는 Method 인스턴스
- args : 프록시 인스턴스의 메서드 호출에서 전달된 인수 값을 포함하는 개체 배열
이것만 보면 감이 오지 않습니다. 어떻게 사용하는지 예시를 한 번 보도록 합시다.
@Slf4j
public class ExampleInvocationHandler implements InvocationHandler {
private final Object target;
public ExampleInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("메서드를 실행하기 이전에 발생하는 로그입니다.");
Object result = method.invoke(target, args);
log.info("메서드를 실행한 이후에 발생하는 로그입니다.");
return result;
}
}
public interface ExamInterface {
String call();
}
@Slf4j
public class A implements ExamInterface {
@Override
public String call() {
log.info("A 호출");
return "a";
}
}
@Slf4j
public class B implements ExamInterface {
@Override
public String call() {
log.info("B 호출");
return "b";
}
}
기존의 예시에서 그대로 사용하던 로그 추적기의 예시입니다.
JDK 동적 프록시는 인터페이스가 필수라고 했던 것을 기억하나요? 따라서 인터페이스를 선언해준 모습을 확인 할 수 있습니다.
그리고 구현은 다음과 같이 할 수 있습니다.
@Test
void dynamicTest() {
ExampleInvocationHandler handler1 = new ExampleInvocationHandler(new A());
ExampleInvocationHandler handler2 = new ExampleInvocationHandler(new B());
ExamInterface proxy = (ExamInterface) Proxy.newProxyInstance (
ExamInterface.class.getClassLoader(),
new Class[]{ExamInterface.class},
handler1);
ExamInterface proxy2 = (ExamInterface) Proxy.newProxyInstance (
ExamInterface.class.getClassLoader(),
new Class[]{ExamInterface.class},
handler2);
proxy.call();
proxy2.call();
}
신기한 클래스가 하나 보이지요. 바로 Proxy라는 클래스입니다. Proxy 클래스는 java.lang.reflect 패키지에 있습니다.
Proxy 클래스의 인자로 다음과 같은 내용을 전달을 해주어야 합니다.
- loader : 프록시 클래스를 정의하는 클래스 로더
- interfaces : 구현할 프록시 클래스의 인터페이스 목록
- h : 메소드 호출을 디스패치할 호출 핸들러
1번, 2번은 어느정도 감이 올텐데 3번은 감이 안올 수 있는데요. 이전에 저희가 작성한 프록시 패턴 혹은 데코레이터 패턴의 구상과 같은 것들을 이야기 하는 것입니다.
구현은 이렇게 하는거구나 싶겠지만, 나아가서 어떤 실행 순서로 실행되는지는 알 수가 없습니다.
JDK 동적 프록시의 실행 순서
- JDK 동적 프록시의 메서드를 실행합니다. (이때, 형변환을 하였기 때문에 인터페이스의 메서드가 실행됩니다.)
- InvocationHandler의 invoke() 메서드를 호출합니다.
- 내부 로직을 수행합니다.
- 내부 로직 내부에서 실제 target의 메서드가 수행됩니다.
- 수행 후 다시 handler로 돌아와 남은 로직을 수행합니다.
위와 같은 일련의 과정을 정확하게 이해 할 필요가 있습니다.
CGLIB
CGLIB는 Code Generator Library로 Third Party library입니다.
해당 라이브러리는 인터페이스가 아닌 클래스 기반으로 바이트 코드를 조작하여 프록시를 생성하는데, 생성하는 과정에서 ASM이라는 자바 바이트 코드 조작 및 분석 프레임워크를 사용한다고 합니다.
바이트코드를 조작해 프록기슬 만들기 때문에 JDK 동적 프록시보다 성능이 우수하지만 상속 방식을 이용하기 때문에 final 혹은 private 같이 오버라이딩을 지원하지 않는 경우 Proxy를 만드는데에 제한이 있습니다.
스프링에서는 기본적으로 프록시를 만들기 위해 CGLIB를 코어에 내장하고 있습니다. 따라서 스프링을 사용한다면 굳이 추가하지 않아도 되고, 만약 자바에서 사용하려면 라이브러리를 추가해주어야 합니다.
https://mvnrepository.com/artifact/cglib/cglib
에서 찾아 볼 수 있습니다.
CGLIB의 사용
JDK 동적 프록시에서는 InvocationHandler를 구현해서 프록시를 구현했던 것처럼, CGLIB는 MethodInterceptor 인터페이스를 구현해서 사용할 수 있습니다.
public interface MethodInterceptor extends Callback {
Object intercept(Object var1, Method var2, Object[] var3, MethodProxy var4) throws Throwable;
}
MethodInterceptor는 위처럼 생긴 것을 확인 할 수 있습니다. 각 매개변수에 대해서 살펴보면 다음과 같습니다.
- var1 : 이후에 배울 Enhancer를 통해 생성된 프록시 객체
- var2 : 호출 된 메서드
- var3 : 메서드를 호출하면서 전달된 인수
- var4 : 메서드 호출에 사용되는 메서드 프록시 (기존 method를 사용하는 것보다 내부적인 원리로 조금 더 빠르다고 합니다.)
위에서 잠깐 언급이 됐는데, CGLIB에서 프록시를 생성하기 위해서는 Enhancer라는 것을 사용하고 있습니다.
사용 방식은 아래와 같습니다.
Hello hello = new Hello(); // 인터페이스가 아닌 구상
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Hello.class); // 클래스의 슈퍼 클래스 명시
enhancer.setCallback(new methodInterceptor(hello)); // MethodInterceptor를 구현한 패턴
Object proxy = enhancer.create();
// Hello proxy = (Hello) enhancer.create(); 로 형변환 가능
다음과 같이 생성해서 사용 할 수 있습니다. 실행 순서도 크게 다르지 않습니다!
- enhancer로 생성된 프록시의 메서드를 수행합니다.
- proxy가 intercept 메서드를 실행하고, 내부 로직을 수행합니다.
- 내부에서 실제 target의 메서드가 실행되고 반환됩니다.
- 남아있는 내부 로직을 수행합니다.
동적 프록시랑 크게 다르지 않음을 볼 수 있습니다.
다만, 위에서 얘기했듯이 CGLIB는 클래스에 대한 상속을 통해서 구현하기 때문에 몇 가지 주의사항이 존재합니다.
CGLIB 사용 시 주의사항
- 부모 클래스의 생성자 수행 문제
- 클래스에 final 붙으면 상속 불가 : CGLIB에서 예외가 발생
- 메서드에 final, private 붙으면 상속 불가 : 프록시 로직이 제대로 동작하지 않는다.
등의 문제가 있습니다.
또한 동적 프록시와 CGLIB 둘 다 현재 상황으로 보자면 다음과 같은 문제 또한 존재합니다.
인터페이스를 사용하는 것에 대해서는 동적 Proxy를 통한 handler를 구현을 해주어야 하고, 구상 클래스에 대해서는 CGLIB를 위해 interceptor를 따로 구현을 해주어야 합니다. 결국 프록시를 생성하기 위해 2개를 생성해주어야 하는 문제이지요.
하지만, 스프링은 이를 해결하기 위해서 프록시 팩토리라는 것을 사용하여 Advice를 통해 인터페이스를 사용한다면 알아서 동적 프록시로, 구상 클래스라면 CGLIB로 처리를 해주는데요. 무조건 CGLIB로 변경하도록 이에 대한 설정 변경 또한 가능하기도 합니다.
해당 내용은 이후에 스프링 AOP를 이해하는데에 있어서 중요한 초석입니다. 해당 영역을 이해하고 나면 스프링 AOP에 대해 어느정도 더 가까워 질 수 있지 않을까요?
'Backend > Spring' 카테고리의 다른 글
스프링의 빈 후처리기 (0) | 2023.04.24 |
---|---|
[Intellij] JUnit Test 시 No tests found for given includes: (0) | 2023.04.22 |
프록시 (0) | 2023.04.18 |
템플릿 콜백 패턴 (1) | 2023.04.17 |
스프링의 싱글톤 패턴으로 인한 동시성 문제와 그 해결 (0) | 2023.04.14 |