다이나믹 프록시와 cglib에 대해 알아보자

Jake Seo·2021년 7월 2일
0

자바 잡지식

목록 보기
9/17

프록시란?

프록시의 사전적 의미는 대리인이라는 뜻이다. 프로그래밍에서 말하는 프록시는 주로 디자인 패턴이다. 이미 존재하는 클래스 (타겟)어떤 기능을 추가하거나 수정 하고 싶을 때, 우리는 프록시 오브젝트를 만들어서 쓴다. 자바 프록시 클래스는 주로 원본 클래스를 상속하여, 원본 클래스에 있는 메소드들이 동일하게 존재한다. 프록시는 원본 오브젝트를 다룰 수 있고, 원본 오브젝트의 메소드 호출이 가능하다.

프록시 클래스를 이용하면 아래와 같은 작업을 편리하게 할 수 있다.

  • 메소드 시작 및 종료 시에 로깅
  • 인자에 대한 추가적인 검증
  • 원본 클래스의 행위 모방
  • 비용이 많이 나가는 리소스에 대한 지연 접근 구현

위와 같은 일들을 클래스의 원본 코드 변경 없이 할 수 있다.

실제 애플리케이션에서 프록시 클래스는 직접적으로 기능을 구현하진 않는다. 단일 책임 원칙을 따라, 프록시는 오직 proxying이라 불리는 행위만 수행하고, 실제 동작 수정은 handler에서 구현된다. 원본 오브젝트를 대신하여 프록시 오브젝트가 호출될 때, 프록시는 원본 메소드를 호출해야 하는지 핸들러를 호출해야 하는지에 대해 결정한다. 핸들러는 아마 이러한 작업을 수행할 것이고 아마 원본 메소드를 호출할 것이다.

프록시 패턴이 오직 런타임에 프록시 오브젝트와 프록시 클래스가 만들어졌을 때에만 적용되는 것은 아니지만, 자바에서는 이러한 토픽에 관심이 많다. 이번 글에서는 이러한 프록시들에 대해 살펴볼 것이다.

프록시 패턴은 말 그대로 패턴이기 때문에 자바에 존재하는 프록시 클래스를 사용해야만 구현할 수 있는 것이 아니다.

Reflection 클래스, 바이트 코드 조작, 생성된 자바 코드를 다이나믹하게 컴파일하는 것들의 사용이 필요하기 때문에, 이 토픽은 난이도가 있다. 런타임에 아직 바이트 코드로 이용 가능하지 않은 새로운 클래스를 갖기 위해서는 바이트 코드의 생성이 필요하고 바이트 코드를 로드할 수 있는 클래스 로더가 필요하다. 바이트코드를 생성하기 위해서는 cglib, bytebuddy 또는 빌트인 자바 컴파일러를 사용할 수 있다.

프록시 클래스와 호출할 핸들러를 생각해보면, 책임을 분리하는 것이 왜 중요한지 알 수 있다. 프록시 클래스는 런타임 중에 생성되지만, 프록시 클래스에 의해 호출되는 핸들러는 일반 소스코드 사이에서 코딩이 되고 전체 프로그램을 따라 해당 코드가 컴파일된다.

순수 자바 코드를 이용한 프록시 구현

일반 자바코드를 이용하면 이미 작성된 클래스의 인터페이스를 이용해 새로운 클래스를 만들고, 이미 작성된 클래스를 새로운 클래스의 멤버로 만들어서 추가적인 동작을 덧붙이는 방식으로 프록시를 만들어낼 수 있다.

코드 예제

public class JavaProxy {
    
    interface Introduce {
        void printWhoYouAre();
    }
    
    static class IntroduceImpl implements Introduce {
        public void printWhoYouAre() {
            System.out.println("I am Jake");
        }
    }

    static class IntroduceHelloAndBye implements Introduce {
        Introduce introduce = new IntroduceImpl();

        public void hello() {
            System.out.println("hello");
        }

        public void bye() {
            System.out.println("bye");
        }

        @Override
        public void printWhoYouAre() {
            hello();
            introduce.printWhoYouAre();
            bye();
        }
    }

    @Test
    public void test() {
        Introduce introduce = new IntroduceHelloAndBye();
        introduce.printWhoYouAre();
    }
}

위의 예제는 Introduce라는 인터페이스가 존재하고 해당 인터페이스를 구현한 InterfaceImpl이 있다. 그런데, InterfaceImpl의 동작이 시작할 때 hello를 끝날 때 bye를 출력하는 동작을 프록시를 이용해 추가해본 것이다.

클라이언트의 입장에서는 Introduce라는 인터페이스 타입으로 접근을 하기 때문에 클라이언트 입장에서는 프록시를 사용하고 있는지 알 수도 알 필요도 없다. 즉, 클라이언트가 타겟을 호출하는 방법을 바꾸지 않아도 된다는 뜻이다.

위와 같이 타겟의 기능을 확장 혹은 타겟에 대한 접근 제어를 위한 목적으로 프록시를 사용한다. 타겟의 기능을 확장하기 위해 프록시를 사용하는 케이스를 데코레이터 패턴이라고 한다.

InputStream is = new BufferedInputStream(new FileInputStream("a.txt")); 와 같은 코드도 데코레이터 패턴의 일종이다.

순수 자바 코드를 이용한 프록시 구현의 문제점

  • 반드시 인터페이스를 작성해야 한다.
  • 다양한 메소드가 존재할 때, 모든 메소드에 적용시키려면 코드의 중복이 발생한다.
    • 클래스 하나의 모든 메소드에 적용할 때, 실수로 적용을 안할 수도 있고, 다른 클래스에 적용하려고 하면 또 모든 메소드에 다 앞뒤로 적용시키고 싶은 것들을 작성해야 한다.

reflect.Proxy를 이용해 프록시를 적용하는 방법

프록시를 적용하는 가장 쉬운 방법 중 하나는 JDK의 일부인 java.lang.reflect.Proxy 클래스를 이용하는 것이다. 이 클래스는 프록시 클래스를 만들거나 즉시 인스턴스를 만들 수 있다. 자바 빌트인 프록시의 사용방법은 쉽다. java.lang.InvocationHandler만 구현하면 된다. 그것만으로 프록시 오브젝트는 호출이 가능하다. InvocationHandler 인터페이스는 완전 간단하다. invoke()라는 오직 하나의 메소드만 갖고 있다. invoke()가 호출될 때, 인자는 프록시화된 원본 오브젝트, 호출된 메소드 (Relection.Method 형태) 그리고 원본 인자의 오브젝트 배열을 갖고 있다. 아래의 샘플 코드가 용례이다.

코드 예제

package proxy_test;

import java.lang.reflect.InvocationHandler;
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 Throwable {
            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("Hello");
    }
}

만일 핸들러가 원본 오브젝트에 있는 원본 메소드를 호출하길 원한다면, 해당 메소드에 접근 권한을 갖고 있어야 한다. 자바 프록시 구현에 의해 이러한 것이 제공되지 않는다. 코드 내에서 이 인자를 핸들러 인스턴스로 넘겨야 한다. (invocation handler에 인자로 넘겨진 프록시로 명명된 오브젝트가 있다는 것을 기억해두자. 이 프록시 오브젝트는 자바 Reflection이 동적으로 생성한 것이고, 우리가 proxy 하길 원하는 오브젝트는 아니다.) 이렇게 하면 각 오리지널 클래스에 대한 핸들러 오브젝트를 사용하는 것과 만일 호출해야 하는 어떠한 메소드가 있는 경우에 호출할 원본 객체를 알고 있는 몇몇 공유 객체를 완전히 자유롭게 사용할 수 있다.

특별한 경우, invocation handler와 원본 오브젝트를 가지고 있지 않은 인터페이스의 프록시를 만들 수 있다. 더 나아가, 소스코드에서 인터페이스를 구현할 어떠한 클래스를 가질 필요도 없다. 동적으로 생성된 프록시 클래스가 인터페이스를 구현할 것이다.

만일 프록시하길 원하는 클래스가 인터페이스를 구현한 클래스가 아니라면 어떻게 해야 할까? 이 경우에는, 다른 프록시 구현을 사용해야 한다.

CgLib이란?

cglib은 강력한 프록시 생성 라이브러리이다. Code Generation Library의 축약어이다. 바이트 코드를 조작하며, Hibernate, Spring과 같은 프레임워크에서 사용된다. 바이트 코드 조작이 프로그램 컴파일 이후에도 클래스를 만들거나 조작하는 것을 가능하게 한다.

자바의 클래스는 런타임에 동적으로 로드된다. cglib은 자바 언어의 이러한 특성을 이용하여 동작하는 자바프로그램에 새로운 클래스를 만드는 것을 가능하게 한다.

하이버네이트는 다이나믹 프록시 생성에 cglib을 이용한다. 이를테면, 데이터베이스에 저장된 전체 오브젝트를 반환하진 않지만 요청에 의해 데이터베이스로부터 지연 전략을 이용하여 값을 로드하는 저장된 클래스의 조작된 버전을 반환한다.

Mokito와 같은 인기있는 Mock 프레임워크도 메소드를 mock하는데 cglib을 사용한다. mock은 빈 구현(empty implementation) 으로 대체된 메소드들이 들어간 조작된 클래스이다.

예제 코드

public class CgLibProxy {
    static class Introduce {
        public void printWhoYouAre(String name) {
            System.out.println("I am " + name);
        }
    }

    static class Interceptor implements MethodInterceptor {
        public void printHello() {
            System.out.println("hello");
        }

        public void printBye() {
            System.out.println("bye");
        }
        @Override
        public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
            printHello();
            Object result = methodProxy.invokeSuper(o, objects);
            printBye();
            return result;
        }
    }

    public static void main(String[] args) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(Introduce.class);
        enhancer.setCallback(new Interceptor());
        Introduce proxy = (Introduce) enhancer.create();
        proxy.printWhoYouAre("jake");
        proxy.printWhoYouAre("jim");
        proxy.printWhoYouAre("ash");
    }
}

스프링에서의 용례

스프링과 같은 프레임워크 영역에서는 프록시를 활용하는 일이 많다. 스프링에서 프록시 객체를 만들 때 2가지 방법을 사용하는데

  • 인터페이스를 구현한 클래스 프록시를 만드는 경우에는 java.reflection.Proxy를 이용하여 다이나믹 프록시 기술을 사용한다.
  • 인터페이스를 구현하지 않은 클래스 프록시를 만드는 경우에는 cglib을 이용하여 바이트코드를 조작하는 프록시 기술을 사용한다.

CGLIB을 이용하는 상황에서 프록시를 만들 때는 final을 붙이면 에러가 나기 때문에 주의해야 한다. @Bean 메소드에 final을 붙이지 말고, 클래스에도 final을 붙이면 안된다. 단, 인터페이스를 구현한 클래스의 경우 java.reflection.Proxy를 이용하기 때문에 무관하다.

레퍼런스

https://dzone.com/articles/java-dynamic-proxy
https://live-everyday.tistory.com/216
https://www.baeldung.com/cglib

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글