동적 프록시 기술 (JDK 동적 프록시, CGLIB)

땡글이·2023년 1월 23일
0

스프링 AOP

목록 보기
1/5

프록시


프록시는 "대리"의 의미로, 인터넷과 관련해서 사용되는 경우 빠른 액세스나 안전한 통신 등을 확보하기 위한 중계 서버를 "프록시 서버"라고 일컫는다. 클라이언트와 서버 사이에 위치해 클라이언트는 프록시 서버와 통신함으로써 origin 서버의 부하를 줄여주거나 성능의 향상을 이뤄낼 수 있다. 네트워크 관점에서의 프록시 서버 종류로는 포워드 프록시, 리버스 프록시가 있다. 이번 글에서는 스프링에서의 프록시에 대해 다루기에 네트워크 관점에서의 프록시는 다루지 않는다.

이 글에서 스프링이 프록시 기능을 어떻게 제공하고 있는지, 해당 기능들을 어떻게 사용하는지, 해당 기능을 사용함으로써 어떤 이익을 취할 수 있는지 확인해볼 것이다.

프록시 객체

프로그래밍 관점에서 프록시는 주요 기능과 부가 기능을 나눠 개발하기 위해 고안된 개념이다. 여기서 주요 기능은 "주문", "배송" 등 서비스에서 주요한 기능을 담당하는 로직을 의미하고, 부가 기능은 "로그", "트랜잭션 관리", "실행시간 측정", "모니터링" 등 서비스의 핵심 기능과는 관계가 없지만, 애플리케이션 전반적으로 필요한 기능을 의미한다.

주요 기능은 "변하는 것"에 속한다고 볼 수 있다. 각각의 기능별로 다른 로직의 코드가 적혀있기 때문이다. 주요 기능과 달리 부가 기능은 "변하지 않는 것"에 속한다. 부가 기능은 주요 기능과 항상 같이 사용되지만, 애플리케이션 전반에서 사용되는 공통된 로직인 경우가 많다.

그래서 스프링에서도 프록시 개념을 이용해 주요 기능과 부가 기능을 나눠 개발되고 발전되어 왔다. 직접 스프링에서 프록시를 만들어보며 프록시에 대해 이해해보자.

public interface OrderServiceV1 {
    void orderItem(String itemId);
}
@RequiredArgsConstructor
public class OrderServiceInterfaceProxy implements OrderServiceV1  {

    private final OrderServiceV1 target;
    private final LogTrace logTrace;

    @Override
    public void orderItem(String itemId) {
        TraceStatus status = null;

        try {
            status = logTrace.begin("OrderService.orderItem()");

            // target 호출
            target.orderItem(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

위의 코드에서 실제 객체, 즉 targetOrderServiceV1 인터페이스(실제로는 OrderServiceV1 인터페이스를 구현한 객체가 주입된다)이고, 프록시 객체는 OrderServiceInterfaceProxy 클래스이다. 프록시 객체에서는 단순히 의존관계를 주입받은 실제 객체를 호출하는 것이 아닌, 실제 객체에 없는 부가 기능(로그 추적 기능) 을 템플릿을 만들어 실행하고 있다. 클라이언트는 프록시 객체와 통신하게 된다. 프록시 객체에서 실제 객체를 참조하고 있기 때문에 문제는 없다. 클라이언트가 프록시 객체와 통신하게 되면 부가 기능은 프록시 객체가 주요 기능은 실제 객체가 담당할 수 있게 된다.

위처럼 프록시를 사용하는 디자인패턴에는 "프록시 패턴", "데코레이터 패턴" 등이 있다.

  • 디자인 패턴은 비슷한 구조더라도, 패턴의 의도에 따라 나뉘게 된다.
    • 프록시 패턴 : 접근 제어가 목적
      • 접근 제어는 origin 서버로의 접근을 제어한다는 것을 의미한다. 예시로는 "캐싱", "인증/인가" 등이 있다.
    • 데코레이터 패턴 : 새로운 기능 추가가 목적
      • 새로운 기능 추가의 예시는 로그 기능, 실행 시간 추적 등이 있다.
  • 비슷한 개념으로 참고할만한 디자인패턴으로는 "템플릿 메서드 패턴", "전략 패턴", "템플릿 콜백 패턴"이 있다.

프록시를 이용해서 주요 기능을 담당하는 코드와 부가 기능을 담당하는 코드를 분리시킬 수 있었다. 하지만, 대상 클래스마다 프록시를 만들어줘야한다는 문제점이 있다. 만약 대상 클래스가 100개라면, 프록시 클래스도 100개를 만들어줘야 한다. 이런 문제를 해결하기 위해 동적 프록시 개념이 도입된 것이다.

동적 프록시

자바가 기본적으로 제공하는 JDK 동적 프록시 기술이나 CGLIB와 같은 프록시 생성 오픈소스 기술을 활용해 프록시 객체를 동적으로 생성할 수 있다.

리플렉션 (Reflection)

동적 프록시 기술을 이해하기 전, 리플렉션에 대해 알아보자. 리플렉션 기술을 사용하면, 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.

    @Test
    void reflection0() {
        Hello target = new Hello();

        //공통로직1 시작
        log.info("start");
        String result1= target.callA();
        log.info("result={}", result1);

        //공통로직2 시작
        log.info("start");
        String result2 = target.callB();
        log.info("result={}", result2);
    }

    @Test
    void reflection1() throws Exception {
        // 클래스 정보
        Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();

        // callA 메서드 정보
        Method methodCallA = classHello.getMethod("callA");
        Object result1 = methodCallA.invoke(target);
        log.info("result={}", result1);

        // callB 메서드 정보
        Method methodCallB = classHello.getMethod("callB");
        Object result2 = methodCallB.invoke(target);
        log.info("result={}", result2);

    }

reflection0() 테스트 코드는 reflection 을 사용하지 않은 코드이고, reflection1() 테스트 코드는 reflection 을 사용한 코드이다.
reflection 기술을 사용함으로써 target을 직접 호출(target.callA()) 하지 않고, 메서드의 메타정보를 동적으로 획득해 호출(Method.invoke())하는 공통로직을 구현할 수 있게 되었다.
아래의 reflection2() 테스트 코드처럼 말이다.

    @Test
    void reflection2() throws Exception {
        // 클래스 정보
        Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");

        Hello target = new Hello();

        // callA 메서드 정보

        Method methodCallA = classHello.getMethod("callA");
        dynamicCall(methodCallA, target);

        // callB 메서드 정보
        Method methodCallB = classHello.getMethod("callB");
        dynamicCall(methodCallB, target);

    }

    private void dynamicCall(Method method, Object target) throws Exception {
        log.info("start");
        // String result = target.callA();
        Object result = method.invoke(target);
        log.info("result={}", result);
    }

JDK 동적 프록시

동적 프록시 기술은 개발자가 직접 프록시 클래스를 만들지 않아도, 프록시 객체를 동적으로 즉, 런타임에 생성해준다. 그리고 동적 프록시에 원하는 실행 로직을 지정할 수 있다.

JDK 동적 프록시 기술 은 자바에서 제공하는 기능 중 하나로, 동적 프록시 기술 중 하나이다.

JDK 동적 프록시 기술은 인터페이스를 기반으로 프록시를 동적으로 만들어준다. 따라서 인터페이스가 필수이다.

JDK 동적 프록시 사용 방법

JDK 동적 프록시 기술 을 사용하려면, 프록시에 적용할 로직을 InvocationHandler 인터페이스를 구현해서 작성한다.

public interface InvocationHandler {

    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}
  • Object proxy : 프록시 자신
  • Method method : 호출한 메서드
  • Object[] args : 메서드를 호출할 때 전달한 인수
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {

    private final Object target;

    public TimeInvocationHandler(Object target) {
        this.target = target;
    }

    @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();
        long resultTime = endTime - startTime;

        log.info("TimeProxy 종료 resultTime={}", resultTime);

        return result;
    }
}

위의 TimeInvocationHandler 클래스처럼 InvocationHandler 인터페이스를 구현해서 JDK 동적 프록시에 적용할 공통 로직을 개발할 수 있다. 위에서 target은 동적 프록시가 실제로 호출할 대상을 의미한다.

method.invoke(target, args)리플렉션(Reflection)을 사용해서 target 인스턴스의 메서드를 실행한다. args는 메서드 호출 시 넘겨줄 인수이다.

이제 JDK 동적 프록시에 적용할 공통로직을 개발했으니 사용방법에 대해 알아보자.

import java.lang.reflect.Proxy;

...

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

    }

    @Test
    void dynamicB() {
        BInterface target = new BImpl();
        TimeInvocationHandler handler = new TimeInvocationHandler(target);

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

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

    }
TimeProxy 실행
A 호출
TimeProxy 종료 resultTime=1
targetClass=class hello.proxy.jdkdynamic.code.AImpl
proxyClass=class com.sun.proxy.$Proxy1

동적 프록시에 적용할 핸들러 로직을 만들어, 프록시 객체를 생성할 때(Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[] {AInterface.class}, handler)) 인자로 넘긴다.

실행 결과를 보면, 실제 객체의 클래스와 프록시 객체의 클래스 정보가 다르다. 즉, 프록시 객체는 JDK 동적 프록시 기술을 통해 생성된 것이다.


위의 그림을 보는 것과 같이, 런타임 시점에서 클라이언트(테스트 코드)는 프록시 객체를 참조하고 프록시 객체로 함수를 호출한다. 프록시 객체에 있는 핸들러가 내부 로직을 수행하고, method.invoke(target, args) 를 호출해서 실제 객체를 호출한다.

이렇게 JDK 동적 프록시 기술을 사용하면, 프록시 객체를 대상 클래스의 수와 동일하게 만들어줘야 하는 문제를 해결할 수 있다. 또한, 부가 기능 로직 또한 하나의 클래스에 모아서 단일 책임 원칙(SRP)를 지킬 수 있게 됐다.


위 그림에서 점선은 개발자가 직접 만든 클래스가 아니다. JDK 동적 프록시 기술을 이용해 자바에서 자동으로 만들어준 프록시 클래스이다.

JDK 동적 프록시 - 한계

앞서 언급했듯이, JDK 동적 프록시인터페이스가 필수이다. 그렇다면 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까? 이것은 CGLIB 라는 바이트코드를 조작하는 특별한 라이브러리를 사용하면 가능하다.


CGLIB

CGLIB 는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다. JDK 동적 프록시와는 달리, 인터페이스 뿐만이 아니라 클래스에도 프록시 적용이 가능하다.

CGLIB스프링 DI 컨테이너에서 빈들을 싱글톤 객체로 유지하기 위해 사용되는 기술이기도 하다. 스프링 DI 컨테이너에서도 빈을 관리할 때 프록시 개념을 이용한다.

CGLIB 사용방법

package org.springframework.cglib.proxy;

import java.lang.reflect.Method;

public interface MethodInterceptor extends Callback {
    Object intercept(Object var1, Method var2, Object[] var3, MethodProxy var4) throws Throwable;
}
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {

    private final Object target;

    public TimeMethodInterceptor(Object target) {
        this.target = target;
    }

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

        // Object result = method.invoke(target, args);
        // 아래처럼 작성하는 것이 공식문서에서 권장하는 방식이다
        Object result = methodProxy.invoke(target, args);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;

        log.info("TimeProxy 종료 resultTime={}", resultTime);

        return result;
    }
}

JDK 동적 프록시에서는 실행 로직을 위해 InvocationHandler 인터페이스를 제공했듯이 CGLIB에선 MethodInterceptor 인터페이스를 제공한다.
TimeMethodInterceptor 클래스는 MethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다. JDK 동적 프록시와 거의 같은 코드 흐름이다.

실제 대상 객체를 호출하는 코드를 작성할 때, Method method 로 사용해도 되지만, CGLIB 에서 성능 관점에서 MethodProxy methodProxy 를 사용하는 것을 권장한다.

    @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를 사용하는 코드는 다음과 같다. CGLIB에서는 Enhancer 를 사용해서 프록시를 생성한다.

  • enhancer.setSuperclass(ConcreteService.class) : CGLIB에서 구체 클래스를 상속 받아서 프록시를 생성할 수 있도록 상위 클래스를 지정한다.
  • enhancer.setCallback(new TimeMethodInterceptor(target)) : 프록시에 적용할 실행 로직을 할당한다.

클래스 의존 관계와 런타임 객체 의존 관계를 그림으로 정리하면 다음과 같다.

CGLIB 한계

클래스 기반 프록시는 상속을 이용하기에 몇 가지 제약이 있다.

  • 부모 클래스에 기본 생성자가 있어야 한다.
  • 클래스나 메서드에 final 키워드가 붙으면 안된다.

결론

인터페이스가 있을 때에는 JDK 동적 프록시를, 그렇지 않은 경우에는 CGLIB를 이용해 동적 프록시를 적용하면 될 것 같다. 하지만 두 개를 같이 사용하기 위해서, InvocationHandler (JDK 동적 프록시) 와 MethodInterceptor (CGLIB) 를 중복으로 만들어서 관리해야 하는 문제점이 있다. 또한 특정 조건에 맞을 때에만 프록시를 적용하는 필터 기능도 공통으로 제공하는 기능이 없다.

이런 문제점들을 해결하는 것에는 프록시 팩토리가 있다. 프록시 팩토리에 대한 내용은 다음 포스팅을 참고하자.

Reference

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard
https://ko.wikipedia.org/wiki/%ED%94%84%EB%A1%9D%EC%8B%9C_%EC%84%9C%EB%B2%84

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

0개의 댓글