[Spring] 동적 프록시 기술

Donghoon Jeong·2024년 6월 1일
0

Spring

목록 보기
7/15
post-thumbnail

저번 포스팅에서 프록시 패턴과 데코레이터 패턴을 사용하여 프록시를 코드로 구현하는 방법을 알아보았습니다. 하지만 해당 디자인 패턴을 사용할 경우 프록시 클래스를 적용 대상의 숫자만큼 만들어야 한다는 단점이 존재합니다.

이러한 문제를 해결하는 기술이 동적 프록시입니다. 동적 프록시는 말 그대로 런타임 시점에 프록시를 자동으로 만들어서 적용해 주는 기술을 의미합니다.

자바에서 대표적인 동적 프록시 기술은 JDK 동적 프록시CGLIB가 존재합니다. 두 기술 모두 동적 프록시를 만들어주지만, 차이점은 다음과 같습니다.

  • JDK 동적 프록시

    • 인터페이스 기반으로 프록시를 생성합니다.

    • 자바에서는 리플렉션을 사용하여 프록시를 생성합니다.

  • CGLIB

    • 클래스 기반(인터페이스도 가능)으로 프록시를 생성합니다.

    • ASM 프레임워크를 활용하여 바이트코드를 조작하여 프록시를 생성합니다.

JDK 동적 프록시와 CGLIB의 예제 코드를 통해 동작 방식에 대해서 살펴보겠습니다.


JDK 동적 프록시

먼저 자바 언어가 기본으로 제공하는 JDK 동적 프록시를 알아보겠습니다. JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어줍니다. 따라서 인터페이스가 필수입니다.

JDK 동적 프록시를 이해하기 위해 아주 단순한 예제 코드를 만들어보자.
간단히 A 클래스와 B 클래스를 만드는데, JDK 동적 프록시는 인터페이스가 필수이기 때문에 인터페이스와 구현체로 구분했습니다.

public interface AInterface {
    String call();
}
@Slf4j
public class AImpl implements AInterface {

    @Override
    public String call() {
        log.info("A 호출");
        return "a";
    }
}
public interface BInterface {
    String call();
}
@Slf4j
public class BImpl implements BInterface {

    @Override
    public String call() {
        log.info("B 호출");
        return "b";
    }
}
public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

JDK 동적 프록시에 적용할 로직은 InvocationHandler 인터페이스를 구현해서 작성하면 됩니다.

제공되는 파라미터는 다음과 같습니다.

  • 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 동적 프록시에 적용할 공통 로직을 개발할 수 있습니다.

  • Object target

    동적 프록시가 호출할 대상

  • method.invoke(target, args)

    리플렉션을 사용해서 target 인스턴스의 메서드를 실행합니다. args는 메서드 호출 시 넘겨줄 인수입니다.

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());
}

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

	// 동적 프록시 생성
	BInterface proxy = (BInterface) Proxy.newProxyInstance(BInterface.class.getClassLoader(), new Class[]{BInterface.class}, handler);

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

// dynamicA 실행 결과
TimeInvocationHandler - TimeProxy 실행
AImpl - A 호출
TimeInvocationHandler - TimeProxy 종료 resultTime=1
JdkDynamicProxyTest - targetClass=class hello.proxy.jdkdynamic.code.AImpl
JdkDynamicProxyTest - proxyClass=class com.sun.proxy.$Proxy12

// dynamicB 실행 결과
TimeInvocationHandler - TimeProxy 실행
BImpl - B 호출
TimeInvocationHandler - TimeProxy 종료 resultTime=0
JdkDynamicProxyTest - targetClass=class hello.proxy.jdkdynamic.code.BImpl
JdkDynamicProxyTest - proxyClass=class com.sun.proxy.$Proxy13

출력 결과를 보면 프록시가 정상 수행된 것을 확인할 수 있습니다.

  • 생성된 JDK 동적 프록시

    proxyClass=class com.sun.proxy.$Proxy12 이 부분이 동적으로 생성된 프록시 클래스 정보입니다.

    이것은 우리가 만든 클래스가 아니라 JDK 동적 프록시가 이름 그대로 동적으로 만들어준 프록시입니다. 이 프록시는 위에서 직접 구현한 TimeInvocationHandler 로직을 실행합니다.

실행 과정을 그림으로 확인해보겠습니다.

  1. 클라이언트는 JDK 동적 프록시의 call() 메서드를 실행합니다.

  2. JDK 동적 프록시는 InvocationHandler.invoke()를 호출합니다. TimeInvocationHandler가 구현체로 있으므로 TimeInvocationHandler.invoke()가 호출됩니다.

  3. TimeInvocationHandler가 내부 로직을 수행하고, method.invoke(target, args)를 호출해서 target인 실제 객체(AImpl)를 호출합니다.

  4. AImpl 인스턴스의 call() 메서드가 실행됩니다.

  5. AImpl 인스턴스의 call() 메서드의 실행이 끝나면 TimeInvocationHandler로 응답이 돌아옵니다. 시간 로그를 출력하고 결과를 반환합니다.

AImpl BImpl 각각 프록시를 만들지 않았습니다. 프록시는 JDK 동적 프록시를 사용해서 동적으로 만들고 TimeInvocationHandler는 공통으로 사용했습니다.

JDK 동적 프록시 기술 덕분에 적용 대상만큼 프록시 객체를 만들지 않아도 됩니다. 그리고 같은 부가 기능 로직을 한 번만 개발해서 공통으로 적용할 수 있습니다. 만약 적용 대상이 100개여도 동적 프록시를 통해서 생성하고, 각각 필요한 InvocationHandler만 만들어서 넣어주면 됩니다.

결과적으로 프록시 클래스를 수없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었습니다.

JDK 동적 프록시 없이 직접 프록시를 만들어서 사용할 때와 JDK 동적 프록시를 사용할 때의 차이를 그림으로 비교해 보겠습니다.

  • 클래스 의존 관계 - JDK 동적 프록시 도입 전

  • 클래스 의존 관계 - JDK 동적 프록시 도입 후

    • 점선은 개발자가 직접 만드는 클래스가 아닙니다.

    • 동적으로 생성된 프록시는 내부에서 invocationHandler를 참조해서 호출합니다.


  • 런타임 의존 관계 - JDK 동적 프록시 도입 전

  • 런타임 의존 관계 - JDK 동적 프록시 도입 후


CGLIB

CGLIB는 바이트코드를 조작하여 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리입니다.

CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있습니다. CGLIB는 원래 외부 라이브러리지만, 스프링 프레임워크에 포함되어 있어 스프링을 사용하면 별도의 외부 라이브러리를 추가하지 않아도 됩니다.

예제 코드를 보면서 CGLIB 동작 방식에 대해서 살펴보겠습니다.

@Slf4j
public class ConcreteService {
    public void call() {
        log.info("ConcreteService 호출");
    }
}

실제 로직 부분을 담당하는 구체 클래스를 생성하겠습니다.

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

JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler를 제공했듯이, CGLIB는 MethodInterceptor를 제공한다.

  • Object obj

    CGLIB가 적용된 객체

  • Method method

    호출된 메서드

  • Object[] args

    메서드를 호출하면서 전달된 인수

  • MethodProxy proxy

    메서드 호출에 사용

@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 = methodProxy.invoke(target, args);

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

TimeMethodInterceptor 클래스는 MethodInterceptor 인터페이스를 구현하여 CGLIB 프록시의 실행 로직을 정의합니다. JDK 동적 프록시 예제와 거의 같은 코드입니다.

  • Object target

    프록시가 호출할 실제 대상

  • methodProxy.invoke(target, args)

    실제 대상을 동적으로 호출합니다. 참고로 method를 사용할 수도 있지만, CGLIB는 성능상 MethodProxy를 사용하는 것을 권장합니다.

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();
}

// 실행 결과
CglibTest - targetClass=class hello.proxy.common.service.ConcreteService 
CglibTest - proxyClass=class hello.proxy.common.service.ConcreteService$ $EnhancerByCGLIB$$25d6b0e3
TimeMethodInterceptor - TimeProxy 실행
ConcreteService - ConcreteService 호출 TimeMethodInterceptor - TimeProxy 종료 resultTime=9

ConcreteService 클래스는 인터페이스가 없는 구체 클래스입니다. 여기에 CGLIB를 사용해서 프록시를 생성해보겠습니다.

  • Enhancer

    CGLIB의 경우 Enhancer를 사용해서 프록시를 생성한다.

  • enhancer.setSuperclass(ConcreteService.class)

    CGLIB는 구체 클래스를 상속 받아서 프록시를 생성할 수 있습니다. 어떤 구체 클래스를 상속 받을지 지정합니다.

  • enhancer.setCallback(new TimeMethodInterceptor(target))

    프록시에 적용할 실행 로직을 할당합니다.

  • enhancer.create()

    앞서 설정한 enhancer.setSuperclass(ConcreteService.class) 에서 지정한 클래스를 상속 받아서 프록시를 생성합니다.

JDK 동적 프록시의 동작 방식과 유사한 것을 확인할 수 있습니다.
위 코드의 실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있습니다.

CGLIB의 제약

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

  • 부모 클래스의 생성자를 체크해야 합니다. CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요합니다.

  • 클래스에 final 키워드가 붙으면 상속이 불가능합니다. CGLIB에서는 예외가 발생합니다.

  • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩할 수 없습니다. CGLIB에서는 프록시 로직이 동작하지 않습니다.


프록시 팩토리

위 동적 프록시 방식의 차이점을 살펴보면 JDK 동적 프록시인터페이스를 구현(implement)해서 프록시를 만들고 CGLIB구체 클래스를 상속(extends)해서 프록시를 만듭니다.

스프링은 여러 기술들을 통합하여 일관성 있고 편리하게 사용할 수 있는 추상화된 기술을 제공합니다. 그 중 하나가 바로 동적 프록시를 편리하게 생성할 수 있는 프록시 팩토리(ProxyFactory)입니다.

예전에는 상황에 따라 JDK 동적 프록시나 CGLIB를 선택해야 했지만, 이제는 프록시 팩토리 하나로 이 모든 작업을 처리할 수 있습니다.

프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를, 구체 클래스만 있다면 CGLIB를 사용합니다. 이 설정은 변경할 수도 있습니다.

두 기술을 함께 사용할 때 부가 기능을 적용하기 위해 JDK 동적 프록시의 InvocationHandler와 CGLIB의 MethodInterceptor를 각각 중복으로 만들어야 할까?

스프링은 이 문제를 해결하기 위해 부가 기능을 적용할 때 Advice라는 개념을 도입했습니다.

이제 개발자는 InvocationHandlerMethodInterceptor를 신경 쓰지 않고, Advice만 만들면 됩니다.

결과적으로 InvocationHandlerMethodInterceptorAdvice를 호출하게 됩니다. 프록시 팩토리를 사용하면 Advice를 호출하는 전용 InvocationHandlerMethodInterceptor를 내부에서 사용합니다.

Advice 만들기

Advice는 프록시에 적용하는 부가 기능 로직입니다. 이는 JDK 동적 프록시의 InvocationHandler와 CGLIB의 MethodInterceptor의 개념과 유사하며, 이를 추상화한 것입니다. 프록시 팩토리를 사용하면 이들 대신 Advice를 사용하면 됩니다.

MethodInterceptor 인터페이스

public interface MethodInterceptor extends Interceptor {
    Object invoke(MethodInvocation invocation) throws Throwable;
}
  • MethodInvocation invocation

    메서드 호출 방법, 현재 프록시 객체 인스턴스, args, 메서드 정보 등이 포함되어 있습니다.

CGLIB에서 사용한 인터페이스는 org.springframework.cglib.proxy.MethodInterceptor 위치에 존재하는 인터페이스지만, 프록시 팩토리에서는 org.aopalliance.intercept.MethodInterceptor에 있는 인터페이스를 사용합니다.

TimeAdvice 구현

@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 클래스의 정보는 MethodInvocation invocation안에 모두 포함되어 있습니다.

프록시 적용 확인

AopUtils.isAopProxy(proxy);        // 프록시 팩토리를 통해 생성된 프록시 여부 확인
AopUtils.isJdkDynamicProxy(proxy); // JDK 동적 프록시 여부 확인
AopUtils.isCglibProxy(proxy);      // CGLIB 동적 프록시 여부 확인

테스트 코드에서 다음과 같은 테스트 명령어를 사용하여 프록시 팩토리, JDK 동적 프록시, 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();

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

// 실행 결과
ProxyFactoryTest - targetClass=class hello.proxy.common.service.ServiceImpl
ProxyFactoryTest - proxyClass=class com.sun.proxy.$Proxy13
TimeAdvice - TimeProxy 실행
ServiceImpl - save 호출
TimeAdvice - TimeProxy 종료 resultTime=1ms
  • new ProxyFactory(target)

    프록시 팩토리를 생성할 때, 프록시의 호출 대상을 함께 넘겨줍니다.

  • proxyFactory.addAdvice(new TimeAdvice())

    프록시에서 사용할 부가 기능 로직을 설정합니다.

  • proxyFactory.getProxy()

    프록시 객체를 생성하고 그 결과를 받습니다.

구체 클래스만 있을 때 CGLIB 사용

java코드 복사
@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("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();
}

// 실행 결과
ProxyFactoryTest - targetClass=class hello.proxy.common.service.ConcreteService
ProxyFactoryTest - proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerBySpringCGLIB$$103821ba
TimeAdvice - TimeProxy 실행
ConcreteService - ConcreteService 호출
TimeAdvice - TimeProxy 종료 resultTime=1ms

인터페이스가 있어도 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();
}

// 실행 결과
ProxyFactoryTest - targetClass=class hello.proxy.common.service.ServiceImpl
ProxyFactoryTest - proxyClass=class hello.proxy.common.service.ServiceImpl$$EnhancerBySpringCGLIB$$2bbf51ab
TimeAdvice - TimeProxy 실행
ServiceImpl - save 호출
TimeAdvice - TimeProxy 종료 resultTime=1ms

프록시 팩토리는 proxyTargetClass 옵션을 제공하는데, 이 옵션에 true 값을 넣으면 인터페이스가 있어도 강제로 CGLIB를 사용하여 클래스 기반의 프록시를 생성합니다.

프록시 팩토리의 기술 선택 방법

  • 대상에 인터페이스가 있으면

    JDK 동적 프록시, 인터페이스 기반 프록시

  • 대상에 인터페이스가 없으면

    CGLIB, 구체 클래스 기반 프록시

  • proxyTargetClass=true

    인터페이스 여부와 상관없이 CGLIB, 구체 클래스 기반 프록시

스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정하여 사용합니다. 따라서 인터페이스가 있어도 항상 CGLIB를 사용하여 구체 클래스를 기반으로 프록시를 생성합니다.

프록시 팩토리를 사용하면 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고, 매우 편리하게 동적 프록시를 생성할 수 있습니다. 또한, 프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게 Advice 하나로 편리하게 사용할 수 있습니다.

이는 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우 InvocationHandlerAdvice를 호출하도록 하고, CGLIB인 경우 MethodInterceptorAdvice를 호출하도록 개발되었기 때문입니다.

한계점

프록시 팩토리와 어드바이저 같은 개념 덕분에 프록시도 깔끔하게 적용하고 포인트 컷으로 어디에 부가 기능을 적용할지도 명확하게 정의할 수 있습니다. 원본 코드를 전혀 손대지 않고 프록시를 통해 부가 기능도 적용할 수 있었다. 하지만 이 또한 여전히 한계점이 존재합니다.

  • 너무 많은 설정

    설정 파일이 지나치게 많다는 점입니다. 예를 들어서 애플리케이션에 스프링 빈이 100개가 있다면 여기에 프록시를 통해 부가 기능을 적용하려면 100개의 동적 프록시 생성 코드를 만들어야 한다는 한계점이 존재합니다.

  • 컴포넌트 스캔

    컴포넌트 스캔을 사용하는 경우 지금까지 알아본 방법으로는 프록시 적용이 불가능합니다.
    프록시를 적용하려면, 실제 객체를 스프링 컨테이너에 빈으로 등록하는 것이 아니라 부가 기능이 있는 프록시를 실제 객체 대신 스프링 컨테이너에 빈으로 등록해야 하는데 컴포넌트 스캔의 경우 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문입니다.

다음 포스팅에서는 이러한 한계점을 해결하는 방안에 대하여 자세하게 알아보겠습니다.


정리

JDK 동적 프록시

  • 필수 조건: 인터페이스가 있어야 프록시 생성이 가능합니다.

  • 구성 요소

    • 인터페이스: 프록시 대상의 인터페이스를 정의합니다.

    • 구현체: 인터페이스를 구현하는 클래스입니다.

    • InvocationHandler: 메서드 호출을 처리하는 핸들러로, invoke 메서드를 구현합니다.

CGLIB

  • 특징: 인터페이스가 없어도 구체 클래스만으로 프록시를 생성할 수 있습니다.

  • 구성 요소

    • 구체 클래스: 프록시 대상 클래스입니다.

    • MethodInterceptor: 메서드 호출을 가로채는 인터셉터로, intercept 메서드를 구현합니다.

프록시 팩토리 (ProxyFactory)

  • 특징: 인터페이스가 있으면 JDK 동적 프록시를, 구체 클래스만 있으면 CGLIB를 사용하여 프록시를 생성합니다. 혹은 인터페이스 여부와 상관없이 CGLIB 프록시를 생성합니다.

  • 부가 기능 적용: Advice 인터페이스를 사용하여 부가 기능 로직을 정의합니다.

  • 구성 요소

    • Advice: 부가 기능 로직을 정의하는 인터페이스입니다.

    • MethodInterceptor: Advice를 구현합니다.


Reference

스프링 핵심 원리 - 고급편

profile
정신 🍒 !

0개의 댓글