JDK 동적 프록시의 한계
JDK 동적 프록시를 사용할 때는 인터페이스가 필수이다. 그렇다면, 이전 포스팅에서 만들었던 V2 애플리케이션처럼 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까? 이것은 일반적인 방법으로는 어렵고 CGLIB라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야 한다.
CGLIB - 소개
CGLIB: Code Generator Library
- CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다.
- CGLIB를 사용하면 인터페이스가 없어도 구체클래스만 가지고 동적 프록시를 만들어낼 수 있다.
- CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다.
참고로, 우리가 CGLIB를 직접 사용하는 경우는 거의 없다. 이후에 설명할 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용하게 도와주기 때문에, 너무 깊이있게 파기보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다. 예제 코드로 CGLIB를 간단히 이해해보자.
공통 예제 코드
앞으로 다양한 상황을 설명하기 위해서 먼저 공통으로 사용할 예제 코드를 만들어보자.
- 인터페이스와 구현이 있는 서비스 클래스 - ServiceInterface, ServiceImpl
- 구체 클래스만 있는 서비스 클래스 - ConcreteService
ServiceInterface
package cwchoiit.springadvanced.proxy.common.service;
public interface ServiceInterface {
void save();
void find();
}
ServiceImpl
package cwchoiit.springadvanced.proxy.common.service;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ServiceImpl implements ServiceInterface{
@Override
public void save() {
log.info("save 호출");
}
@Override
public void find() {
log.info("find 호출");
}
}
ConcreteService
package cwchoiit.springadvanced.proxy.common.service;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ConcreteService {
public void call() {
log.info("ConcreteService call 호출");
}
}
CGLIB - 예제 코드
JDK 동적 프록시에서 공통으로 사용되는 코드들을 작성하기 위해 InvocationHandler를 제공했듯이, CGLIB는 MethodInterceptor를 제공한다.
MethodInterceptor - CGLIB 제공
package org.springframework.cglib.proxy;
import java.lang.reflect.Method;
public interface MethodInterceptor extends Callback {
Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
- obj → CGLIB가 적용된 객체
- method → 호출된 메서드
- args → 메서드를 호출하면서 전달된 인수
- proxy → 메서드 호출에 사용
TimeMethodInterceptor
package cwchoiit.springadvanced.proxy.cglib.code;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
@Slf4j
@RequiredArgsConstructor
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
@Override
public Object intercept(Object obj,
Method method,
Object[] args,
MethodProxy proxy) throws Throwable {
log.info("TimeMethodInterceptor 실행");
long startTime = System.currentTimeMillis();
Object result = proxy.invoke(target, args);
long endTime = System.currentTimeMillis();
log.info("TimeMethodInterceptor 종료 {} ms", endTime - startTime);
return result;
}
}
- TimeMethodInterceptor는 MethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다.
- JDK 동적 프록시를 만들어낼 때 예제와 거의 같은 코드이다. 여기에다가 프록시가 적용해줄 공통 기능 코드를 작성하고 실제 객체를 호출하면 된다.
- Object target → 프록시가 호출할 실제 대상
- proxy.invoke(target, args) → 실제 대상을 동적으로 호출한다. JDK 동적 프록시는 전달받는 Method를 사용해서 method.invoke(...)를 호출했는데, 여기에서는 그렇게 해도 무방하지만 CGLIB는 성능상 MethodProxy를 사용하는 것을 권장한다.
이제 테스트 코드로 CGLIB를 사용해서 프록시를 동적으로 만들어보자!
CglibTest
package cwchoiit.springadvanced.proxy.cglib;
import cwchoiit.springadvanced.proxy.cglib.code.TimeMethodInterceptor;
import cwchoiit.springadvanced.proxy.common.service.ConcreteService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.cglib.proxy.Enhancer;
@Slf4j
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("target: {}", target.getClass());
log.info("proxy: {}", proxy.getClass());
}
}
- ConcreteService는 인터페이스가 없는 구체 클래스이다. 여기에 CGLIB를 사용해서 프록시를 생성해보자.
- Enhancer → CGLIB는 Enhancer를 사용해서 프록시를 생성한다.
- enhancer.setSuperclass(ConcreteService.class) → CGLIB는 구체 클래스를 상속받아서 프록시를 생성할 수 있다. 따라서, 어떤 구체 클래스를 상속 받을지 지정해야한다.
- enhancer.create() → 프록시를 생성한다. 앞서 할당한 enhancer.setSuperclass(ConcreteService.class)에서 지정한 클래스를 상속 받아서 프록시가 만들어진다.
실행 결과
14:16:55.264 [Test worker] INFO cwchoiit.springadvanced.proxy.cglib.CglibTest -- target: class cwchoiit.springadvanced.proxy.common.service.ConcreteService
14:16:55.266 [Test worker] INFO cwchoiit.springadvanced.proxy.cglib.CglibTest -- proxy: class cwchoiit.springadvanced.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$48ab274a
- 실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다.
- CGLIB가 생성한 프록시 이름 → ConcreteService$$EnhancerByCGLIB$$48ab274a
- 참고로, JDK 동적 프록시가 생성한 클래스 이름은 이렇게 생겼다 → jdk.proxy3.$Proxy12
CGLIB의 제약
클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
- 부모 클래스의 생성자를 체크해야 한다. → CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다.
- 클래스에 final 키워드가 붙으면 상속이 불가능하다. → 그 말은 CGLIB로 프록시를 만들어낼 수 없다.
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. → 그 말은 CGLIB로 프록시를 만들어내도 그 메서드에는 프록시 로직이 동작하지 않는다.
정리
JDK 동적 프록시와 CGLIB를 사용해서 동적으로 프록시를 만들어 낼 수 있게 됐는데 문제는 JDK 동적 프록시는 인터페이스가 필수라서 구체클래스만 있는 경우 CGLIB를 사용해야 하고, 반대로 CGLIB로 프록시를 만들때는 구체 클래스를 기반으로 상속을 받아 프록시를 만들어내니 어쩔땐 이걸 쓰고 어쩔땐 저걸 쓰고 이래야 할까? 그러면 어쩔땐 InvocationHandler를 만들고, 어쩔땐 MethodInterceptor를 만들고 이래야 할까? 이렇게 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되면 얼마나 좋을까? 그리고 보통 자바와 스프링은 이렇게 같은 역할을 두고 서로 다른 기술을 사용할때 항상 뭐다? 역할과 구현을 나눠서 표준을 제공하고 그 표준을 구현한 구현체를 제공해준다. 이제 스프링이 제공하는 ProxyFactory를 알아보자!
프록시 팩토리 - 소개
문제점을 다시 리마인드 해보자!
- 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야 할까?
- 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 할까?
- 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되었으면!?
스프링은 유사한 구체적인 기술들이 있을 때, 그것들을 통합해서 일관성 있게 접근할 수 있고, 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공한다. 스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory)라는 기능을 제공한다. 이전에는 상황에 따라서 JDK 동적 프록시를 사용하거나, CGLIB를 사용해야 했다면, 이제는 이 프록시 팩토리 하나로 편리하게 동적 프록시를 생성할 수 있다. 프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고 구체 클래스만 있다면 CGLIB를 사용한다. 그리고 이 설정을 변경할 수도 있다.
그럼 추상화로 어떤 것을 사용해도 상관없게 해주는 것 까지는 이해했는데, 결국 JDK 동적 프록시는 InvocationHandler를 만들어야 하고 CGLIB는 MethodInterceptor를 각각 중복으로 만들어야 하는 건 변함없을까? 스프링은 이 문제를 해결하기 위해 부가 기능을 적용할 때 Advice라는 새로운 개념을 도입했다. 개발자는 InvocationHandler, MethodInterceptor를 신경쓰지 않고 Advice만 만들면 된다. 결과적으로 InvocationHandler나 MethodInterceptor는 우리가 만든 Advice를 호출하게 된다. 프록시 팩토리를 사용하면, Advice를 호출하는 전용 InvocationHandler, MethodInterceptor를 내부에서 사용한다.
그럼, 특정 조건에 맞을 때 프록시 로직을 적용하는 기능은 어떻게 하면 좋을까? 위에서 /no-log로 요청하는 것은 프록시 로직이 동작하지 않게끔 하게 했던 것 처럼 말이다. 어떤 경우엔 프록시를 적용하고 어떤 경우에는 프록시를 적용하지 않을지에 대한 기능을 스프링은 Pointcut 이라는 개념을 도입해서 이 문제를 일관성있게 해결한다.
프록시 팩토리 - 예제 코드1
Advice 만들기
Advice는 프록시에 적용하는 부가 기능 로직이다. 이것은 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor의 개념과 유사하다. 둘을 개념적으로 추상화 한 것이다. 프록시 팩토리를 사용하면 둘 대신에 Advice를 사용하면 된다.
Advice를 만드는 방법은 여러가지가 있지만, 기본적인 방법은 MethodInterceptor 인터페이스를 구현하면 된다.
MethodInterceptor
package org.aopalliance.intercept;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@FunctionalInterface
public interface MethodInterceptor extends Interceptor {
@Nullable
Object invoke(@Nonnull MethodInvocation invocation) throws Throwable;
}
- 엥?! MethodInterceptor는 CGLIB꺼 아닌가요? → 이름이 똑같은데 패키지를 잘 보자. CGLIB것이 아니라 org.aopalliance.intercept이다.
- 그리고 생김새도 살짝 다르다. 얘는 MethodInvocation 이라는 딱 하나의 파라미터만 받는다. 이 내부에 실제 객체를 호출하는 것, 현재 프록시 객체 인스턴스, args, 메서드 정보 등이 다 포함되어 있다. JDK 동적 프록시의 InvocationHandler나, CGLIB의 MethodInterceptor에서 파라미터로 제공되는 부분들이 이 안으로 다 들어갔다고 생각하면 된다.
- 근데 Advice를 만든다더니 왜 이걸 구현해야 하나요? → 이 MethodInterceptor는 Interceptor를 상속받고, Interceptor는 Advice를 상속한다. 그래서 괜찮다.
TimeAdvice
package cwchoiit.springadvanced.proxy.common.advice;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
@Slf4j
public class TimeAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("TimeAdvice 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed();
long endTime = System.currentTimeMillis();
log.info("TimeAdvice 종료 {} ms", endTime - startTime);
return result;
}
}
- invoke()에서 invocation.proceed()를 호출하는데 이게 실제 객체의 메서드를 호출하는 부분이라고 보면 된다. 원래 기존에는 프록시는 항상 실제 객체를 주입받아야 한다고 했는데 여기에는 그런 코드가 없다. 이는 ProxyFactory를 만들 때 전달해주기 때문이다. 말보단 코드. ProxyFactory를 만들어내는 코드를 보면 자연스럽게 이해가 될 것이다.
- 이 invoke()안에 프록시가 공통으로 수행할 로직을 작성하면 된다.
ProxyFactoryTest
package cwchoiit.springadvanced.proxy.proxyfactory;
import cwchoiit.springadvanced.proxy.common.advice.TimeAdvice;
import cwchoiit.springadvanced.proxy.common.service.ServiceImpl;
import cwchoiit.springadvanced.proxy.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class ProxyFactoryTest {
@Test
@DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
void interfaceProxy() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("target : {}", target.getClass());
log.info("proxy : {}", proxy.getClass());
proxy.find();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
}
}
- new ProxyFactory(target) → 프록시 팩토리를 생성할 때, 생성자에 프록시의 호출 대상을 함께 넘겨준다. 프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 만들어낸다. 만약, 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고, 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성한다. 여기서는 target이 new ServiceImpl()의 인스턴스이기 때문에 ServiceInterface 인터페이스가 있다. 따라서 이 인터페이스를 기반으로 JDK 동적 프록시를 생성한다. 그리고 이렇게 실제 호출 대상을 여기서 알려주기 때문에 Advice에는 실제 호출 대상을 받을 필요가 없는 것이다.
- proxyFactory.addAdvice(new TimeAdvice()) → 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor의 개념과 유사하다. 이렇게 프록시가 제공하는 부가 기능 로직을 Advice라 한다.
- proxyFactory.getProxy() → 프록시 객체를 생성하고 그 결과를 받는다.
실행 결과
16:35:27.253 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl
16:35:27.255 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class jdk.proxy3.$Proxy13
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ServiceImpl -- find 호출
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
- 실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다. JDK 동적 프록시로 잘 적용되었다.
- 프록시 팩토리를 통해서 프록시 적용을 확인할 수도 있는데, 프록시 팩토리를 통해서 프록시가 생성되면 AopUtils라는 유틸리티 클래스를 사용해서 JDK 동적 프록시인지 CGLIB인지 확인할 수 있다.
프록시 팩토리 - 예제 코드2
이제 인터페이스 말고 구체 클래스를 넘겨줘보자.
@Test
@DisplayName("구체 클래스만 있으면 CGLIB 사용")
void concreteProxy() {
ConcreteService target = new ConcreteService();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();
log.info("target : {}", target.getClass());
log.info("proxy : {}", proxy.getClass());
proxy.call();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
- ConcreteService는 구체 클래스다. 이 객체를 target으로 넘기면 ProxyFactory는 CGLIB로 동적 프록시를 만든다.
- 나머지 내용은 위 인터페이스를 넘긴것과 동일하다.
실행 결과
09:56:14.466 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ConcreteService
09:56:14.468 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class cwchoiit.springadvanced.proxy.common.service.ConcreteService$$SpringCGLIB$$0
09:56:14.469 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
09:56:14.470 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ConcreteService -- ConcreteService call 호출
09:56:14.470 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
proxyTargetClass 옵션
ProxyFactory한테 인터페이스를 넘겨도 CGLIB로 만들어달라고 할 수 있다.
ProxyFactory가 가지고 있는 setProxyTargetClass() 메서드를 사용하면 된다. 이 메서드는 이름 그대로 Proxy를 Target의 클래스로 설정하겠다는 의미다. 파라미터로 true를 넘기면 된다.
@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고, 클래스 기반 프록시 생성")
void proxyTargetClass() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
proxyFactory.setProxyTargetClass(true);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("target : {}", target.getClass());
log.info("proxy : {}", proxy.getClass());
proxy.find();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
- 이번엔 target이 인터페이스가 있지만, 프록시팩토리의 setProxyTargetClass(true) 옵션을 설정해서, CGLIB로 프록시를 만들고 클래스 기반 프록시를 생성하는 코드이다.
실행 결과
09:59:08.286 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl
09:59:08.288 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl$$SpringCGLIB$$0
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ServiceImpl -- find 호출
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
- 실행 결과를 보면 프록시 클래스가 ServiceImpl$$SpringCGLIB$$...으로 되어있는 것을 볼 수 있다.
프록시 팩토리의 기술 선택 방법
- 대상에 인터페이스가 있으면 JDK 동적 프록시, 인터페이스 기반 프록시
- 대상에 인터페이스가 없으면 CGLIB, 구체 클래스 기반 프록시
- setProxyTargetClass(true)를 사용하면 CGLIB, 구체 클래스 기반 프록시, 인터페이스 여부와 상관없음.
정리
- 프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고 매우 편리하게 동적 프록시를 생성할 수 있다.
- 프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게 Advice 하나로 편리하게 사용할 수 있었다. 이것은 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우 InvocationHandler가 Advice를 호출하도록 개발해두고, CGLIB인 경우 MethodInterceptor가 Advice를 호출하도록 기능을 개발해두었기 때문이다.
- 참고로, 스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용한다. 따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.
'Spring Advanced' 카테고리의 다른 글
빈 후처리기(BeanPostProcessor) (2) | 2023.12.27 |
---|---|
Advisor, Advice, Pointcut (0) | 2023.12.15 |
Proxy/Decorator Pattern 2 (JDK 동적 프록시) (0) | 2023.12.14 |
Proxy/Decorator Pattern (0) | 2023.12.13 |
Template Callback Pattern (0) | 2023.12.12 |