스프링 핵심 원리 - 고급편 : 동적 프록시 기술

jkky98·2024년 11월 14일
0

Spring

목록 보기
73/77

되돌아보기

우리는 공통로직을 적용하기 위한 목적을 가지고 다음과 같은 디자인 패턴을 학습했다.

  • 템플릿 메서드 패턴
  • 전략 패턴
  • 템플릿 콜백 패턴 - 전략 패턴에서 람다 사용가능하게끔
  • 프록시 패턴

템플릿 메서드 패턴은 추상 클래스에 공통 로직을 홀드해놓고 변화 로직을 주입하는 형식이었다.

실제 객체(RealSubject)가 공통 로직을 위한 추상 클래스의 상속으로 인해 강결합이 발생한다.

강결합 문제를 해결하고자 전략 패턴으로 하여금 공통 로직이 담기는 Context, 변화 로직이 담기는 인터페이스로 하여금 더욱 유연한 구성을 구상할 수 있었다.

이때 변화 로직의 인터페이스 아래에 설계되는 메서드를 하나로 제한하여 만들어 람다를 활용가능하게끔 템플릿 콜백 패턴을 학습하기도 했다.

하지만 전략 패턴과 템플릿 콜백 패턴 모두 클라이언트 코드에서 공통 로직에 관련한 코드가 존재할 수 밖에 없게 된다.

어쨌든 공통로직이 담긴 객체의 메서드를 호출해야하기 때문이다. 이 과정에서 공통로직이 바뀐다면 공통로직을 호출하는 쪽도 많던 적던 수정이 이루어져야 하기 때문에 변경에서 자유롭지 못한다.

즉 템플릿을 담당하는 코드에 의존성이 생겨버린다.

프록시 패턴은 기존 로직과 새로이 붙일 공통로직 대한 분리가 가능했다.

프록시 패턴은 해당 기능마다 책임분리는 성공했지만 프록시 클래스를 만들어야 한다는 점에서 여전히 중복을 완전히 제거하지 못했다.

즉 100개의 클래스에 공통로직을 붙이기 위해서는 100개의 프록시 클래스를 만들어야했다.

동적 프록시

프록시 클래스를 만들어두고 이를 가져다쓰는 방식이 아니라 자동적으로 target에 걸맞는 프록시 객체를 생성하는 방식이 동적 프록시기술이다.

100개의 클래스가 있고 프록시 생성기 하나만으로 어떤 클래스든 인자로 받아 프록시 객체를 생성할 수 있다면 우리 프로젝트에 클래스가 100개이든 200개이든 부담없이 그에 따른 공통로직 프록시를 만들어낼 수 있을 것이다.

동적 프록시의 필요성을 이해했다면 이제 동적 프록시 기술에 해당하는 JDK ProxyCglib을 알아볼 것이다.

프록시는 항상 1대1로 매핑되는 진짜 객체(target)이 존재한다.

우선 이 target이 인터페이스 기반의 객체인지 일반적인 객체인지 구분하는 것이 중요하다.

그에 따라 인터페이스 기반 프록시구체 클래스 프록시로 구현 방법이 갈리기 때문이다.

JDK Proxy

JDK Proxy는 인터페이스 기반 target 객체에 대한 자바가 지원하는 프록시를 말한다.

JDK proxy는 InvocationHandler를 통해 공통로직을 처리한다.

InvocationHandler : 호출(메서드의 호출)을 통제,관리하는 객체

즉 InvocationHandler를 구성해야한다는 의미는 원래 본 메서드 호출을 어떻게 호출할거냐에 관한 것이다.

공통로직을 포함해서 호출해야하니 추가적인 기능 즉 공통로직과 같이 본 메서드 호출이 일어나는 곳이다.

실제로는 InvocationHandler의 invoke()메서드를 구현해야한다.

@RequiredArgsConstructor
public class LogTraceBasicHandler implements InvocationHandler {

    private final Object target;

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        공통로직 시작
        method.invoke(target, args);
        공통로직 종료
}

공통 로직 부분은 자신이 원하는 추가기능을 잘 적으면 될 것이고,

method.invoke(target, args) 부분으로 하여금 본 메서드의 호출을 수행한다.

method.invoke(target, args)는 자바의 리플렉션(reflection)을 사용한다.

Reflection

자바의 리플렉션(reflection) 기술은 런타임에 클래스의 구조를 조사하고 동적으로 객체를 생성하거나 메서드를 호출할 수 있는 기능을 제공한다. 리플렉션을 통해 특정 클래스의 이름이나 경로만 알고 있어도 해당 클래스의 인스턴스를 생성하고, 메서드나 필드에 접근하여 값을 설정하거나 메서드를 실행할 수 있다. 예를 들어, 클래스의 Method 객체를 사용하여 메서드 이름으로 메서드를 찾아내고, invoke() 메서드를 통해 해당 메서드를 호출할 수 있다. 이를 통해 컴파일 시점에 특정되지 않은 클래스나 메서드를 유연하게 호출할 수 있으며, 주로 의존성 주입, 프레임워크 개발, 테스트 코드에서 활용된다.

(1). target.method()
(2). method.invoke(target, args);

우리는 일반적으로 (1)의 방법으로 메서드를 호출한다. 우리가 리플렉션 기술을 통해 클래스로 부터 클래스 메서드에 해당하는 Method 객체로 하여금 invoke(메서드가 소속된 클래스, 메서드 파라미터)를 호출하면 (1)과 동일하게 클래스가 메서드를 실행하는 기능을 한다.

다시 돌아가서 InvocationHandler 구현체를 보면 invoke()메서드는 파라미터로 프록시 객체, 메서드 객체, 메서드 파라미터 배열을 받는다. 이때 메서드 객체는 당연히 타겟의 메서드 객체이며 프록시 객체는 타겟의 프록시일 것이다.

JDK 프록시를 만드는 방법

(타겟 인터페이스 타입) Proxy.newProxyInstance(타겟 인터페이스 클래스 로더, 배열{타겟 인터페이스.class}, InvocationHandler 구현체)

AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);

위의 코드로 하여금 동적인 프록시, JDK 프록시를 만들 수 있다.

프록시는 당연하게 타겟에 대한 정보를 알아야하므로 타겟의 인터페이스 정보에 해당하는 해당 클래스로더, 리터럴 클래스를 넘겨줘야 한다.

이때 마지막 인자로 InvocationHandler 구현체를 받는다.

만들어질 프록시는 이 핸들러를 가지고 핸들러의 invoke()를 호출시킨다.

Proxy가 생성될 때 타겟 인터페이스 정보를 받기에 자연스레 핸들러의 invoke메서드 인자로
타겟 구현 클래스와 그 클래스의 메서드를 주입해준다.

@Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

개발자가 직접 InvocationHandler 구현체의 invoke() 메서드 호출을 일으키지는 않는다.

실제 예시 코드로 JDK프록시 동작 파악

@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();
        log.info("targetClass={}", target.getClass());
        log.info("targetClass={}", proxy.getClass());
    }
    
// 결과
22:38:37.887 [Test worker] INFO hello.proxy.jdkdynamic.code.TimeInvocationHandler - TimeProxy 실행
22:38:37.888 [Test worker] INFO hello.proxy.jdkdynamic.code.AImpl - A 호출
22:38:37.888 [Test worker] INFO hello.proxy.jdkdynamic.code.TimeInvocationHandler - TimeProxy 종료 resultTime = 0
22:38:37.889 [Test worker] INFO hello.proxy.jdkdynamic.JdkDynamicProxyTest - targetClass=class hello.proxy.jdkdynamic.code.AImpl
22:38:37.889 [Test worker] INFO hello.proxy.jdkdynamic.JdkDynamicProxyTest - targetClass=class jdk.proxy3.$Proxy12

실제로 handler를 Proxy가 가지고있으며 Proxy가 call()로 하여금 동작할 때 handler의 invoke()메서드가 호출된다.

다시 invoke() 메서드의 인자들을 파악해보자.

@Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();

        Object result = method.invoke(target, args);

        long endTime = System.currentTimeMillis();
        log.info("TimeProxy 종료 resultTime = {}", endTime - startTime);
        return result;
    }

invoke()의 세 파라미터는 다음과 같다.

  • Object proxy
  • Method method
  • Object[] args

method와 args는 실제 객체의 메서드를 호출해야하기에 파라미터로 존재하는 것에 어색함이 없으며 실제로, 실제로직을 호출하기 위해 개발자가 직접 이 두 인자를 가져다 쓰고 있다.

하지만 Object proxy는 위 코드에서 활용되지 않고 있다.

InvocationHandler의 invoke 메서드에서 Object proxy 파라미터는 현재 메서드를 호출한 프록시 인스턴스 자체를 가리킨다.

개발자가 이 proxy를 직접 활용하는 경우는 흔치 않지만, 프록시 내에서 재귀 호출이 필요하거나 프록시 자체의 메타데이터에 접근할 때 유용하게 사용될 수 있다.

그러나 대다수의 경우, Java의 리플렉션 API 설계상 해당 파라미터가 인터페이스 규약에 포함되어 있을 뿐 직접 활용되지 않는 것이 일반적이다.

구현하면서 발견한 특징

  • 인터페이스 기반 프록시에만 해당하며 구체 클래스 기반 프록시에서는 JDK 동적 프록시 활용이 불가능하다. 인자로 인터페이스의 클래스 관련 속성들을 받고 있기 때문이다.

Cglib

JDK 프록시가 인터페이스 기반이라면 Cglib은 일반 클래스(콘크리트 클래스) 타겟에 매칭되는 프록시이다.

CglibJDK 프록시InvocationHandler에 해당하는 MethodInterceptor가 존재한다.

public interface MethodInterceptor extends Callback {
     Object intercept(Object obj, Method method, Object[] args, MethodProxy
 proxy) throws Throwable;
 }

MethodInterceptor 또한 invoke() 메서드처럼 intercept() 메서드가 존재한다.

intercept()메서드는 invoke()메서드와 달리 타겟 객체까지 주입받는다.

@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("targetClass={}", target.getClass());
         log.info("proxyClass={}", proxy.getClass());
         proxy.call();
     }

cglib은 Enhancer를 통해 cglib프록시를 생성할 수 있다.

setSuperclass는 타겟의 클래스 리터럴 정보를 삽입해준다.

cglib이 인터페이스 기반에서 동작하지 않는 이유가 여기서 보이는데, cglib은 타겟에 대해 상속을 통한 자식 객체생성으로 프록시 개념을 구현하기 때문이다.

ProxyFactory

JDK ProxyCglib Proxy는 동작 방식이 비슷하다.

두 방식 모두 각각 InvocationHandler, MethodInterceptor에 단일하게 존재하는 오버라이드 메서드인 invoke()intercept()가 존재한다.

두 메서드 모두 공통로직을 수행하고, 파라미터의 Method객체로 하여금 타겟 메서드를 호출한다.

스프링은 이러한 비슷한 동작 방식에 대해 추상화를 제공한다.

그것이 바로 ProxyFactory이다.

어떤 클래스가 들어오든(그 클래스가 인터페이스 기반이든 콘크리트이든)

ProxyFactory는 이를 구분하여 cglib방식으로 프록시를 생성할 지, jdk방식으로 프록시를 생성할 지 결정하는 것이다.

덕분에 개발자는 동적 프록시를 어떻게 구현해야할 지 어떤 기술을 선택해야할 지 고민하지 않아도 된다.

@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();
        proxy.find();

        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue();
        assertThat(AopUtils.isCglibProxy(proxy)).isFalse();
    }

    @Test
    @DisplayName("인터페이스가 없으면 Cglib 프록시 사용")
    void concretProxy() {
        ConcreteService target = new ConcreteService();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvice(new TimeAdvice());
        ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();

        log.info("targetClass={}", target.getClass());
        log.info("proxyClass={}", proxy.getClass());

        proxy.call();

        assertThat(AopUtils.isAopProxy(proxy)).isTrue();
        assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
        assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
    }

위의 코드는 프록시 팩토리 방식을 적용한 것이다. 특이한 점은 Advice라는 생소한 객체가 들어가는 점이다.

Advice

AdviceCGlib의 MethodInterceptor, JDK Proxy의 InvocationHandler에 해당한다고 볼 수 있다.

Advice 는 공통로직을 가지며 진짜 객체를 호출하는 곳이다.

@Slf4j
public class TimeAdvice implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("TimeProxy 실행");
        long startTime = System.currentTimeMillis();

//        Object result = method.invoke(target, args);
        Object result = invocation.proceed();

        long endTime = System.currentTimeMillis();
        log.info("TimeProxy 종료 resultTime = {}", endTime - startTime);
        return result;
    }
}

Advice 구현체의 예제 코드를 살펴보자.

TimeAdvice는 진짜객체의 로직의 시간을 재기 위한 것으로, MethodInterceptor를 주입받는다.

CGlib의 MethodInterceptor와 이름만 같고 다른 패키지이다. TimeAdvice의 인터페이스인 MethodInterceptor는 Spring AOP 모듈 안에 들어있다.

공통로직 사이에서 타겟의 메서드를 호출하는 익숙한 형태이다.

이전의 두 프록시 방식(JDK Proxy, CGlib)과 다를 것이 없지만 인자를 MethodInvocation 객체 하나만 받고 있고 target 클래스 정보들이 보이지 않는다.

실제로 MethodInvocation속에 타겟 클래스 정보들이 모두 존재하며, 이 MethodInvocation는 프록시 팩토리가 주입해준다.

프록시 팩토리가 생성될 때 타겟 클래스를 쥐고 생성하고 타겟 클래스의 정보들을 MethodInvocation에 관리한다.

즉 우리는 invocation.proceed()만 원하는 실행 시점에 붙여주면 된다는 것이다.

Pointcut

프록시 사용시 컨트롤러의 1번 메서드에는 공통로직을 적용하고, 2번 메서드에는 공통로직을 적용하지 않게끔 하기 위해 Advice의 invoke메서드를 다음과 같이 코딩할 수 있다.

// InvocationHandler 구현체
@Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        // 메서드 이름 필터
        String methodName = method.getName();
        if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
            return method.invoke(target, args);
        }
        공통 로직
        ...
        

리플렉션을 활용하여 메서드 이름을 기준으로 필터링 하는 코드를 넣었다.

스프링은 위 방법보다 더 나은 방법을 제공한다.

ProxyFactory 사용시 포인트컷(PointCut)이라는 옵션을 줄 수 있다.

이 옵션으로 하여금 적용할 곳에 대한 필터링이 가능하다.

	@Bean
    public Pointcut pointcut() {
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "order*", "save*");
        return pointcut;
    }

위와 같이 포인트 컷을 만들 수 있다.

이름을 기준으로 분류하기에 NameMatchMethodPointcut()이라는 포인트컷 구현체를 사용한다.

이 포인트 컷을 프록시 팩토리에 잘 적용하면 필터링이 가능해진다.

Advisor

프록시 팩토리는 Advice를 직접 꽂을 수 있지만 보통은 필터링 기준이 되는 포인트컷과 어드바이스를 담은 Advisor를 꽂는다.

Advice는 공통로직을 담고있고 포인트 컷은 이 공통로직이 적용될 타겟 객체(where정보)를 담는다.

이를 함께 감싼 Advisor를 프록시 팩토리에 주입해주는 것이 가장 모범적인 방법이다.

적용

@Configuration
public class ProxyFactoryConfigV2 {

    @Bean
    public Advisor logTraceAdvisor(LogTrace logTrace) {
        LogTraceAdvice logTraceAdvice = new LogTraceAdvice(logTrace);
        DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut(), logTraceAdvice);
        return advisor;
    }

    @Bean
    public Pointcut pointcut() {
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedNames("request*", "order*", "save*");
        return pointcut;
    }

    @Bean
    public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
        OrderControllerV2 target = new OrderControllerV2(orderServiceV2(logTrace));

        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvisor(logTraceAdvisor(logTrace));
        return (OrderControllerV2) proxyFactory.getProxy();
    }

    @Bean
    public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
        OrderServiceV2 target = new OrderServiceV2(orderRepositoryV2(logTrace));

        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvisor(logTraceAdvisor(logTrace));
        return (OrderServiceV2) proxyFactory.getProxy();
    }

    @Bean
    public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
        OrderRepositoryV2 target = new OrderRepositoryV2();

        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.addAdvisor(logTraceAdvisor(logTrace));
        return (OrderRepositoryV2) proxyFactory.getProxy();
    }

}

위와 같이 빈 설정파일을 구성할 수 있다. 포인트 컷과 어드바이저또한 빈으로 두어 변수는 target이므로 각 빈마다 타겟(자신 빈)을 주입한 프록시 팩토리로 하여금 리턴을 프록시 객체로 하는 것이다.

발견된 문제

문제는 컴포넌트 스캔때 프록시 팩토리를 적용할 수 없다는 점이다.

만약 100개의 스프링 빈이 있고 모든 스프링 빈에 이를 적용하기 위해서는 컴포넌트 스캔 방식을 포기하고 모두 Config파일에 직접 작성해서 등록해야한다는 것이다.

이제는 이러한 설정 문제를 해결해줄 빈 후처리기를 공부할 차례이다.

profile
자바집사의 거북이 수련법

0개의 댓글