dev-sohee 님의 블로그

프록시의 세계: 다이나믹 프록시와 CGLIB의 차이 본문

spring

프록시의 세계: 다이나믹 프록시와 CGLIB의 차이

dev-sohee 2024. 9. 21. 12:44

다이나믹 프록시와 CGLIB는 Java에서 프록시를 생성하는 방법입니다. 이 두 가지는 비슷한 목적을 가졌지만, 그 구현 방식과 사용되는 상황이 조금 다릅니다. 이 글에서는 각각의 특징과 장단점을 자세히 살펴보겠습니다.

출처_https://giron.tistory.com/129

*다이나믹 프록시
*CGLIB(Code Generator Library)

 

먼저 다이나믹 프록시를 알아보기에 앞서, 프록시가 무엇인지부터 짚고 넘어가겠습니다. 프록시(Proxy)는 '대리자'라는 뜻으로, 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 대신 받아주는 역할을 합니다. 프록시가 실제 대상인 것처럼 위장함으로써 이를 사용하는 클라이언트는 구체 클래스를 알 필요가 없어집니다. 또한 실제 타겟 오브젝트는 프록시를 통해 최종적으로 요청을 받아 처리함으로써 자신의 기능에만 집중하고 부가기능(로깅, 캐싱, 권한 검증, 트랜잭션 관리 등)은 프록시에게 위임할 수 있습니다. 아래 그림에서 중심에 있는 구조가 바로 프록시입니다. 

출처_https://velog.io/

 

위 그림은 프록시를 활용하여 타겟 오브젝트가 어떻게 부가기능을 통해 핵심기능을 이용하는지 보여줍니다. 프록시의 사용 목적은 클라이언트가 타겟에 접근하는 방법을 제어하고, 타겟에 부가적인 기능을 부여해주기 위해서입니다. 예를 들어 타겟 오브젝트를 생성하기가 복잡하거나 당장 필요하지는 않지만 타겟 오브젝트에 대한 레퍼런스가 미리 필요할 때 사용할 수 있습니다. 또한 웹 어플리케이션에서 특정 서비스 메서드의 호출을 로그로 기록하고 싶을 때 프록시가 메서드 호출 전후에 로그를 기록하는 부가기능을 추가할 수 있습니다.

 

이렇게 프록시는 기존 코드에 영향을 주지 않으면서 타겟의 기능을 확장하거나 접근 방법을 제어할 수 있는 기술입니다. 하지만 프록시의 이런 유용한 장점에도 불구하고 프 록시를 만드는 일은 꽤나 번거롭기 때문에 많은 개발자들은 프록시를 만들기 꺼려합니다. 프록시를 만들기가 번거로운 이유는 크게 두가지입니다.

  1. 첫 번째 이유는 타겟의 인터페이스를 구현하고 위임하는 코드를 작성하기가 번거롭기 때문입니다. 부가기능이 필요없는 메서드도 구현해서 타겟으로 위임하는 코드를 일일이 만들어줘야 합니다. 
  2. 두 번째는 부가기능 코드가 중복될 가능성이 많다는 점입니다. 예를 들어 트랜잭션은 DB를 사용하는 대부분의 로직에 적용될 필요가 있습니다. 만일 메서드가 많아지고 트랜잭션 적용의 비율이 높아지면 트랜잭션 기능을 제공하는 유사한 코드가 여러 메서드에 중복되어 나타날 것입니다. 

이런 문제를 해결하기 위해 등장한 것이 바로 다이나믹 프록시입니다.

 

#다이나믹 프록시

다이나믹 프록시는 개발자가 직접 일일이 프록시 객체를 생성하는 것이 아닌, 어플리케이션 실행 도중 java.lang.reflect.Proxy 패키지에서 제공해주는 API를 이용하여 동적으로 프록시 인스턴스를 만들어 등록하는 방법입니다. 프록시 패턴의 기본 흐름은 거의 같고, 프록시를 클래스로 직접 만들어서 등록하냐 이미 지원하는 API를 이용하여 동적으로 등록하느냐에 따른 차이만 있을 뿐입니다.

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

// 1. 사용할 인터페이스 정의
interface Hello {
    void sayHello(String name);
}

// 2. 실제 구현 클래스
class HelloImpl implements Hello {
    @Override
    public void sayHello(String name) {
        System.out.println("Hello, " + name + "!");
    }
}

// 3. InvocationHandler 구현
class MyInvocationHandler implements InvocationHandler {
    private final Object target;

    public MyInvocationHandler(Object target) {
        this.target = target; // 실제 객체를 저장
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("Before method: " + method.getName()); // 메서드 호출 전
        Object result = method.invoke(target, args); // 실제 메서드 호출
        System.out.println("After method: " + method.getName()); // 메서드 호출 후
        return result; // 결과 반환
    }
}

// 4. 다이나믹 프록시 사용 예제
public class DynamicProxyExample {
    public static void main(String[] args) {
        Hello realHello = new HelloImpl(); // 실제 객체 생성
        Hello proxyHello = (Hello) Proxy.newProxyInstance(
            realHello.getClass().getClassLoader(),
            new Class<?>[]{Hello.class}, // 프록시할 인터페이스
            new MyInvocationHandler(realHello) // InvocationHandler 전달
        );

        proxyHello.sayHello("World"); // 프록시 객체를 통해 메서드 호출
    }
}

위의 코드는 다이나믹 프록시의 사용 예제 코드입니다. 4번 DynamicProxyExample 클래스에서 실제 객체를 생성한 후, Proxy.newProxyInstance 메서드를 사용하여 프록시 객체를 생성합니다. 이 프록시 객체를 통해 sayHello 메서드를 호출하면, 프록시의 invoke 메서드가 호출되고, 전후 작업을 수행합니다. 다이나믹 프록시를 사용하면 하나의 InvocationHandler에서 모든 로깅을 처리할 수 있으므로, 변경이 용이하고 코드의 일관성을 유지할 수 있습니다. 결론적으로, 다이나믹 프록시는 코드의 재사용성을 높이고, 유지보수를 쉽게 하며, 코드의 구조를 깔끔하게 유지하는 데 큰 도움이 됩니다.

 

이런 다이나믹 프록시에도 한 가지 제약사항은 존재합니다. 다이나믹 프록시에 타켓을 등록할때 클래스 타입이 아닌 인터페이스를 파라미터로 넣어야 된다는 점입니다. 인터페이스를 기반으로 프록시를 동적으로 만들어주기 때문에, 인터페이스가 필수이기 때문입니다. 예제코드에서도 (Hello) Proxy.newProxyInstance에서 Hello.class를 통해 Hello 인터페이스가 파라미터로 전달되고 있습니다. 따라서 인터페이스 없이 클래스만 있는 경우에는 다이나믹  프록시를 적용할 수 없다는 문제점이 발생합니다. 

 

 

#CGLIB

다이나믹 프록시가 java.lang.reflect api를 이용했다면 CGLIB는 CGLIB(Code Generation Library)라는 라이브러리를 사용하여 클래스 기반의 프록시를 생성합니다. CGLIB는 다이나믹 프록시와 달리 인터페이스가 아닌 클래스를 대상으로 바이트코드를 조작해서 런타임과 클래스로딩 시점 모두에 클래스를 동적으로 생성하고 수정할 수 있는 기능을 제공하는 라이브러리입니다. 

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

// 1. 실제 객체 클래스
class Hello {
    public void sayHello(String name) {
        System.out.println("Hello, " + name + "!");
    }
}

// 2. MethodInterceptor 구현
class MyMethodInterceptor implements MethodInterceptor {
    @Override
    public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("Before method: " + method.getName()); // 메서드 호출 전
        Object result = proxy.invokeSuper(obj, args); // 실제 메서드 호출
        System.out.println("After method: " + method.getName()); // 메서드 호출 후
        return result; // 결과 반환
    }
}

// 3. CGLIB 프록시 사용 예제
public class CGLIBExample {
    public static void main(String[] args) {
        Enhancer enhancer = new Enhancer(); // Enhancer 객체 생성
        enhancer.setSuperclass(Hello.class); // 프록시할 클래스 설정
        enhancer.setCallback(new MyMethodInterceptor()); // MethodInterceptor 설정

        Hello proxyHello = (Hello) enhancer.create(); // 프록시 객체 생성
        proxyHello.sayHello("World"); // 프록시 객체를 통해 메서드 호출
    }
}

위의 예제코드에서 보면, CGLIB는 클래스 기반이므로 인터페이스가 없어도 Hello 클래스에 대해 직접 프록시를 생성할 수 있습니다. 또한 intercept 메서드를 통해 원본 클래스의 메서드를 가로챌 수 있으며, invokeSuper를 통해 원본 메서드와 사용자 정의 로직을 결합할 수 있습니다. 이러한 방식으로 CGLIB는 인터페이스 없이도 동적 프록시를 생성하고, 다양한 기능을 쉽게 추가할 수 있습니다.

 

이렇게 보면 인터페이스 기반일 때는 다이나믹 프록시를 사용하고, 클래스 기반일 때는 CGLIB를 사용하면 될 것 같지만, 이 라이브러리에도 제약사항이 존재합니다. 우선 CGLIB는 기본적으로 클래스 상속(extends)을 통해 프록시 구현이 되기 때문에, 타겟 클래스가 상속이 불가능할 때는 당연히 프록시 등록이 불가능합니다. 또한 메서드에 final 키워드가 붙게되면 그 메서드를 오버라이딩하여 사용 할수 없게 되어 결과적으로 프록시 메서드 로직이 작동되지 않습니다. 즉, CGLIB를 사용하여 프록시를 생성하기 위해서는 타겟 객체가 상속과 관련하여 제약이 없어야 합니다.

 

위의 내용들을 정리하여, 다이나믹 프록시와 CGLIB를 비교하면 다음과 같습니다.

특성 다이나믹 프록시 CGLIB
프록시 타입 인터페이스 기반 클래스 기반
상속 여부 없음 클래스를 상속하여 구현
사용 시 특징 인터페이스가 반드시 필요 클래스에 대해서도 가능
코드 복잡성 인터페이스만을 다루기 때문에 간단함 바이트코드를 조작하는 과정에서 비교적 복잡할 수 있음


다이나믹 프록시에서 메서드를 호출할 때는 항상 인터페이스의 메서드를 리플렉션을 통해 호출해야 하는 반면, CGLIB는 프록시 객체가 원본 클래스의 메서드를 직접 호출하는 구조이므로, 이론적으로 CGLIB의 성능이 더 빠를 수 있습니다. 하지만 다이나믹 프록시는 코드가 더 간단하고 명확하게 구현될 수 있는 장점이 있기 때문에, 여러 측면을 고려하여 성능을 최적화하는 방법을 선택해야 할 것입니다.