본문 바로가기
Java/Java

Proxy 패턴과 JDK Dynamic Proxy, CGLIB

by oneny 2023. 8. 17.

Proxy 패턴

특정 객체에 대한 접근을 제어하거나 기능을 추가할 수 있는 패턴이다. 즉, 클라이언트가 사용하려고 하는 대상을 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받는다. 프록시 패턴의 장점에는 초기화 지연, 접근 제어, 로깅, 캐싱 등 다양하게 응용해 사용할 수 있다는 것이다.

 

Proxy 패턴 구성 요소

위 그림은 인터페이스와 인터페이스 구현체를 사용하는 구조를 설명하는 그림이다(위 그림과 다르게 인터페이스가 아닌 클래스 상속을 사용할 수도 있다). 인터페이스 기반의 Proxy 패턴은 특징으로는 본인의 타입을 참조하는 필드를 하나 가지고 있다. 이 필드를 가지고 있는 이유는 RealSubject를 참조하기 위함이다. 해당 필드의 operation() 메서드는 RealSubject의 메서드를 사용하고, operation() 전후로 원하는 추가적인 작업을 할 수 있다.

이렇게 Proxy 패턴을 사용하면 기존 코드를 변경하지 않고, 원하는 기능을 추가하면서 Client는 원래 사용해야할 객체는 RealSubject이지만 실제 사용하는 객체는 Proxy를 쓰게 된다.

 

Proxy 패턴의 장단점

장점

  • 기존 코드를 변경하지 않고 원하는 새로운 기능을 추가할 수 있다.
    • 객체 지향 원칙 중 OCP(Open-Closed Principle, 개방-폐쇄 원칙)에 해당한다.
  • 기존 코드가 해야 하는 일을 유지할 수 있다.
    • 객체 지향 원칙 중 SRP(Single Responsibility Principle, 단일 책임 원칙)에 해당한다.
  • 기능 추가 및 초기화 지연 등으로 다양하게 활용할 수 있다.

 

단점

  • 코드의 복잡도가 증가한다.

 

Proxy 패턴이 필요한 예제 코드

public class Client {

  public static void main(String[] args) {
    GameService gameService = new GameService();
    gameService.startGame();
  }
}

 

public class GameService {

  public void startGame() {
    System.out.println("이 자리에 오신 여러분을 진심으로 환영합니다.");
  }
}

아주 간단한 예제 코드가 있다. GameService의 startGame() 메서드를 실행하면 "이 자리에 오신 여러분을 진심으로 환영합니다"라는 문장이 출력되도록 코드를 작성했다. 이 때, GameService는 건들이지 않고 GameService를 시작하고 끝낼 때 operation(startGame())이 얼마나 걸리는지 확인을 하고 싶다고 가정해보자. 

 

상속을 활용한 프록시 패턴 구현

public class GameServiceProxy extends GameService {

  @Override
  public void startGame() {
    long before = System.nanoTime();
    System.out.println(before);
    super.startGame();
    System.out.println(System.nanoTime() - before);
  }
}

 

다음처럼 상속을 사용하여 GameServiceProxy 클래스를 만들어 startGame() 메서드가 실행되기 전과 후의 시간차를 구할 수 있도록 로직을 추가하였다.

 

public class Client {

  public static void main(String[] args) {
    GameService gameService = new GameServiceProxy();
    gameService.startGame();
  }
}

이를 사용하는 클라이언트에서는 다음처럼 Proxy 패턴을 적용한 GameServiceProxy를 사용하면 RealSubject인 GameService를 건들이지 않고 시간을 측정할 수 있는 기능을 추가했다. 클라이언트는 자신이 사용하는 타입이 GameService인지 GameServiceProxy인지 아니면 다른 GameService를 확장한 타입인지 모르고 GameService가 제공하는 API를 사용할 수 있다는 장점이 있다.

 

인터페이스 기반 Proxy

public interface GameService {
  
  void startGame();
}

 

public class DefaultGameService implements GameService {

  @Override
  public void startGame() {
    System.out.println("이 자리에 오신 여러분을 진심으로 환영합니다.");
  }
}

 

public class GameServiceProxy implements GameService {

  private GameService gameService;

  @Override
  public void startGame() {
    long before = System.nanoTime();
    if (this.gameService == null) {
      this.gameService = new DefaultGameService();
    }
    System.out.println(before);
    gameService.startGame();
    System.out.println(System.nanoTime() - before);
  }
}

위에서 설명했듯이 인터페이스 기반의 Proxy 객체는 GameService 타입의 필드를 하나 가진다. 본래 자신이 해야 하는 일은 RealSubject가 하고, RealSubject인 DefaultGameService가 주입될 수 있도록 생성자 주입 방식 보다는 처음 operation()이 호출되었을때 필드에 대한 인스턴스를 생성하는 초기화 지연(Lazy Initialization)을 선택할 수 있다는 장점이 있다.

 

public class Client {

  public static void main(String[] args) {
    GameService gameService = new GameServiceProxy();
    gameService.startGame();
  }
}

이렇게 인터페이스를 기반으로 Proxy 패턴을 구현하면 RealSubject인 DefaultGameService는 본래 본인이 해야하는 일만 할 수 있고, 부가적인 일들은 Proxy에서 작업을 수행한다.

 

JDK Dynamic Proxy

 

Dynamic Proxy를 사용하는 이유

지금까지 프록시 객체를 컴파일 타임에 사용할 수 있게끔 정적으로 만들고, 메서드가 하나라서 간편했다. 하지만 메서드가 여러 개이고, 여러 클래스에 적용해야 한다면 대상 클래스 수만큼의 프록시 클래스를 하나하나 만들어줘야 하기 때문에 굉장히 번거로운 작업이 될 것이다. 또 그에 따른 반복되는 코드를 작성해야 하기 때문에 코드중복이 된다는 단점도 발생하게 된다.

따라서 이러한 단점들을 보완하기 위해 자바의 Reflection 기능 중 하나로 프록시 팩토리에게 인터페이스 정보만 제공해주면 해당 인터페이스를 구현한 프록시 인스턴스를 컴파일 시점이 아닌 런타임에 동적으로 만들 수 있는 방법을 제공하는 다이나믹 프록시(Dynamic Proxy)를 제공한다. 주의할 점으로 인터페이스가 반드시 존재해야 한다.

 

newProxyInstance()

@CallerSensitive
public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h)

Java에서 제공해주는 Reflection API의 newProxyInstance() 메서드를 사용하면, 런타임 시점에 프록시 클래스를 만들어주기 때문에 대상 클래스 수만큼 프록시 클래스를 만들어야 하는 첫 번째 단점을 해결해준다. newProxyInstance() 메서드의 인자를 다음과 같다.

  • ClassLoader: 프록시 클래스를 만들 클래스로더
  • Class: 동적으로 생성되는 프록시 클래스가 구현해야 하는 인터페이스 타입 목록(배열)
  • InvocationHandler: Proxy의 어떤 메서드가 호출이 될 때 어떻게 처리할 지에 대한 핸들러

 

InvocationHandler

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

InvocationHandler는 invoke() 메서드만 가지고 있는 인터페이스이다. invoke() 메서드는 런타임 시점에 생긴 동적 프록시의 메서드가 호출되었을 때 실행되는 메서드이고, 어떤 메서드가 실행되었는지 메서드 정보와 메서드에 전달된 인자까지 invoke() 메서드의 인자로 들어오게 된다.

또한, invoke() 메서드에 프록시만 사용할 당시, 프록시 클래스마다 들어간 반복된 코드를 한 번만 작성하면 되기 때문에 두 번째 단점을 해결해준다. InvocationHandler의 인자는 다음과 같다.

  • Object: 프록시 객체
  • Method: 호출한 메서드 정보
  • Object[]: 메서드에 전달된 파라미터

 

Dynamic Proxy 예제 코드

public class ProxyInJava {

  public static void main(String[] args) {
    ProxyInJava proxyInJava = new ProxyInJava();
    proxyInJava.dynamicProxy();
  }

  private void dynamicProxy() {
    GameService gameServiceProxy = getGameServiceProxy(new DefaultGameService());
    gameServiceProxy.startGame();
  }

  private GameService getGameServiceProxy(GameService target) {
    return (GameService) Proxy.newProxyInstance(this.getClass().getClassLoader(),
            new Class[]{GameService.class}, (proxy, method, args) -> {
              long before = System.nanoTime();
              System.out.println(before);
              if (method.getName().equals("startGame")) {
                method.invoke(target, args); // 메서드 호출
              }
              System.out.println(System.nanoTime() - before);
              return null;
            });
  }
}

위 코드를 보면 람다식을 사용했는데 익명 클래스로 보면 invoke()라는 메서드를 구현한 것을 알 수 있다. method.invoke()를 실행하면 Proxy 패턴의 구성 요소를 기준으로 RealSubject의 operation()이 실행된다고 생각하면 좋다. 이 때, 첫 번째 인자로는 우리가 원래 사용해야 하는 RealSubject에 해당하는 구현체를, 두 번째 인자로 메서드에 넘겨줄 파라미터들을 전달해주면 된다. 실제 실행해보면 이전에 봤던 프록시랑 똑같이 출력하는 것을 확인할 수 있다.

 

이제는 Proxy 클래스를 매번 만드는 번거로운 작업은 하지 않아도 되지만, InvocationHandler 자체가 유연하지 않다는 문제가 있다. 그리고 한 가지 제약사항이 있다면 JDK Dynamic Proxy를 사용하는 경우 클래스 기반의 프록시를 만들지 못한다는 것이다. 위에서 말했듯이 Proxy.newProxyInstance() 메서드의 두 번째 인자에 인터페이스가 무조건 필요하다.

그러면 클래스 기반의 프록시는 동적으로 만드는 방법이 없을까? 자바가 제공하는 JDK Dynamic Proxy는 사용하지 못하지만 다른 방법이 있다.

 

CGLIB

인터페이스가 없는 클래스 기반의 프록시의 경우에는 어떻게 동적으로 프록시를 생성할 수 있을까? 다른 라이브러리를 사용할 수 있지만 스프링, 하이버네이트에서도 사용하고 있는 GCLIB 라이브러리를 사용해서 런타임에 동적으로 자바 클래스의 프록시를 생성할 수 있다.

CGLIB 프록시는 Target Class를 상속받아 생성되기 때문에 개발자는 Proxy 생성을 위해 굳이 인터페이스를 만드는 수고를 덜 수 있고, 실제 바이트 코드를 조작하기 때문에 JDK Dynamic Proxy 보다는 포퍼먼스가 상대적으로 빠르다는 장점이 있다. 하지만 final이나 private와 같이 상속된 객체에 오버라이딩을 지원하지 않는 경우 Proxy에서 해당 메서드에 대한 Aspect을 적용할 수 없다는 단점이 있다.

 

CGLIB를 사용하여 프록시 객체 생성하는 방법

CGLIB를 사용할 경우에는 Enhancer.create() 메서드를 사용하여 프록시 객체를 생성할 수 있다(팩토리 메서드 방식이 아니더라도 가능). 첫 번째 인자로는 상속받을 클래스 타입을, 두 번 째 인자로 MethodInterceptor 타입을 받는데 아래처럼 MethodInterceptor 인터페이스는 1개의 메서드를 선언하고 있고, intercept() 메서드에는 4개의 파라미터가 있다.

 

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

}
  • object: 원본 객체
  • method: 원본 객체의 호출될 메서드를 나타내는 Method 객체
  • args: 원본 객체에 전달될 파라미터
  • methodProxy: CGLIB가 제공하는 원본 객체의 메서드 프록시

 

위 그림처럼 프록시 객체에 대한 모든 호출이 MethodInterceptor를 거친 뒤에 원본 객체에 전달된다. 따라서, MethodInterceptor를 사용하면 원본 객체 대신 다른 객체의 메서드를 호출할 수 있도록 할 수 있으며, 심지어 원본 객체에 전달될 인자의 값을 변경할 수도 있다.

 

CGLIB 예제 코드

public class GameService {

  public void startGame() {
    System.out.println("이 자리에 오신 여러분을 진심으로 환영합니다.");
  }
}

GameService 인터페이스를 클래스로 수정하였다.

 

class GameServiceTest {

@Test
void di() {
  MethodInterceptor handler = new MethodInterceptor() {

    private final GameService gameService = new GameService();

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {

      if (method.getName().equals("startGame")) {
        long before = System.nanoTime();
        System.out.println(before);
        Object invoke = methodProxy.invoke(gameService, args);
        System.out.println(System.nanoTime() - before);
        return invoke;
      }

      return methodProxy.invoke(gameService, args);
    }
  };

  // 다이나믹 프록시를 만들 때 InvocationHandler를 만들었던 것처럼 Handler를 넘겨줘야 한다.
  GameService gameService = (GameService) Enhancer.create(GameService.class, handler);

  gameService.startGame();
}
}

그리고 다음과 같이 MethodInterceptor 구현체를 만드는데 위에서 InvocationHandler와 많이 비슷한 형태인 것을 확인할 수 있다. 이렇게 CGLIB 라이브러리를 이용하면 클래스 기반으로도 동적으로 프록시 객체를 생성할 수 있다.

 

 

 

출처

[GoF 디자인 패턴] 프록시 패턴 1부, 프록시 패턴 소개

[GoF 디자인 패턴] 프로시 패턴 2부, 프록시 패턴을 구현해 보자.

[GoF 디자인 패턴] 프록시 패턴 3부, 장점과 단점

[GoF 디자인 패턴] 프록시 패턴 4부, 자바의 다이나믹 프록시와 스프링 AOP

더 자바, 코드를 조작하는 다양한 방법

[Spring] Proxy (1) Java Dynamic Proxy vs. CGLIB

CGLIB를 이용한 프록시 객체 만들기