동적 프록시(Dynamic Proxy) with Spring

SungBum Park·2022년 9월 4일
2

디자인 패턴 글 중 프록시 패턴과 데코레이터 패턴에서 프록시를 코드로 구현하는 방법을 알아보았다. (이전 글 참고) 그런데 프록시를 적용해야 할 클래스가 수십 개에서 수백 개가 되면 어떻게 될까? 이러한 프록시 클래스를 그 개수만큼 반복해서 만들어주어야 한다.

이러한 불편함을 해소하기 위해 나온 기술이 동적 프록시(Dynamic Proxy)이다. 동적 프록시는 말그대로 동적인 시점(런타임 시점)에 프록시를 자동으로 만들어서 적용해주는 기술이다.

자바에서 대표적인 동적 프록시 기술은 JDK 동적 프록시와 CGLIB(Code Generator LIBrary)이 있다. 두 기술 모두 동적 프록시를 만들어주지만, 차이점은 다음과 같다.

  • JDK 동적 프록시
    • 인터페이스 기반으로 프록시를 생성한다.
    • 자바에서는 리플렉션을 사용하여 프록시를 생성한다. (JDK 동적 프록시 관련 클래스도 reflect 패키지에 존재한다.)
  • CGLIB
    • 클래스 기반(인터페이스도 가능)으로 프록시를 생성한다.
    • ASM 프레임워크를 활용하여 바이트코드를 조작하여 프록시를 생성한다. (리플렉션도 적절히 활용하는 것으로 보임.)

프록시를 생성하는 것은 타켓 클래스를 구현 시점에 알고 있다면, 생성하기 매우 간단하다. 하지만, 타켓 클래스와 그 클래스의 메서드 정보를 전혀 알 수 없는 상태에서 프록시를 만들기는 쉽지 않다. 동적 프록시는 이러한 환경에서 프록시를 만들어야 한다. 이를 위해, 개발자가 구현한 코드에 대한 정보가 필요한데 이를 자바 컴파일 이후 바이트코드를 만드는 시점이나 JVM에 모든 정보가 올라가서 실행하는 시점 등에서 이러한 코드 정보를 가져온다. JDK 동적 프록시와 CGLIB 역시 앞서 말한 시점 등에서 코드 정보를 분석하여 프록시를 생성한다. (리플렉션은 자바에서 공식적으로 제공하여 자주 접할 수도 있고, 사용법 역시 직관적이고 복잡하지 않다. 하지만 바이트코드를 조작하는 일은 매우 복잡하다.)

그러면, JDK 동적 프록시와 CGLIB을 어떻게 사용하는지 예제를 통해 살펴보자.

전체 코드는 이 링크에서 볼 수 있습니다.

0. 정적 프록시의 문제점

어떤 서비스는 ‘일반' 회원과 ‘어드민' 회원, 그리고 주문을 저장할 수 있는 기능이 있다고 가정해보자. 이를 저장하는 역할은 ‘repository’에서 수행한다. 이를 다음과 같은 클래스 관계로 정의하였다.

save() 메서드는 DB에 저장하는 역할을 하며, 이 예제에서는 단순히 로그를 출력한다.

@Slf4j
public class NormalMemberRepository implements MemberRepository {

    @Override
    public void save(String name) {
        log.info("일반 회원 저장, name={}", name);
    }
}
@Slf4j
public class AdminMemberRepository implements MemberRepository {

    @Override
    public void save(String name) {
        log.info("어드민 회원 저장, name={}", name);
    }
}
@Slf4j
public class OrderRepository {

    public void save(String name) {
        log.info("주문 저장, name={}", name);
    }
}

위 3개의 클래스 코드는 클래스 관계도에서 보았던 클래스 중 구체 클래스만 나열하였다.

여기서 트랜잭션 기능을 추가해야한다면, 어떻게 해야할까? 물론, 위 save() 메서드 내부에 트랜잭션 관련 로직을 직접 추가해도 된다. 하지만, 객체지향 관점에서 이는 매우 좋지 못한 코드이다. 중복도 너무 많고, 객체에 대한 책임도 많아진다. 그리고 트랜잭션은 비즈니스 핵심 기능이라기 보다는 부가 기능에 속한다. 이를 파악한 개발자는 이를 프록시로 직접 만들기로 했다.

프록시를 추가하면 클래스 관계는 다음과 같이 변경될 것이다.

회원은 MemberRepository 인터페이스로 두 구체클래스가 묶여있어, 상위 인터페이스에 대한 프록시를 만들었고, 주문은 OrderRepository 구체 클래스에 대한 프록시를 만들어 총 2개의 프록시 클래스가 만들어졌다.

@Slf4j
public class MemberTransactionProxy implements MemberRepository {

    private MemberRepository target;

    public MemberTransactionProxy(MemberRepository memberRepository) {
        this.target = memberRepository;
    }

    @Override
    public void save(String name) {
        log.info("Target Class={}", target.getClass());

        try {
            log.info("--- 트랜잭션 커밋 시작 ---");

            target.save(name);

            log.info("--- 트랜잭션 커밋 완료 ---");
        } catch (Exception e) {
            log.info("--- 트랜잭션 롤백 ---");
        } finally {
            log.info("--- DB 커넥션 자원 반환 ---");
        }
    }
}
@Slf4j
public class OrderTransactionProxy extends OrderRepository {

    private OrderRepository target;

    public OrderTransactionProxy(OrderRepository target) {
        this.target = target;
    }

    @Override
    public void save(String name) {
        log.info("Target Class={}", target.getClass());

        try {
            log.info("--- 트랜잭션 커밋 시작 ---");

            target.save(name);

            log.info("--- 트랜잭션 커밋 완료 ---");
        } catch (Exception e) {
            log.info("--- 트랜잭션 롤백 ---");
        } finally {
            log.info("--- DB 커넥션 자원 반환 ---");
        }
    }
}

위 두 클래스가 각각 회원과 주문을 위한 트랜잭션 기능을 하는 프록시이다.(트랜잭션 기능도 로그를 출력하는 것으로 대체하였다.) 클라리언트에서 일반 회원을 저장하는 간단한 예제 테스트 코드를 보자.

@Test
void save_normal_member() {
    String normalMemberName = "PARKER";

    MemberRepository normalMemberRepository = new NormalMemberRepository();
    MemberRepository proxy = new MemberTransactionProxy(normalMemberRepository);

    proxy.save(normalMemberName);
}
Target Class=class me.parker.dynamicproxywithspring.repository.NormalMemberRepository
--- 트랜잭션 커밋 시작 ---
일반 회원 저장, name=PARKER
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---

타겟 클래스로 NormalMemberRepository 클래스가 호출되었고, 트랜잭션 기능이 정상적으로 적용된 것을 볼 수 있다.

하지만 이렇게 정적으로 프록시를 생성하는 데 있어, 단점은 처음에 말했던 부분을 바로 느낄 수 있다. 프록시마다 같은 트랜잭션 기능을 중복으로 구현하고 있다. 만약 다른 리포지토리에 트랜잭션 기능을 추가하려면 같은 기능을 수행하는 프록시 클래스를 새로 만들어야 한다. 만약 트랜잭션의 어떤 기능을 변경하려면 만들었던 모든 트랜잭션 프록시 클래스를 모두 변경해야 하는 불편함이 있다.

이 문제를 해결하기 위해 나온 것이 바로 동적 프록시이다. 이를 적용해보자.

1. JDK 동적 프록시 적용

먼저, JDK 동적 프록시를 적용해보자. JDK 동적 프록시는 자바의 리플렉션 패키지에 포함되어 있어, 다른 의존성을 추가할 필요없이 바로 사용가능하다.

JDK 동적 프록시를 사용하기 위해서는 InvocationHandler 인터페이스를 구현해야 한다.

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

@Slf4j
public class TransactionInvocationHandler implements InvocationHandler {

    private final Object target;

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

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        log.info("InvocationHandler parameter: proxy={}, method={}, args={}", proxy, method, args);

        try {
            log.info("--- 트랜잭션 커밋 시작 ---");

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

            log.info("--- 트랜잭션 커밋 완료 ---");
            return result;
        } catch (Exception e) {
            log.info("--- 트랜잭션 롤백 ---");
            throw e;
        } finally {
            log.info("--- DB 커넥션 자원 반환 ---");
        }
    }
}
  • proxy: 프록시 자신
  • method: 호출한 메서드
  • args: 호출한 메서드에 전달한 실제 파라미터 데이터

위는 InvocationHandler 인터페이스를 구현하여 트랜잭션 기능을 추가한 코드이다. 이 인터페이스 구현 시 invoke() 메서드를 반드시 구현해야 하며, 여기에 부가 기능을 선언한다. 위는 트랜잭션 기능을 추가한 모습이다. 트랜잭션 기능을 추가하고 싶은 클래스들은 이 핸들러를 모두 재사용할 수 있다.

method.invoke() 는 실제 타겟의 메서드를 실행한다. (어떤 메서드를 실행할지는 클라이언트에서 실제로 타겟의 어떤 메서드를 호출하는지에 따라 결정된다.)

이 핸들러를 사용하여 회원을 생성해보자.

@Test
void save_normal_member() {
    String normalMemberName = "PARKER";
    MemberRepository target = new NormalMemberRepository();

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

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

@Test
void save_admin_member() {
    String adminMemberName = "PARKER_ADMIN";
    MemberRepository target = new AdminMemberRepository();

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

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

클라이언트에서 생성된 핸들러를 사용하기 위해서는 먼저, 핸들러를 생성하고 Proxy.newProxyInstance() 를 통해 프록시 객체를 생성해야 한다. 그리고 중요한 점은 프록시로 선언한 타겟 클래스의 타입은 반드시 인터페이스어야 한다. 회원 리포지토리는 인터페이스이므로 위 클라이언트 코드는 문제없이 동작하는 것을 볼 수 있다.

targetClass=class me.parker.dynamicproxywithspring.repository.NormalMemberRepository
proxyClass=class jdk.proxy3.$Proxy11
InvocationHandler parameter: proxy=me.parker.dynamicproxywithspring.repository.NormalMemberRepository@465232e9, method=public abstract void me.parker.dynamicproxywithspring.repository.MemberRepository.save(java.lang.String), args=[PARKER]
--- 트랜잭션 커밋 시작 ---
일반 회원 저장, name=PARKER
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---
targetClass=class me.parker.dynamicproxywithspring.repository.AdminMemberRepository
proxyClass=class jdk.proxy3.$Proxy11
InvocationHandler parameter: proxy=me.parker.dynamicproxywithspring.repository.AdminMemberRepository@465232e9, method=public abstract void me.parker.dynamicproxywithspring.repository.MemberRepository.save(java.lang.String), args=[PARKER_ADMIN]
--- 트랜잭션 커밋 시작 ---
어드민 회원 저장, name=PARKER_ADMIN
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---

로그를 살펴보면, JDK 동적 프록시로 생성된 동적 프록시 클래스는 jdk.proxy3.$Proxy11 형태로 출력되는 것을 알 수 있다. 나머지는 트랜잭션이 정상적으로 각각 핸들러를 통해 적용된 모습을 볼 수 있다.

그런데, 주문 리포지토리를 이 핸틀러를 사용하게 되면 어떻게 될까? OrderRepository 는 인터페이스가 아닌 구체 클래스였다.

@Test
void save_order() {
    String orderName = "주문01";
    OrderRepository target = new OrderRepository();

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

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

위 테스트 코드를 실행하게 되면 다음과 같은 에러가 발생한다.

me.parker.dynamicproxywithspring.repository.OrderRepository is not an interface
java.lang.IllegalArgumentException: me.parker.dynamicproxywithspring.repository.OrderRepository is not an interface
	at java.base/java.lang.reflect.Proxy$ProxyBuilder.validateProxyInterfaces(Proxy.java:706)
	...

눈의 띄는 에러 메시지는 OrderRepository is not an interface 이다. JDK 동적 프록시는 타겟 클래스의 타입이 인터페이스가 아니면 에러가 발생하여 적용할 수 없다는 것을 볼 수 있다.

2. CGLIB 적용

JDK 동적 프록시에서는 OrderRepository 와 같은 구체 클래스에 대한 프록시를 생성할 수 없었다. 이를 해결할 수 있는 방법이 CGLIB 라이브러리를 사용하는 것이다. CGLIB를 사용하기 위해서 직접 Maven Repository에서 가져올 수도 있지만, 사실 스프링 내부에서 CGLIB 소스 코드가 포함되어 있다. 따라서, 스프링 부트 기준 spring-boot-starter 의존성만 가지고 있다면 별도의 의존성 추가 없이 CGLIB를 사용할 수 있다.

CGLIB은 프록시의 부가 기능을 MethodInterceptor 인터페이스를 구현하여 추가할 수 있다.

import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

@Slf4j
public class TransactionMethodInterceptor implements MethodInterceptor {

    private final Object target;

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

    @Override
    public Object intercept(Object object, Method method,
                            Object[] args, MethodProxy proxy) throws Throwable {
        try {
            log.info("--- 트랜잭션 커밋 시작 ---");

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

            log.info("--- 트랜잭션 커밋 완료 ---");
            return result;
        } catch (Exception e) {
            log.info("--- 트랜잭션 롤백 ---");
            throw e;
        } finally {
            log.info("--- DB 커넥션 자원 반환 ---");
        }
    }
}
  • object: 동적 프록시로 만들어진 객체
  • method: 호출된 메서드 정보
  • args: 호출된 메서드의 매개변수에 전달된 실제 데이터
  • proxy: 메서드 호출에 사용 (Method 인스턴스를 사용해도 되지만, CGLIB에서 성능상 MethodProxy 를 사용하도록 권장함.)

코드를 보면 알겠지만, MethodInerceptor 인터페이스를 사용한다는 것을 제외하고 JDK 동적 프록시와 사용 방법은 거의 동일하다. 위에서도 말했지만, MethodInterceptor 인터페이스의 위치는 스프링 프레임워크인 것을 확인할 수 있다.

프록시의 기능을 선언하는 것은 JDK 동적 프록시와 매우 유사하지만, 클라이언트에서 프록시를 호출하는 방법은 눈에 띄게 차이가 존재한다. 먼저, 회원을 저장하는 클라이언트 테스트 코드를 보자.

@Test
void save_normal_member() {
    String normalMemberName = "PARKER";
    MemberRepository target = new NormalMemberRepository();

    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(MemberRepository.class);
    enhancer.setCallback(new TransactionMethodInterceptor(target));
    MemberRepository proxy = (MemberRepository) enhancer.create();

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

@Test
void save_admin_member() {
    String adminMemberName = "PARKER_ADMIN";
    MemberRepository target = new AdminMemberRepository();

    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(MemberRepository.class);
    enhancer.setCallback(new TransactionMethodInterceptor(target));
    MemberRepository proxy = (MemberRepository) enhancer.create();

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

CGLIB에서 프록시를 생성하기 위해서는 Enhancer 클래스를 사용한다. setSuperclass() 메서드는 CGLIB이 프록시를 생성하기 위해 어떤 클래스(인터페이스 포함)를 상속받을 지에 대한 클래스 타입 정보를 매개변수로 받는다. CGLIB에서는 프록시를 생성할 때 앞선 메서드에 선언한 클래스를 상속하는 방식으로 프록시를 생성한다. (상속을 사용하므로, JDK 동적 프록시와 달리 구체 클래스도 프록시를 생성할 수 있다.)

setCallback() 메서드는 프록시에 적용할 부가 기능이 어떤 것인지 매개변수로 받는다. 여기서 앞서 생성했던 MethodInterceptor 를 구현했던 클래스의 인스턴스를 할당한다. 마지막으로 create() 메서드를 통해 앞서 설정했던 정보를 바탕으로 프록시를 생성한다.

targetClass=class me.parker.dynamicproxywithspring.repository.NormalMemberRepository
proxyClass=class me.parker.dynamicproxywithspring.repository.MemberRepository$$EnhancerByCGLIB$$65b979a4
--- 트랜잭션 커밋 시작 ---
일반 회원 저장, name=PARKER
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---
targetClass=class me.parker.dynamicproxywithspring.repository.AdminMemberRepository
proxyClass=class me.parker.dynamicproxywithspring.repository.MemberRepository$$EnhancerByCGLIB$$65b979a4
--- 트랜잭션 커밋 시작 ---
어드민 회원 저장, name=PARKER_ADMIN
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---

CGLIB에서 생성한 프록시 클래스의 모습은 MemberRepository$$EnhancerByCGLIB$$65b979a4 의 형태이다.

JDK 동적 프록시에서 문제가 되었던 구체 클래스의 프록시를 생성하는 주문 리포지토리 예제도 살펴보자.

@Test
void save_order() {
    String orderName = "주문01";
    OrderRepository target = new OrderRepository();

    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(OrderRepository.class);
    enhancer.setCallback(new TransactionMethodInterceptor(target));
    OrderRepository proxy = (OrderRepository) enhancer.create();

    log.info("targetClass={}", target.getClass());
    log.info("proxyClass={}", proxy.getClass());
    proxy.save(orderName);
}
targetClass=class me.parker.dynamicproxywithspring.repository.OrderRepository
proxyClass=class me.parker.dynamicproxywithspring.repository.OrderRepository$$EnhancerByCGLIB$$929eed0
--- 트랜잭션 커밋 시작 ---
주문 저장, name=주문01
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---

위 클라이언트 테스트 코드를 수행해보면, 이번에는 정상적으로 동작하는 것을 볼 수 있다.

JDK 동적 프록시 VS CGLIB

지금까지 JDK 동적 프록시와 CGLIB으로 동적으로 어떻게 프록시를 만드는지 예제를 통해 살펴보았다. 마지막으로 이 두 기술에 대한 차이점 다시 정리해보자.

  • JDK 동적 프록시
    • 인터페이스 기반으로만 동적 프록시를 생성할 수 있다.
    • 리플렉션 기술을 사용하여 동적 프록시를 생성한다.
    • InvocationHandler 인터페이스를 구현하여 프록시 기능을 설정한다.
    • 단점으로는 구체 클래스에 대해서는 동적 프록시를 생성할 수 없는 한계가 있다.
  • CGLIB
    • 인터페이스를 포함하여 구체 클래스에도 동적 프록시를 생성할 수 있다.
    • 바이트코드를 조작하여 동적 프록시를 생성한다.
    • MethodInterceptor 인터페이스를 구현하여 프록시 기능을 설정한다.
    • 단점으로는
      • 동적으로 생성을 위한 기본 생성자가 필수로 필요하다. (없을 시 예외 발생)
      • final 키워드가 붙은 클래스는 이를 상속할 수 없어 CGLIB을 적용할 수 없다. (예외 발생)
      • final 키워드가 붙은 메서드는 오버라이딩할 수 없어 CGLIB을 적용할 수 없다. (예외는 발생하지 않지만, 적용이 되지 않음)

3. Spring ProxyFactory 적용

스프링 프레임워크에서는 동적 프록시를 편리하게 사용하기 위해서 ProxyFactory 클래스로 추상화하여 사용한다. ProxyFactory 는 결국 JDK 동적 프록시와 CGLIB을 사용하지만 이러한 구체적인 기술을 전혀 모른 채로 사용할 수 있다. 그리고 프록시 기능을 설정하기 위해 구현하던 인터페이스도 MethodInterceptor 인터페이스로 통일하여 사용할 수 있다. (CGLIB의 MethodInterceptor 와 이름은 동일하지만, 전혀 다른 패키지에 존재한다. 아래 예제에서 어떤 패키지인지 알 수 있다.)

ProxyFactory 는 내부적으로 타겟 클래스의 타입이 인터페이스면 JDK 동적 프록시를 사용하고, 구체 클래스면 CGLIB을 사용한다. (이는 설정으로 모든 경우에 CGLIB을 사용하게 할 수 있으며, Spring Boot에서는 모든 경우에 CGLIB을 사용하는 것이 기본 설정이다.)

먼저, ProxyFactory 를 사용하기 위해 프록시 부가 기능을 설정해보자.

import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;

@Slf4j
public class TransactionAdvice implements MethodInterceptor {

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        log.info("Advice parameter: invocation={}", invocation);

        try {
            log.info("--- 트랜잭션 커밋 시작 ---");

            Object result = invocation.proceed();

            log.info("--- 트랜잭션 커밋 완료 ---");
            return result;
        } catch (Exception e) {
            log.info("--- 트랜잭션 롤백 ---");
            throw e;
        } finally {
            log.info("--- DB 커넥션 자원 반환 ---");
        }
    }
}
  • invocation: JDK 동적 프록시와 CGLIB에서는 메서드 호출과 프록시 인스턴스, 매개변수 정보 등이 분리되어 매개변수로 선언되어 있었지만, 여기서는 MethodInvocation 클래스 내부에 모두 포함되어있다.
    • 또 다른 점은 target 객체를 받지 않은 점이다. 이는 ProxyFactory 클래스를 사용하는 데서 보겠지만, 이를 생성할 때 target 인스턴스를 받아서 생성한다. 따라서 target 정보 역시 invocation 인스턴스 안에 저장되어 있다.

invocation 매개변수를 설명하면서도 앞서 두 동적 프록시 기술과 다른 점을 말했지만, 그 외에도 2 가지 정도 더 살펴볼 부분이 있다. 첫 째는 앞서 말했던 MethodInterceptor 인터페이스가 포함되어 있는 패키지이다. CGLIB에서는 의존성을 스프링으로 사용한 것을 감안하여 보면 org.springframework.cglib.proxy.MethodInterceptor 였지만, 프록시 팩토리에서는 org.aopalliance.intercept.MethodInterceptor 에 있는 인터페이스이다.

둘 째는 advice 라는 이름이다. MethodInterceptor 인터페이스 구조는 MethodInterceptorInterceptorAdvice 구조로 되어있다. 여기서 Advice라는 용어는 AOP에서 부가기능이라는 의미로 사용된다. AOP에서 사용하는 단어가 스프링의 프록시 팩토리에서 여럿 적용되어 있다. (이러한 용어 정리는 다음에 AOP 글을 작성할 때 좀 더 자세히 살펴볼 예정이다.)

마지막으로 클라이언트 코드에서 ProxyFactory 를 사용하여 동적 프록시를 생성하는 것을 보자.

@Test
void save_normal_member() {
    String normalMemberName = "PARKER";
    MemberRepository target = new NormalMemberRepository();

    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvice(new TransactionAdvice());
    MemberRepository proxy = (MemberRepository) proxyFactory.getProxy();

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

@Test
void save_admin_member() {
    String adminMemberName = "PARKER_ADMIN";
    MemberRepository target = new AdminMemberRepository();

    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvice(new TransactionAdvice());
    MemberRepository proxy = (MemberRepository) proxyFactory.getProxy();

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

@Test
void save_order() {
    String orderName = "주문01";
    OrderRepository target = new OrderRepository();

    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.addAdvice(new TransactionAdvice());
    OrderRepository proxy = (OrderRepository) proxyFactory.getProxy();

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

회원과 주문 모두 이전에 살펴봤던 예제와 동일하다. 단지 위는 ProxyFactory 를 사용해서 동적 프록시를 생성한 점이다. 앞서 말했듯 target 인스턴스를 직접 생성자에서 받아서 생성하므로, 여기에 대한 정보를 생성하는 시점에 저장한다. 그리고 addAdvice() 메서드로 생성한 부가기능 Advice를 설정하였다. 그 후 프록시를 생성하여 트랜잭션 기능을 적용하였다.

targetClass=class me.parker.dynamicproxywithspring.repository.NormalMemberRepository
proxyClass=class jdk.proxy3.$Proxy11
Advice parameter: invocation=ReflectiveMethodInvocation: public abstract void me.parker.dynamicproxywithspring.repository.MemberRepository.save(java.lang.String); target is of class [me.parker.dynamicproxywithspring.repository.NormalMemberRepository]
--- 트랜잭션 커밋 시작 ---
일반 회원 저장, name=PARKER
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---

targetClass=class me.parker.dynamicproxywithspring.repository.AdminMemberRepository
proxyClass=class jdk.proxy3.$Proxy11
Advice parameter: invocation=ReflectiveMethodInvocation: public abstract void me.parker.dynamicproxywithspring.repository.MemberRepository.save(java.lang.String); target is of class [me.parker.dynamicproxywithspring.repository.AdminMemberRepository]
--- 트랜잭션 커밋 시작 ---
어드민 회원 저장, name=PARKER_ADMIN
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---

먼저, 인터페이스 기반인 회원 저장 리포지토리를 보자. 둘다 proxyClass 결과를 보면 JDK 동적 프록시가 적용된 인스턴스 형태인 것을 알 수 있다.

targetClass=class me.parker.dynamicproxywithspring.repository.OrderRepository
proxyClass=class me.parker.dynamicproxywithspring.repository.OrderRepository$$EnhancerBySpringCGLIB$$b63f6d05
Advice parameter: invocation=ReflectiveMethodInvocation: public void me.parker.dynamicproxywithspring.repository.OrderRepository.save(java.lang.String); target is of class [me.parker.dynamicproxywithspring.repository.OrderRepository]
--- 트랜잭션 커밋 시작 ---
주문 저장, name=주문01
--- 트랜잭션 커밋 완료 ---
--- DB 커넥션 자원 반환 ---

구체 클래스 기반인 주문 리포지토리는 CGLIB으로 생성된 프록시 인스턴스 형태인 것을 확인할 수 있다.

마지막으로 모든 동적 프록시 생성을 CGLIB으로 하는 설정은 아래와 같다.

proxyFactory.setProxyTargetClass(true);

정리

지금까지 동적 프록시 기술에 대해 살펴보았다. 자바에서 기본적으로 사용할 수 있는 동적 프록시 기술인 JDK 동적 프록시와 CGLIB부터 스프링 프레임워크에서 이를 추상화한 ProxyFactory까지 알아보았다. 사실 ProxyFactory 역시도 스프링을 사용하더라도 거의 사용하지 않는다. 이를 더 최적화하고 편리하게 사용할 수 있게 만든 것이 Spring AOP 기술이기 때문이다. 이에 대해서는 다음에 자세히 살펴볼 예정이다.

참고자료

profile
https://parker1609.github.io/ 블로그 이전

0개의 댓글