CGLIB와 JDK 동적 프록시를 사용하는 경우 아래와 같은 문제점들이 있었다.
문제점
- 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야할까?
- 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야할까?
- 특정 조건에 맞을 떄 프록시 로직을 적용하는 기능도 공통으로 제공할수있을까? 동적 프록시는 직접 프록시 로직에 넣어줬다!
Q1) 인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야할까?
스프링은 유사한 구체적 기술들이 있을 때, 그것들을 통합해서 일관성 있게 접근할 수 있고, 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공한다.
스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory)라는 기능을 제공한다.
Q2) 두 기술을 함께 사용할때 부가 기능을 적용하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 따로 만들어야할까?
스프링은 이 문제를 해결하기 위해 부가 기능을 적용할때 Advice
라는 새로운 개념을 도입했다. 개발자는 InvocationHandler
나 MethodInterceptor
를 신경쓰지 않고 Advice
만 만들면된다.
결과적으로 InvocationHandler
나 MethodInterceptor'는 'Advice
를 호출하게 된다.
프록시 팩토리를 사용하면 Advice
를 호출하는 전용 InvocationHandler
,MethodInterceptor
를 내부에서 사용한다.
참고
스프링이 adviceInvocationHandler와 adviceMethodInterceptor를 알아서 세팅해놓는다. 따라서 개발자는 개발자는 advice만 만들어서 프록시 팩토리에게 제공해주면 된다.
adviceInvocationHandler, adviceMethodInterceptor는 내부에서 advice를 호출하도록 되어 있다.
즉, 프록시 생성은 프록시 팩토리로, 로직은 advice로 제공해주면 된다. 그 외의 필요한 항목은 스프링이 알아서 세팅해준다.
Q3) 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공된다면?
앞서 특정 메서드 이름의 조건에 맞을 때만 프록시 부가 기능이 적용되는 코드를 직접 만들었다. 스프링은 PointCut
이라는 개념을 도입해서 이 문제를 일관성 있게 해결한다.
Advice
는 프록시에 적용하는 부가 기능 로직이다. 이것은 JDK 동적 프록시가 제공하는 InvocationHandler
와 CGLIB가 제공하는 MethodInterceptor
의 개념과 유사하다. 둘을 개념적으로 추상화 한것이다. 프록시 팩토리를 사용하면 둘 대신에 Advice
를 사용하면 된다.
Advice
를 만드는 방법은 여러가지가 있지만, 기본적으로는 아래의 인터페이스를 구현하면 된다.
MethodInterceptor - 스프링이 제공하는 코드
package org.aopalliance.intercept;
public interface MethodInterceptor extends Interceptor {
Object invoke(MethodInvocation invocation) throws Throwable;
}
org.aopalliance.intercept
패키지는 스프링 AOP 모듈(spring-aop)안에 들어 있다.MethodInterceptor
는 Interceptor
를 상속하고 Interceptor
는 Advice
인터페이스를 상속한다.@Slf4j
public class TimeAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
TimeAdvice
는 앞서 설명한 MethodInterceptor
인터페이스를 구현한다. invocation.proceed()
를 호출하면 target
클래스를 호출하고 그 결과를 받는다.target
클래스의 정보가 보이지 않는다. target
클래스의 정보는 MethodInvocation invocation
안에 모두 포함되어 있다.target
정보를 파라미터로 전달받기 때문이다.@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("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.save();
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 동적 프록시를 생성한다.
proxyFactory.addAdvice(new TimeAdvice()) : 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. JDK 동적 프록시가 제공하는 InvocationHandler
와 CGLIB가 제공하느 MethodInterceptor
의 개념과 유사하다. 이렇게 프록시가 제공하는 부가 기능 로직을 어드바이스(advice)라 한다.
proxyFactory.getProxy()
: 프록시 객체를 생성하고 그 결과를 받는다.
프록시 팩토리로 프록시가 잘 적용되었는지 확인하려면 다음 기능을 사용하면 된다.
AopUtils.isAopProxy(proxy)
: 프록시 팩토리를 통해서 프록시가 생성되면 JDK 동적 프록시나, CGLIB 모두 참이다.AopUtils.isjdkDynamicProxy(proxy)
: 프록시 팩토리를 통해서 프록시가 생성되고, JDK 동적 프록시인 경우 참AopUtils.isCglibProxy(proxy)
: 프록시 팩토리를 통해서 프록시가 생성되고 CGLIB 동적 프록시인 경우 참 @Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고, 클래스 기반 프록시 사용")
void proxyTargetClass() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.save();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
인터페이스가 있지만 CGLIB를 사용해서 인터페이스가 아닌 클래스 기반으로 동적 프록시를 만드는 방법도 존재한다.
프록시 팩토리는 proxyTargetClass
라는 옵션을 제공하는데, 이 옵션에 true
값을 넣으면 인터페이스가 있어도 강제로 CGLIB를 사용한다. 그리고 인터페이스가 아닌 클래스 기반 프록시를 만들어준다.
proxyTargetCalss=true;
: CGLIb, 구체 클래스 기반 프록시, 인터페이스 여부와 상관 없음정리
- 프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고 매우 편리하게 동적 프록시를 생성할 수 있다.
- 프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게
Advice
하나로 편리하게 사용할 수 있었다. 이것은 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우InvocationHandler
가Advice
를 호출하도록 개발해두고 CGLIB인 경우MethodInterceptor
가Advice
를 호출하도록 기능을 개발해두었기 때문이다.
참고
스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다.
따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.
PointCut
) : 어디에 부가 기능을 적용할지 어디에 부가 기능을 적용하지 않을지 판단하는 필터링 로직이다. 주로 클래스와 메서드 이름으로 필터링 한다. 이름 그대로 어떤 포인트(Point)에 기능을 적용할지하지 않을지 잘라서(Cut) 구분하는것이다.Advice
) : 이전에 본 것 처럼 프록시가 호출하는 부가 기능이다. 단순하게 프록시 로직이라 생각하면 된다.Advisor
) : 단순하게 하나의 포인트멋과 하나의 어드바이스를 가지고 있는 것이다. 쉽게 이야기해서 포인트컷1 + 어드바이스1이다.정리하면 부가 기능 로직을 적용해야하는데 포인트컷으로 어디에? 적용할지 선택하고 어드바이스로 어떤 로직을 적용할지 선택하는것이다. 그리고 어디에? 어떤로직?을 모두 알고있는것이 어드바이저이다.
역할과 책임
이렇게 구분한 것은 역할과 책임을 명확하게 분리한 것이다.
어드바이저는 하나의 포인트컷과 하나의 어드바이스를 가지고 있다
프록시 팩토리를 통해 프록시를 생성할 때 어드바이저를 제공하면 어디에 어떤 기능을 제공할 지 알 수 있다.
@Slf4j
public class AdvisorTest {
@Test
void advisorTest1() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
}
new DefaultPointcutAdvisor
: Advisor
인터페이스의 가장 일반적인 구현체이다. 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어주면 된다. 어드바이저는 하나의 포인트컷과 하나의 어드바이스로 구성된다.PointCut.TRUE
: 항상 true
를 반환하는 포인트컷이다. new TimeAdvice()
: 앞서 개발한 TimeAdvice
어드바이스를 제공한다.proxyFactory.addAdvisor(advisor)
: 프록시 팩토리에 적용할 어드바이저를 지정한다. 어드바이저는 내부에 포인트컷과 어드바이스를 모두 가지고 있다. 따라서 어디에 어떤 부가 기능을 적용해야 할지 어드바이저 하나로 알 수 있다. 프록시 팩토리를 사용할 때 어드바이저는 필수이다.proxyFactory.addAdvice(new TimeAdvice())
이렇게 어드바이저가 아니라 어드바이스로도 바로 적용이 가능하다. 이것은 단순히 편의 메서드이고 결과적으로 해당 메서드 내부에서 아래와 같은 어드바이저가 생성된다. DefaultPointcutAdvisor(PointCut.TRUE, new TimeAdvice())
특정 메서드(save()
)에서는 어드바이스 로직을 적용하지만, find()
메서드에서는 어드바이스 로직을 적용하지 않도록 해야한다면 어떻게 할까?
물론 과거에 했던것처럼 어드바이스 로직에 추가해서 메서드 이름으로 코드 분기를 진행할 수도 있다. 하지만 이런 기능에 특화되어서 제공하는것이 바로 포인트컷이다.
public interface Pointcut {
ClassFilter getClassFilter();
MethodMatcher getMethodMatcher();
}
public interface ClassFilter {
boolean matches(Class<?> clazz);
}
public interface MethodMatcher {
boolean matches(Method method, Class<?> targetClass);
//..
}
포인트컷은 크게 ClassFilter
와 MethodMatcher
둘로 이루어진다. 이름 그대로 하나는 클래스가 맞는지, 하나는 메서드가 맞는지 확인할 때 사용한다. 둘다 true
로 반환해야 어드바이스로 적용할 수 있다.
일반적으로는 스프링이 이미 만들어둔 구현체를 사용한다.
static class MyPointcut implements Pointcut {
@Override
public ClassFilter getClassFilter() {
return ClassFilter.TRUE;
}
@Override
public MethodMatcher getMethodMatcher() {
return new MyMethodMatcher();
}
}
static class MyMethodMatcher implements MethodMatcher {
private String matchName = "save";
@Override
public boolean matches(Method method, Class<?> targetClass) {
boolean result = method.getName().equals(matchName);
log.info("포인트컷 호출 method={} targetClass={}", method.getName(), targetClass);
log.info("포인트컷 결과 result={}", result);
return result;
}
@Override
public boolean isRuntime() {
return false;
}
@Override
public boolean matches(Method method, Class<?> targetClass, Object... args) {
return false;
}
}
@Test
@DisplayName("직접 만든 포인트컷")
void advisorTest2() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
MyPointCut
PointCut
인터페이스를 구현한다.true
를 반환하며, 메서드 비교 기느은 MyMethodMatcher
를 사용한다.MyMethodMatcher
MethodMatcher
이다. MethodMatcher
인터페이스를 구현한다.matches()
: 이 메서드에 method
, targetClass
정보가 넘어온다. 이 정보로 어드바이스를 적용할지 적용하지 않을지 판단할 수 있다.isRuntime()
, matches(...args)
: isRuntime()
이 값이 참이면 matches(...args)
메서드가 대신 호출된다. 동적으로 넘어오는 매개변수를 판단로직으로 사용할 수 있다.isRuntime()
이 false
인 경우 클래스의 정적 정보만 사용하기 떄문에 스프링이 내부에서 캐싱을 통해 성능 향상이 가능하지만, isRuntime()
이 true
인 경우 매개변수가 동적으로변경된다고 가정하기 떄문에 캐싱하지 않는다.save()
를 호출한다.Service
클래스의 save()
메서드에 어드바이스를 적용해도 될지 물어본다.true
를 반환한다. 따라서 어드바이스를 호출해서 부가 기능을 적용한다.save()
를 호출한다.
1. 클라이언트가 프록시의 find()
를 호출한다.
2. 포인트컷에 Service
클래스의 find()
메서드에 어드바이스를 적용해도 될지 물어본다.
3. 포인트컷이 false
를 반환한다. 따라서 어드바이스를 호출하지 않고 부가 기능도 적용되지 않는다.
4. 실제 인스턴스를 호출한다.
스프링은 대부분 필요한 포인트컷을 모두 제공한다.
아래의 코드는 NameMatchMethodPointcut
을 사용한 예시다.
@Test
@DisplayName("스프링이 제공하는 포인트컷")
void advisorTest3() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("save");
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
NameMatchMethodPointcut
을 생성하고 setMappedNames(...)
으로 메서드 이름을 지정하면 포인트컷이 완성된다.
NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils 를사용한다.
예) xxx 허용
JdkRegexpMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭한다.
TruePointcut : 항상 참을 반환한다.
AnnotationMatchingPointcut : 애노테이션으로 매칭한다.
AspectJExpressionPointcut : aspectJ 표현식으로 매칭한다.
만약 여러 어드바이저를 하나의 target에 적용하려면 어떻게할까?
하나의 target에 여러 어드바이스를 적용할 수 있을까?
가능하다!
간단하게는 프록시를 여러개 만들어서 각각 체인이 되도록 하면 된다.
하지만, 프록시를 여러번 생성해야한다.
스프링은 이러한 문제를 해결하기 위해 하나의 프록시에 여러 어드바이저를 적용할 수 있게 만들었다.
public class MultiAdvisorTest {
@Test
@DisplayName("여러 프록시")
void multiAdvisorTest1() {
//client -> proxy2(advisor2) -> proxy1(advisor1) -> target
//프록시1 생성
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();
//프록시2 생성, target -> proxy1 입력
ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
proxyFactory2.addAdvisor(advisor2);
ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();
//실행
proxy2.save();
}
@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
void multiAdvisorTest2() {
//client -> proxy -> advisor2 -> advisor1 -> target
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
//프록시1 생성
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
proxyFactory1.addAdvisor(advisor2);
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy();
//실행
proxy.save();
}
@Slf4j
static class Advice1 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("advice1 호출");
return invocation.proceed();
}
}
@Slf4j
static class Advice2 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("advice2 호출");
return invocation.proceed();
}
}
}
addAdvisor()
를 통해서 어드바이저를 등록하면 된다.advisor
가 호출된다. 주요
스프링의 AOP를 처음 공부하거나 사용하면 AOP 적용 수만큼 프록시가 생성된다고 착학하게 된다. 실제 많은 실무 개발자들도 이렇게 생각하기도 한다.
스프링은 AOP를 적용할떄 최적화를 진행해서 지금처럼 프록시는 하나만 만들고, 하나의 프록시에 여러 어드바이저를 적용한다.
정리하면 하나의target
에 여러 AOP가 동시에 적용되어도 스프링의 AOP는target
마다 하나의 프록시만 생성한다!
하지만 프록시 팩토리를 사용해도 문제가 있다.
문제1 - 너무 많은 설정
config 파일을 이용해서 스프링빈을 수동으로 등록해줄때 매우 복잡해진다.
문제2 - 컴포넌트 스캔
컴포넌트스캔을 통해서 자동으로 등록할수없다. 왜냐하면 프록시가 아닌 실제 객체가 스프링빈에 등록되기 때문이다.
해당 포스팅은 아래의 강의를 공부하여 정리한 내용입니다.
김영한님의 프록시 팩토리