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

rin·2020년 6월 4일
6
post-thumbnail

[MongoDB] MongoTemplate과 MongoRepository를 함께 사용할 순 없는 걸까? 에서 가졌던 의문들을 정리하고 공부한다.

ref. [번역] Spring (3) Aspect Oriented Programming with Spring

Java Dynamic Proxy: What is a Proxy and How can We Use It

What is a Proxy?

⭐️ Proxy는 디자인 패턴이다.
이미 존재하는 클래스에 어떤 기능을 추가하거나 수정할 때, 프록시 오브젝트를 만들어 사용한다. 프록시 오브젝트는 원래 오브젝트 대신 사용된다. 일반적으로 프록시 오브젝트는 원래 오브젝트와 동일한 메소드를 가지며, 자바 프록시 클래스에서는 대개 원본 클래스를 확장한다. 프록시는 원래 오브젝트에 대한 제어권을 가지기 때문에 메소드를 호출 할 수 있다.

❗️NOTE
Proxy vs. Proxy Pattern
일반적으로 사용하는 Proxy라는 용어와 디자인 패턴에서 이야기하는 프록시 패턴은 구분할 필요가 있다. 비슷한 개념이지만, 내용은 조금 다르다.

일반적으로 부르는 Proxy는 실제 Target의 기능을 수행하면서 기능을 확장하거나 추가하는 실제 "객체"를 의미한다.

Proxy Pattern은 실제로 Target에 대한 기능을 확장하거나, 추가하지 않는다. 그저 클라이언트가 타깃에 접근하는 방식을 변경해주는 역할을 한다.

프록시 클래스는 많은 것들을 원래 코드를 수정하지 않고도 편리하게 구현할 수 있다.(아래 목록은 일부 예만 나열한 것이다.)

  • 메소드가 시작하고 끝날 때 로그를 남긴다.
  • argument에 대한 추가적인 확인을 수행한다.
  • 원래 클래스의 행동을 모킹한다.
  • 비싼 자원에 대한 lazy 접근을 실행한다.

실제 응용 프로그램에서 "프록시 클래스"는 직접 기능을 구현하지 않는다. 단일 책임 원칙에 따라 프록시 클래스는 프록시만 수행하고 실제 동작 수정은 핸들러에서 구현된다. 프록시 객체가 원래 객체 대신 호출되면, 프록시는 원래 메서드 혹은 어떤 핸들러에서 이를 호출할지 결정한다. 핸들러는 직접 그 작업을 수행할 수도 있고 원래의 메소드를 호출할 수도 있다.

프록시 패턴은 런타임 중에 프록시 객체와 프록시 클래스나 생성되는 상황에만 적용되는 것이 아니고 이는 Java에서 특히 흥미로운 주제이다.

이것은 리플렉션 클래스를 사용하는 것이 요구되거나 바이트코드 조작이나 동적으로 생성된 자바 코드를 컴파일하는 것들이 포함되므로 꽤 고급 주제이다. 런타임 중에 아직 바이트코드로 사용할 수 없는 새 클래스를 가지려면 바이트 코드를 생성하고 로드하는 클래스 로더가 필요하다. 바이트코드를 생성하기 위해선 cglib이나 bytebuddy 혹은 내장 Java 컴파일러를 사용하라.

프록시 클래스와 프록시가 호출하는 핸들러를 떠올리면 책임의 분리가 왜 중요한지 이해할 수 있다. 런타임 중에 프록시 클래스가 생성되지만, 프록시 클래스에 의해 호출되는 핸들러는 일반적인 소스 코드이므로 전체 프로그램의 코드를 따라 컴파일할 수 있다 (컴파일 타임).

How Can We Use This In Our Code?

가장 쉬운 방법은 JDK의 일부인 java.lang.reflection.Proxy 클래스를 사용하는 것이다. java.lang.reflection.Proxy 클래스는 프록시 클래스나 프록시 클래스의 인스턴스를 직접 만들 수 있다. 자바 내장 프록시를 사용하는 것은 꽤 쉽다. 해야할 일은 프록시 오브젝트가 호출할 수 있도록 java.lang.InvocationHandler를 구현하는 것이다. InvocationHandler 인터페이스는 invoke()라는 단 하나의 메소드만 가지는 매우 간단한 구조이다. invoke() 메소드가 호출될 때, argument에는 프록시될 원본 오브젝트, 호출된 메서드 (리플랙션 Method 오브젝트로써), 원본 argument의 오브젝트 배열이 포함된다. 아래 샘플 코드를 보자 :

package proxy;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class JdkProxyDemo {

   interface If {
      void originalMethod(String s);
   }
   
   static class Original implements If {
      @Override
      public void originalMethod(String s) {
         System.out.println(s);
      }
   }
   
   static class Handler implements InvocationHandler {
      private final If original;
      
      public Handler(If original) {
         this.original = original;
      }
      
      @Override
      public Object invoke(Object proxy, Method method, Object[] args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {
         System.out.println("BEFORE");
         method.invoke(original, args);
         System.out.println("AFTER");
         return null;
      }
   }
   
   public static void main(String[] args){
      Original original = new Original();
      Handler handler = new Handler(original);
      If f = (If) Proxy.newProxyInstance(If.class.getClassLoader(), new Class[] {If.class}, handler);
      f.originalMethod("Hallo");
   }
}

핸들러가 원본 객체에 대해 원본 메소드를 호출하려면, 그것에 대한 엑세스 권한이 있어야한다. 이것은 Java 프록시 구현체에 의해 제공되지 않는다. 코드에서 이 인수를 핸들러의 인스턴스에 직접 전달해야한다. (대게 프록시 객체가 InvocationHandler에 인수로 전달된단 점에 유의하라. 이것은 Java 리플랙션이 동적으로 생성하는 프록시 객체이며 우리가 프록시하고자 하는 객체가 아니다.) 이렇게 하면 각 원본 클래스에 대해 별도의 핸들러 객체를 사용할 수 있고, 적어도 호출할 어떤 메소드가 있다면 호출하기 위한 원본 객체를 알 수 있는 일부 공유된 객체를 사용할 수 있다.

특별한 경우에 호출 핸들러와 원본 오브젝트가 없는 인터페이스 프록시를 만들수 있다. 또한 소스코드 내에서 이 인터페이스를 구현하기 위한 클래스도 필요하지 않다. 동적으로 생성된 프록시 클래스는 인터페이스를 구현한다.

프록시하려는 클래스가 인터페이스의 구현체가 아니라면 어떻게 해야할까? 이런 경우에 다른 프록시 구현체를 사용할 수 있는데, Cglib 기반의 CglibAopProxy 클래스를 사용할 수 있다.

Java dynamic proxy - Example

interface People

Java dynamic proxy는 인터페이스가 필수적으로 요구되므로 우선 인터페이스를 생성하였다.

package com.company.object;

public interface People {

    void talking(String sentence);
    void eating(String food);
    void studying(String subject);
    void nowState();
}

class PeopleImpl

People 인터페이스의 구현체를 만들었다. readCount()라는 static 멤버 변수인 count를 출력하는 클래스 자체 메소드도 추가되었다.

package com.company.object;

public class PeopleImpl implements People {

    static private int count = 0;
    private int stamina = 100;
    private int intellect = 50;

    @Override
    public void talking(String sentence) {
        if ( stamina < 0 ){
            System.out.println("에너지가 부족합니다.");
        } else {
            System.out.println(sentence);
            minusStamina(10);
            ++count;
        }
    }

    @Override
    public void eating(String food) {
        System.out.println(food+"를 먹었습니다.");
        addStamina(10);
        ++count;
    }

    @Override
    public void studying(String subject) {
        if ( stamina < 0 ){
            System.out.println("에너지가 부족합니다.");
        }else {
            System.out.println(subject+"를 공부합니다.");
            minusStamina(20);
            addIntellect(10);
            ++count;
        }
    }

    @Override
    public void nowState(){
        System.out.println("스태미너 : "+stamina+"\n지력 : "+intellect);
    }

    public void readCount(){
        System.out.println("행동 횟수 : "+count);
    }

    private void addStamina(int value) {
        stamina += value;
    }

    private void minusStamina(int value) {
        stamina -= value;
    }

    private void addIntellect(int value) {
        intellect += value;
    }
}

enum MethodName

메소드에 따라서 다른 작업을 수행하도록 이를 구별하는 enum 타입을 만들었다.

package com.company.object;

import java.util.Arrays;
import java.util.List;

public enum MethodName {

    TALKING("talking"),
    STUDYING("studying"),
    EATING("eating"),
    NOWSTATE("nowState");

    private String realName;

    MethodName(String realName) {
        this.realName = realName;
    }

    public boolean isEquals(MethodName... methodNames) {
        List<MethodName> methodNameList = Arrays.asList(methodNames);

        return methodNameList.contains(this.realName);
    }

}

class PeopleHandler

위 예제에서는 handler의 생성자를 통해 구현체를 주입해주었지만, 이 예제에서는 특정 구현체에 대한 핸들러로 만들어진 것이므로 멤버 변수 초기화시 바로 생성해주도록 하겠다.

메소드를 호출하기 전/후에 매번 before(), after() 메소드를 수행하게된다. 즉 메소드의 호출 전/후가 joinpoint가 되는 것이다.

original.nowState()는 프록시 객체를 호출할까 원본 객체를 호출할까? 만약 프록시 객체를 호출한다면, 무한 루프에 빠질 것이다.

package com.company.handler;

import com.company.object.MethodName;
import com.company.object.People;
import com.company.object.PeopleImpl;

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

public class PeopleHandler implements InvocationHandler {

    private final People original = new PeopleImpl();

    @Override
    public Object invoke(Object o, Method method, Object[] objects) throws Throwable {

        before();
        method.invoke(original, objects);
        after(method);

        return null;
    }

    private void before(){
        System.out.println(">>>>> 활동을 시작합니다.");
        original.nowState();
    }

    private void after(Method method){
        MethodName target = MethodName.valueOf(method.getName().toUpperCase());

        if(target.isEquals(MethodName.TALKING, MethodName.STUDYING)){
            System.out.println(">> 스태미너를 사용합니다.");
        }else if(target.isEquals(MethodName.EATING)){
            System.out.println(">> 스태미너가 증가합니다.");
        }

        original.nowState();
        System.out.println(">>>>> 활동을 종료합니다.\n\n");
    }
}

Main

public class Main {

    public static void main(String[] args) {
        People people = (People) Proxy.newProxyInstance(People.class.getClassLoader(), new Class[]{People.class}, new PeopleHandler());
        people.talking("Hello world!");
        people.eating("chicken");
        people.studying("math");
        people.nowState();
    }
}

실행해보면 실제로 메소드에서 정의한 부분은 보라색 박스 뿐이지만 프록시에서 해당 메소드를 호출하기 때문에 before, after 메소드에서 작성한 로직이 수행되는 것을 확인 할 수 있다.

또한 핸들러 내에서 호출한 nowState()는 프록시 객체가 아닌 원본 객체를 호출함을 알 수 있다.

그럼 프록시를 여러개 만들어서 수행하면 어떻게 될까?

이름 추가하기

각각 다른 프록시에서 작동하는 것이 확실한지 보기위해 약간의 변경을 가할 것이다. 🤔
PeopleImpl 클래스에 name이라는 멤버변수를 추가하고, 이를 인수로 받는 생성자와 getter 메서드까지 추가해주자.

이전엔 기본 생성자로 핸들러의 멤버 변수에 원본 객체를 할당해주었지만, 각기 다른 이름의 객체를 받기 위해서 PeopleImpl 클래스를 인수로 받는 생성자를 만들었다.
또한 People 인스턴스에는 getName()이라는 메소드가 선언돼있지 않기때문에, 생성자에서 전달받은 인수의 name을 핸들러의 새로운 변수 peopleName에 할당해준다.

Main을 다음처럼 수정하고 실행한다.

public class Main {

    public static void main(String[] args) {
        
        People merry = (People) Proxy.newProxyInstance(People.class.getClassLoader(), new Class[]{People.class}, new PeopleHandler(new PeopleImpl("Merry")));
        People judy = (People) Proxy.newProxyInstance(People.class.getClassLoader(), new Class[]{People.class}, new PeopleHandler(new PeopleImpl("Judy")));

        merry.talking("헬로 월드!");
        judy.eating("사과");
        judy.eating("바나나");
        merry.studying("과학");
        judy.eating("후라이드 치킨");
        merry.studying("수학");
        judy.talking("저녁 시간 이에요!");

    }

}

각 객체마다 고유의 프록시를 가지고 실행되고 있음을 확인 할 수 있다.

전체 코드는 github에서 확인 할 수 있습니다.

Cglib

위 코드의 Main에서 PeopleImpl의 static 변수인 count를 출력하는 고유 메소드인 readCount 호출을 시도해보자.
인터페이스에는 정의되어있지 않은 메소드니 다운캐스팅을 통해서 접근하였다.
자 일단 문법상으론 전혀 문제가 없다. 실행시켜도 잘 될 것만 같다. 🤔
기대와는 다르게 judy.talking(..); 메소드까지 잘 수행된 뒤, 런타임 Exception이 발생한다. 에러 내용은 다음과 같다.

Exception in thread "main" java.lang.ClassCastException: class com.sun.proxy.$Proxy0 cannot be cast to class com.company.object.PeopleImpl (com.sun.proxy.$Proxy0 and com.company.object.PeopleImpl are in unnamed module of loader 'app') at com.company.Main.main(Main.java:24)

프록시 클래스 타입을 PeopleImpl 클래스 타입으로 캐스팅 할 수 없다고 한다.

❗️인터페이스를 통해서만 Proxy를 생성하는 Java dynamic proxy는 인터페이스를 구현하지 않은 순수 클래스를 프록시로 랩핑할 수 없다. 그렇다면 매번 인터페이스를 생성해야하는 것일까?
🙅🏻놉. 순수 클래스의 프록시를 지원하는 Cglib을 사용할 것이다.

What is a CGLIB?

코드 생성 라이브러리(Code Generaor Library), 런타임에 동적으로 자바 클래스의 프록시를 생성해주는 기능을 제공한다.

이는 순수 Java JDK 라이브러리를 이용하는 것이 아니므로 CGLIB이라는 외부 라이브러리를 추가함으로써 사용할 수 있다. 실제 CGLIB의 Enhancer라는 클래스를 바탕으로 프록시를 생성하며, 인터페이스가 아닌 클래스에 대해서 동적 프록시를 생성할 수 있기 때문에 다양한 프로젝트에서 널리 사용되고 있다. (예를 들어, Hibernate는 자바빈 객체에 대한 프록시를 생성할 때 CGLIB를 사용하며, Spring은 프록시 기반 AOP를 구현할 때 CGLIB을 사용하고 있다.)

CGLIB 프록시는 Target Class를 상속받아 생성되기 때문에 개발자는 Proxy 생성을 위해 굳이 Interface를 만드는 수고를 덜 수 있게된다.

하지만, 상속을 이용하는 만큼 final이나 private와 같이 상속된 객체에 오버라이딩을 지원하지 않는 경우 Proxy에서 해당 메소드에 대한 Aspect를 적용할 수 없다.

CGLIB Proxy의 경우 실제 바이트 코드를 조작하여 JDK Dynamic Proxy보다는 퍼포먼스가 상대적으로 빠른 장점이 있다.

Example

그럼, 위에서 Java dynamic proxy로 구현한 내용을 cglib으로 동일하게 만들어보자.

우선 cglib 라이브러리를 추가해준다.

// https://mvnrepository.com/artifact/cglib/cglib
    compile group: 'cglib', name: 'cglib', version: '3.3.0'

설명했듯이 Enhancer 라는 클래스를 이용하여 프록시를 생성한다.
setCallback에 등록한 핸들러로 메소드 전후를 join point로 잡고 추가적인 기능을 수행할 수 있다.

이 상태로 실행하면 아래와 같은 Exception이 발생한다.

❗️Exception in thread "main" java.lang.ClassCastException: class com.company.handler.PeopleHandler cannot be cast to class net.sf.cglib.proxy.Callback (com.company.handler.PeopleHandler and net.sf.cglib.proxy.Callback are in unnamed module of loader 'app') at com.company.Main.main(Main.java:36)

Callback이라는 클래스로 캐스팅이 불가하다고 하는데, 아래와 같은 클래스에서 확장하고 있다고 한다.

PeopleHandler가 InvocationHandler를 상속하고 있지 않나요? 🤔
PeopleHandler는 java.lang.reflect.InvocationHandler 인터페이스를 상속받고 있으며 이 인터페이스는 순수 인터페이스이다.
위의 Callback을 확장하는 InvocationHandler
net.sf.cglib.proxy.InvocationHandler 인터페이스이다.

해당 인터페이스에 들어가면 다음과 같은 안내가 있다.
{@link java.lang.reflect.InvocationHandler} replacement (unavailable under JDK 1.2). This callback type is primarily for use by the {@link Proxy} class but may be used with {@link Enhancer} as well.

java.lang.reflect.InvocationHandler의 대체. (JDK 1.2에서는 사용할 수 없음) 이 콜백 유형은 주로 Proxy 클래스에서 사용되지만 Enhancer와 함께 사용될 수도 있다.

그렇다면, Enhancer에 사용하기 위해 net.sf.cglib.proxy.InvocationHandler를 상속받는 PeopleCglibHandler라는 이름의 핸들러를 만들어보자.

새로운 핸들러를 콜백으로 넣어주고 실행하면, 또 다른 Exception이 발생할 것이다.

❗️Exception in thread "main" java.lang.IllegalArgumentException: Superclass has no null constructors but no arguments were given

슈퍼클래스에 기본생성자가 없는데 인자가 전달되지 않았다고 한다. 기본 생성자를 추가해주자.

public PeopleImpl(){
   this("noNamed");
}

메인 함수를 실행해주면 다음처럼 잘 작동하는 것을 확인할 수 있다.

Enhancer 클래스에도 바로 프록시를 생성해 반환해주는 create 메소드가 있음을 발견하여 이 메소드를 사용하는 것으로 코드를 변경하였다.

// 변경 전
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(PeopleImpl.class);
enhancer.setCallback(new PeopleCglibHandler(new PeopleImpl("Uzu")));
Object object = enhancer.create();
PeopleImpl uzu = (PeopleImpl) object;
uzu.talking("저는 우주입니다!");

// 변경 후
PeopleImpl uzu = (PeopleImpl) Enhancer.create(PeopleImpl.class, new PeopleCglibHandler(new PeopleImpl("Uzu")));
uzu.talking("저는 우주입니다!");

프록시 생성 시 인터페이스(People.class)가 아닌, 구현체인 클래스(PeopleImpl.class)를 전달해주기 때문에 핸들러의 멤버변수를 인터페이스가 아닌 클래스로 변경해주었다.

PeopleImpl 클래스 순수 메소드인 original.readCount()를 핸들러에서 사용할 수 있다.

package com.company.handler;

import com.company.object.MethodName;
import com.company.object.People;
import com.company.object.PeopleImpl;
import net.sf.cglib.proxy.InvocationHandler;

import java.lang.reflect.Method;

public class PeopleCglibHandler implements InvocationHandler {

    private final PeopleImpl original;

    public PeopleCglibHandler(PeopleImpl people){
        this.original = people;
    }

    @Override
    public Object invoke(Object o, Method method, Object[] objects) throws Throwable {

        before();
        method.invoke(original, objects);
        after(method);

        return null;
    }

    private void before() {
        System.out.println(">>>>> "+original.getName()+"의 활동을 시작합니다.");
        original.nowState();
    }

    private void after(Method method) {
        MethodName target = MethodName.valueOf(method.getName().toUpperCase());

        if (target.isEquals(MethodName.TALKING, MethodName.STUDYING)) {
            System.out.println(">> 스태미너를 사용합니다.");
        } else if (target.isEquals(MethodName.EATING)) {
            System.out.println(">> 스태미너가 증가합니다.");
        }

        original.nowState();
        System.out.println(">>>>> "+original.getName()+"의 활동을 종료합니다.");
        original.readCount(); // 순수 메소드 사용
        System.out.println("\n\n");
    }
}

❗️NOTE
지금은 인터페이스의 구현체PeopleImpl재활용하였기 때문에 위와 같은 코드가 Java Dynamic proxy를 사용하는 PeopleHandle에서도 돌아갈 것이다.

PeopleImpl를 People의 구현체가 아닌 순수 클래스로 만든 뒤 코드를 실행해보라.

순수 클래스로 변경PeopleHandle (Java Dynamic proxy 사용)

❗️Exception in thread "main" java.lang.IllegalArgumentException: com.company.object.PeopleImpl is not an interface 라는 Exception이 발생하며 실행이 중단 될 것이다.

반면에 cglib 기반의 프록시는 문제없이 실행된다.

Main 클래스에 아래와 같이 코드를 추가하고 실행시켜보자!

PeopleImpl uzu = (PeopleImpl) Enhancer.create(PeopleImpl.class, new PeopleCglibHandler(new PeopleImpl("Uzu")));
PeopleImpl ruby = (PeopleImpl) Enhancer.create(PeopleImpl.class, new PeopleCglibHandler(new PeopleImpl("Ruby")));

uzu.talking("저는 우주입니다!");
ruby.talking("저는 루비입니다!!");
ruby.studying("Java");
uzu.studying("C++");
ruby.eating("치킨");

프록시를 통해 메소드가 수행된 것과 관계없이 PeopleImpl에 static 변수로 선언된 count값이 두 객체 사이에 공유되고 있음을 확인할 수 있다.

전체 코드는 github에서 확인 할 수 있습니다.

profile
🌱 😈💻 🌱

1개의 댓글

comment-user-thumbnail
2020년 8월 6일

좋은 내용의 게시글 감사드립니다!

답글 달기