동적 프록시 생성 기술 JDK Dynamic Proxy, CGLIB
프록시는 접근 제어, 값 변형을 사용하는데 유용한 패턴이다. 하지만, 특정 클래스마다 프록시 클래스를 만들어 작업하기에는 너무 고되다.
자바에서 기본적으로 제공하는 프록시 생성 오픈 소스를 사용하면 동적으로 프록시를 생성할 수 있다. JDK Dynamic proxy는 인터페이스를 기반으로, CGLIB는 상속을 기반으로 프록시를 생성한다. 이 두가지 방식은 모두 스프링에서 프록시를 만드는데 사용하는 기술이다.
우선, 프록시가 인터페이스, 상속 기반으로 어떻게 만들어지는지 잘 모른다면 아래 게시글을 먼저 참고하자!
JDK Dynamic Proxy
JDK Dynamic Proxy는 리플렉션을 사용하여 프록시를 동적으로 생성한다.
리플렉션(reflection)기술
리플렉션(reflection)은 클래스와 메서드의 메타정보를 동적으로 획득하고 코드도 동적으로 호출할 수 있다.
리플렉션의 간단한 예시이다.
package com.example
class A {
fun callA(): String = "A"
fun callB(): String = "B"
}
class ReflectionTest {
private val logger = LoggerFactory.getLogger(this::class.simpleName)
@Test
fun `reflection test`() {
val classA: Class<*> = Class.forName("com.example.A")
val targetA = A()
val methodCallA = classA.getMethod("callA")
val methodCallB = classA.getMethod("callB")
logger.info("callA : ${methodCallA.invoke(targetA)}")
logger.info("callB : ${methodCallB.invoke(targetA)}")
}
}
- classA는 Class.forName()에 패키지 경로를 넘겨주면 클래스에 대한 메타 데이터를 반환한다.
- methodCallA, methodCallB는 클래스 메타 데이터에서 메서드 이름을 사용하여 메서드의 메타데이터를 가져온다.
- methodCall.invoke(targetA)은 가져온 메서드 메타데이터에 객체를 넘겨 실제 그 메서드가 실행되게 한다.
targetA를 통해 직접 메서드를 호출하지 않고 리플렉션으로 메서드를 호출하는 장점은 클래스와 메서드 정보를 동적으로 변경할 수 있다는 점이다.
@Test
fun reflection을 사용한 dynamic call test`() {
val classA: Class<*> = Class.forName("com.example.A")
val targetA = A()
val methodCallA = classA.getMethod("callA")
val methodCallB = classA.getMethod("callB")
logger.info("callA : ${dynamicCall(methodCallA, targetA)}")
logger.info("callB : ${dynamicCall(methodCallB, targetA)}")
}
private fun dynamicCall(method: Method, target: Any?): Any? {
return method.invoke(target)
}
여기까지 봤을 때 어떤 것이 장점인지 쉽게 와닿지 않는다.
예를 들어 메서드의 실행시간을 측정하는 로직이 있다고 하자.
@Test
fun `targetA의 callA()실행 시간 측정 로직`() {
val targetA = A()
val startTime = System.currentTimeMillis()
targetA.callA()
val endTime = System.currentTimeMillis()
logger.info("걸린 시간 : ${endTime - startTime}")
}
@Test
fun `targetB의 callA()실행 시간 측정 로직`() {
val targetB = B()
val startTime = System.currentTimeMillis()
targetB.callA()
val endTime = System.currentTimeMillis()
logger.info("걸린 시간 : ${endTime - startTime}")
}
targetA와 targetB의 실행시간 측정 로직은 동일하다. 이걸 공통부분으로 추출하려면 어떻게 할 수 있을까? 생각보다 간단하지 않다.
중간에 호출하는 메서드가 다르기 때문이다. targetA.callA(), targetB.callA()를 뭔가 파라미터로 받고 메서드를 실행해주는 기능이 있으면 해결할 수 있을 것 같다. 이때. 리플렉션이 사용될 수 있다.
@Test
fun `targetB의 callA()실행 시간 측정 로직`() {
val classA: Class<*> = Class.forName("com.example.A")
val classB: Class<*> = Class.forName("com.example.B")
val methodCallA = classA.getMethod("callA")
val methodCallB = classB.getMethod("callB")
val targetA = A()
val targetB = B()
logger.info("call: ${dynamicCall(methodCallA, targetA)}") // 동적 호출
logger.info("call: ${dynamicCall(methodCallB, targetB)}") // 동적 호출
}
fun dynamicCall(method: Method, target: Any?): Any? {
val startTime = System.currentTimeMillis()
val result = method.invoke(target) // 전달 받은 메서드 실행
val endTime = System.currentTimeMillis()
logger.info("걸린 시간 : ${endTime - startTime}")
return result
}
dynamicCall은 메서드 정보와 타깃을 넘겨받아 메서드를 invoke 한다. 따라서 서로 다른 클래스의 메서드도 파라미터로 전달받아 실행 시 킬 수 있다. 이런 점을 잘 활용하면 메서드 호출 전 후에 로직을 추가한다거나, 값 변형, 접근 제어 등을 할 수도 있을 것 같다.
맞다. JDK dynamic proxy는 이런 리플렉션 기술을 사용해서 프록시를 만든다.
* 단, 리플렉션은 런타임에 동작하기 때문에 예외 발생 시 런타임에 잡힌다. 오류는 컴파일러 단계에서 잡히는 게 가장 좋다. 프로그래밍 언어들은 타입 정보를 통해 컴파일 시점에 오류를 잡아줬지만 리플렉션은 이를 역행하는 방식으로 프레임 워크나 공통적인 부분의 처리가 필요할 때 부분적으로 사용하자.
JDK dynamic proxy는 인터페이스를 기반으로 프록시를 생성한다. 따라서 인터페이스는 반드시 필요하다.
동적으로 프록시를 생성하는 모습을 확인해야 하므로 인터페이스를 2개 만들고 구현체를 만든다.
interface AInterface {
fun call(): String
}
interface BInterface {
fun call(): String
}
class AImpl : AInterface {
private val logger = LoggerFactory.getLogger(this::class.simpleName)
override fun call(): String {
logger.info("A 호출")
return "data"
}
}
class BImpl : BInterface {
private val logger = LoggerFactory.getLogger(this::class.simpleName)
override fun call(): String {
logger.info("B 호출")
return "data"
}
}
그다음으로 JDK dynamic proxy에서 적용할 로직을 만들어준다.
import java.lang.reflect.InvocationHandler
class TimeInvocationHandler(private val target: Any?) : InvocationHandler {
private val logger = LoggerFactory.getLogger(this::class.simpleName)
override fun invoke(proxy: Any?, method: Method?, args: Array<Any?>?): Any? {
val startTime = System.currentTimeMillis()
val result = method?.invoke(target, *args ?: arrayOf())
val endTime = System.currentTimeMillis()
logger.info("걸린 시간 : ${endTime - startTime} Millis")
return result
}
}
이제 JDK dynamic proxy를 사용해보자!
@Test
fun `dynamicA`() {
val target: AInterface = AImpl()
val timeInvocationHandler: InvocationHandler = TimeInvocationHandler(target)
val proxy = Proxy.newProxyInstance(
AInterface::class.java.classLoader,
arrayOf(AInterface::class.java),
timeInvocationHandler
) as AInterface
proxy.call()
logger.info("targetClass=${target::class}")
logger.info("proxyClass=${proxy.javaClass}")
}
@Test
fun `dynamicB`() {
val target: BInterface = BImpl()
val timeInvocationHandler: InvocationHandler = TimeInvocationHandler(target)
val proxy = Proxy.newProxyInstance(
AInterface::class.java.classLoader,
arrayOf(BInterface::class.java),
timeInvocationHandler
) as BInterface
proxy.call()
logger.info("targetClass=${target::class}")
logger.info("proxyClass=${proxy.javaClass}")
}
두 테스트는 AInterface, BInterface를 각각 프록시를 만들어 호출했다. 코드의 차이는 Proxy.newProxyInstance에 어떤 파라미터를 넘기고 있는지 정도이다. 코드 레벨로 들어가 보자.
1. 실제 객체 생성
val target: AInterface = AImpl()
2. 프록시 로직 생성
val timeInvocationHandler: InvocationHandler = TimeInvocationHandler(target)
객체를 파라미터로 넘겨준다. TimeInvocationHandler는 객체의 메서드를 실행하기 전, 후 시간을 측정하는 로직이 포함되어있다.
3. 프록시 생성
val proxy = Proxy.newProxyInstance(
AInterface::class.java.classLoader,
arrayOf(AInterface::class.java),
timeInvocationHandler
) as AInterface
java.lang.reflect.Proxy에서 Proxy.newProxyInstance를 사용해서 동적으로 프록시를 만든다.
클래스 로더, 인터페이스, 핸들러 로직을 넘겨줘한다. 마지막으로 넘겨주는 인터페이스로 캐스팅한다.
4. 프록시 호출 & 로깅
proxy.call()
logger.info("targetClass=${target::class}")
logger.info("proxyClass=${proxy.javaClass}")
만들어진 프록시를 호출하면 핸들러의 로직과 함께 실행된다.
proxy.call()을 호출하게 되면 동작하는 순서는 다음과 같다.
1. jdk dynamic proxy는 InvocationHandler.invoke()를 호출한다. TimeInvocationHandler을 넘겼으므로 TimeInvocationHandler.invoke()
2. TimeInvocationHandler.invoke()가 실행되면서 작성해둔 method.invoke()를 호출한다. TimeInvocationHandler는 AImpl을 넘겨 만들었으니 AImpl의 call()이 실행된다.
3. AImpl의 call()이 끝나면 다시 TimeInvocationHandler.invoke()의 나머지 로직이 실행된다.
다시 정리하자면 JDK dynamic proxy를 사용해서 프록시를 만들려면 다음 순서처럼 하면 된다.
1. 인터페이스를 만든다.
2. 실제 객체가 인터페이스를 구현한다.
3. InvocationHander인터페이스를 구현하는 핸들러를 만든다. 이때 실제 객체를 생성자에 넘겨준다.
4. Proxy.newInstance()를 사용해서 클래스 로더, 인터페이스, 핸들러를 넘겨주면 프록시가 만들어진다.
JDK Dynamic proxy의 제약
JDK Dynamic proxy방식은 인터페이스가 반드시 필요하다
CGLIB Proxy
CGLIB는 바이트 코드를 조작해서 동적으로 프록시를 만들어주는 라이브러리다. 인터페이스 없이도 프록시를 만들어 낼 수 있다.
CGLIB는 구체 클래스를 상속받아 프록시를 생성하므로 상속할 수 있는 대상에 대해서만 프록시가 적용된다.
먼저, 프록시를 만들 클래스를 준비하자. 여기서 open으로 클래스와 메서드가 선언됐다. 프록시를 적용하기 위해서는 둘 다 상속이 가능해야 한다.
open class AClass {
private val logger = LoggerFactory.getLogger(this::class.simpleName)
open fun call() {
logger.info("A호출")
}
}
그다음 MethodInterceptor를 구현하는 클래스를 만든다. 이 클래스는 프록시의 로직이 담기는 클래스이다. JDK dynamic proxy의 InvocationHandler를 구현한 클래스와 유사하다.
class TimeMethodInterceptor(private val target: Any?) : MethodInterceptor {
private val logger = LoggerFactory.getLogger(this::class.simpleName)
override fun intercept(obj: Any?, method: Method?, args: Array<out Any>?, proxy: MethodProxy?): Any? {
val startTime = System.currentTimeMillis()
val result = method?.invoke(target, *args ?: arrayOf())
val endTime = System.currentTimeMillis()
logger.info("걸린 시간 : ${endTime - startTime} Millis")
return result
}
}
테스트 코드이다. CGLIB는 다음과 같이 사용할 수 있다.
@Test
fun `dynamicA`() {
val target = AClass()
val enhancer = Enhancer()
enhancer.setSuperclass(AClass::class.java)
enhancer.setCallback(TimeMethodInterceptor(target))
val proxy = enhancer.create() as AClass
proxy.call()
logger.info("targetClass=${target::class}")
logger.info("proxyClass=${proxy.javaClass}")
}
1. target 객체를 생성한다.
val target = AClass()
2. enhancer를 생성한다. cglib는 enhancer를 사용해서 프록시를 생성한다.
val enhancer = Enhancer()
3. enhancer.setSuperclass()는 어떤 클래스를 상속받아 프록시를 만들지 정해준다.
enhancer.setSuperclass(AClass::class.java)
4. enhancer.setCallback() 프록시의 로직을 넘겨준다.
enhancer.setCallback(TimeMethodInterceptor(target))
5. 프록시 호출 및 로깅
proxy.call()
logger.info("targetClass=${target::class}")
logger.info("proxyClass=${proxy.javaClass}")
만들어진 프록시를 호출하면 핸들러의 로직과 함께 실행된다.
CGLIB의 제약
1. 부모 클래스의 생성자를 체크해야 한다. CGLIB는 상속을 사용하기 때문에 자식 클래스가 만들어지기 위해서는 부모 클래스의 생성자를 호출해야 한다. 따라서, 기본 생성자가 필요하다.
2. 클래스에 final을 붙이면 상속이 불가능하므로 CGLIB에서 예외가 발생한다.
3. 메서드에 final을 붙이면 오버 라이딩이 불가능하므로 CGLIB에서는 프록시 로직이 동작하지 않는다.
kotlin에서는 클래스와 메서드에 아무것도 붙이지 않을 경우 final로 동작하니 유의가 필요하겠다.