동적 프록시가 아닌 일반 프록시 사용시 단점은 대상 클래스 수만큼 프록시 클래스를 만들어야 한다는 점이다.
(상황에 따라 모양이 매우 비슷한 코드가 중복될 수도 있다.)
자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프로시 객체를 동적으로 만들어낼 수 있다. 프록시를 적용할 코드를 하나만 만들어두고 동적 프록시 기술을 사용해서 프록시 객체를 찍어내면 된다.
동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다.
리플렉션 기술을 사용하면 클래스나 메서드의 메타 정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.
다음은 Hello 클래스의 callA()메서드를 메서드명을 통해 실행시키는 방식이다.
@Sl4j
public static class Hello {
public String callA() {
log.info("callA");
return "A";
}
}
@Test
void reflection() throws Exception {
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello"); //클래스 메타정보 획득
Hello target = new Hello();
Method methodA = classHello.getMethod("callA"); //메서드 메타정보 획득
dynamicCall(methodA, target);
}
private void dynamicCall(Method method, Object target) throws Exception {
log.info("start");
Object result = method.invoke(target); //실행
log.info("result={}", result);
}
리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다.
하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.
따라서 리플렉션은 일반적으로 사용하면 안되고 프레임워크 개발이나 매우 일반적인 공통처리가 필요할 때 부분적으로 주의해서 사용해야 한다.
앞서 설명했듯이 동적 프록시 기술을 사용하면 개발자가 직접 프록시 클래스를 만들지 않아도 된다.
JDK 동적 프록시는 인터페이스 기반으로 프록시 객체를 동적으로 런타임에 대신 만들어주고 원하는 실행 로직을 지정할 수 있다.
이는 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 된다.
아래는 A구현체와 B구현체의 프록시를 동적으로 생성해주는 예제이다.
JDK 동적 프록시의 경우 인터페이스가 필수적이기 때문에 인터페이스를 따로 만들어 주었다.
public interface AInterface { String call(); }
public interface BInterface { String call(); }
@Sl4j
public class AImpl implements AInterface {
@Override
public String call() {
log.info("A 호출");
return "A";
}
}
@Sl4j
public class BImpl implements BInterface { //A와 동일한 형태 }
TimeInvocationHandler
는 InvocationHandler
인터페이스를 구현한다.
이렇게 JDK 동적 프록시에 적용할 공통 로직을 개발할 수 있다.
@Sl4j
public class TimeInvacationHandler implements InvocationHandler {
private final Object target; // 적용할 대상 클래스
public TimeInvocationHandler (Object target) { this.target = target; }
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Exception {
//적용할 공통 로직 실행
//대상 클래스 로직 실행
Object result = method.invoke(target, args);
return result;
}
}
아래는 위 설정으로 동적 프록시를 생성하여 각 구현체의 메서드를 실행해보는 예제이다.
@Test
void dynamicA() {
AInterface target = new AImpl();
//핸들러 생성
TimeInvocationhandler handler = new TimeInvocationHandler(target);
//프록시 생성
AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(),
new class[] {AInterface.class}, handler);
proxy.call();
}
void dynamicB() {
//위와 같은 로직
}
- 클라이언트는 동적 프록시의 call()을 실행한다.
- JDK 동적 프록시는 InvocationHandler.invoke()를 호출한다.
- InvacationHandler가 내부 로직을 수행하고, mehod.invokde(target, args)를 호출해서 실제 객체 (AImpl)을 호출한다.
- AImpl 인스턴스의 call()이 실행된다.
CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다.
CGLIB을 사용하면 인터페이스가 없어도(<-> JDK 동적 프록시) 구체 클래스만 가지고 동적 프록시를 만들 수 있다.
CGLIB는 원래 외부 라이브러리인데, 스프링 프레임워크가 내부 소스에 포함했다. 따라서 따로 외부 라이브러리를 추가하지 않아도 된다.
아래에는 앞선 예제와 달리 구체 클래스를 대상으로 프록시를 생성한다.
@Sl4j
public class ConcreteService {
public void call() {
log.info("ConcreteService 호출");
}
}
JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler
를 제공했듯이, CGLIB에서는 MethodInterceptor
를 제공한다.
TimeMethodInterceptor
는 MethodInterceptor
를 구현한다.
특이한 점은 실제 메서드를 호출할때 method.invoke()
가 아닌 proxy.invoke()
를 쓰는데, 성능상 이를 권장한다.
@Sl4j
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
public TimeMethodInterceptor (Object target) { this.target = target; }
//obj : CGLIB가 적용된 객체, method : 호출 메서드, args : 인자, proxy : 메서드 호출에 사용
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
//적용할 공통 로직 실행
//대상 클래스 로직 실행
Object result = proxy.invoke(target, args);
return result;
}
}
아래는 위 설정으로 동적 프록시를 생성하여 구현체의 메서드를 실행하는 예제이다.
@Test
void cglib() {
ConcreteService target = new ConcreteService();
Enhancer enhancer = new Enhancer(); //CGLIB는 enhancer로 프록시 생성
enhancer.setSuperClass(ConcreteService.class); //어떤 구체 클래스를 상속받을 것인지.
enhancer.setCallback(new TimeMethodInterceptor(target)); //실행 로직 할당
ConcreteService proxy = (ConcreteService)enhancer.create(); //프록시 생성
proxy.call();
}
클래스 기반 프록시는 구현이 아닌 상속을 하기 때문에 다음과 같은 제약이 있다.
앞서 JDK 동적 프록시와 CGLIB는 상황에 따라 쓰임새가 다르다. (인터페이스, 구현클래스)
그럼 범용성 있게 동적으로 선택해서 적용할 수 있는 방법은 없을까?
스프링은 이러한 문제를 해결하기 위해 프록시 팩토리
라는 기능을 제공한다.
프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를, 구현 클래스만 있다면 CGLIB를 사용한다.
그러면 JDK 동적 프록시의 InvocationHandler
나 CGLIB의 MethodInterceptor
는 둘 다 생성해야 할까?
스프링은 이런 문제 해결을 위해 Advice
라는 개념을 도입했다.
개발자는 추가 로직을 위해 단순히 Advice
한 개만 생성하면 된다.
프록시 팩토리를 사용하면 Advice를 호출하는 전용 Invocationhandler, MethodInterceptor를 내부에서 사용한다.
또한 조건에 따라 프록시 로직을 적용하고 적용하지 않게 하기위해서
스프링은 Pointcut
이라는 개념을 도입해서 일관성 있게 문제를 해결한다.
//스프링이 제공하는 어드바이스 전용 인터페이스
public interface MethodInterceptor extends Interceptor {
Object invoke(MethodInvocation) invocation) throws Throwable;
}
//사용자 어드바이스
@Sl4j
public class TimeAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
//사용자 로직 수행
//타겟 로직 수행
Object result = invocation.proceed();
return result;
}
}
위 예제는 스프링이 제공하는 인터페이스를 상속한 어드바이스를 만드는 예제이다.
기존 JDK 동적 프록시, CGLIB를 사용할 때와는 다르게 target 클래스의 정보가 없는데 이는 프록시 팩토리로 생성하는 단계에서 이미 target 정보를 파라미터로 전달받기 때문이다.
@Test
void interfaceProxy() {
ServiceInterface target = new ServiceImpl(); //타겟 클래스 생성
ProxyFactory proxyFactory = new ProxyFactory(target); //프록시 팩토리 생성
proxyFactory.addAdvice(new TimeAdvice()); //사용자 어드바이스 설정
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy(); //프록시 생성
proxy.save(); //함수 실행
}
위 단어들은 스프링 AOP에서는 빠질 수 없는 용어들이다. 각 용어의 뜻은 다음과 같다.
이렇게 구분하게 된 것은 역할과 책임을 명확하게 분리하기 위함이다.
@Test
void advisorTest3() {
ServiceImpl 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
: 메서드 이름을 기반으로 매칭한다. 내부에서는 PatternMatchUtils 사용JdkRegexMethodPointcut
: JDK 정규 표현식 기반 포인트컷을 매칭한다.TruePointcut
: 항상 참을 반환AnnotationMatchingPointcut
: 어노테이션으로 매칭한다.AspectJExpressionPointcur
: aspectJ 표현식으로 매칭한다. (실무에서 가장 많이 사용!)