4 분 소요

프록시 패턴과 자바에서 지원하는 프록시에 대해 알아보겠습니다.

프록시

프록시는 클라이언트와 타겟 사이에 위치하여 이어주는 인터페이스 역할을 하는 클래스입니다. 타겟에 대한 접근을 제어하며 타겟에 접근하기 전에 무언가를 수행하는 역할을 합니다.

프록시를 이용한 패턴은 프록시 패턴과 데코레이터 패턴이 존재하는데 이 둘은 같은 구조를 가집니다. 사용하고자 하는 목적에 의해 구분되는데 프록시 패턴은 접근 제어의 목적을 가지며, 데코레이터 패턴은 부가 기능을 부여하기 위해 사용됩니다.

예제

먼저 예제로 작성될 코드의 클래스 다이어그램입니다. 클라이언트는 Subject의 메서드를 호출해 어떤 결과를 얻길 바라며, 구현체에 대한 것은 알고 있지 않습니다. ProxySubject는 TargetSubject를 대신하는 프록시 객체이며 먼저 호출되고 기능을 위임하는 역할을 합니다.

@Slf4j
public class ProxyTest {

    @Test
    void proxyTest() {
        TargetSubject target = new TargetSubject();
        ProxySubject proxy = new ProxySubject(target);
        Client client = new Client(proxy);

        client.execute();
    }

    static class Client {
        private Subject subject;

        public Client(Subject subject) {
            this.subject = subject;
        }

        public void execute() {
            subject.doSomething();
        }
    }

    interface Subject {
        void doSomething();
    }

    static class ProxySubject implements Subject {

        private final Subject subject;

        public ProxySubject(Subject subject) {
            this.subject = subject;
        }

        @Override
        public void doSomething() {
            log.info("프록시 객체 호출: {}", this.getClass().getName());

            long start = System.currentTimeMillis();
            subject.doSomething();
            long end = System.currentTimeMillis();

            log.info("메서드 동작 시간 : {}", end - start);
        }
    }

    static class TargetSubject implements Subject {
        @Override
        public void doSomething() {
            log.info("타겟 객체 호출: {}", this.getClass().getName());

            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

간단한 프록시 테스트 코드입니다. 인터페이스인 Subject를 구현한 ProxySubject와 TargetSubject가 있으며 ProxySubject는 실제 로직의 소요 시간을 기록하는 부가적인 로직이 작성되어 있습니다.

여기서 중요한 점은 ProxySubject가 내부적으로 TargetSubject를 참조하고 있으며 doSomething 메서드가 호출될 때 타겟 객체의 메서드를 호출한다는 것입니다.

로그를 확인해보면 프록시 객체가 먼저 호출되고 이어 타겟 객체가 호출되는 것을 확인할 수 있습니다.

로직 소요 시간을 위와 같은 패턴없이 사용한다면 어떨까요?

public void doSomething() {
    log.info("타겟 객체 호출: {}", this.getClass().getName());
    
    long start = System.currentTimeMillis();
    // 비즈니스 로직...
    long end = System.currentTimeMillis();
    log.info("메서드 동작 시간 : {}", end - start);
}

핵심 비즈니스 로직과는 전혀 연관없는 코드를 작성하게 됩니다.

부가적인 기능을 수행하는 코드는 유틸성이 있기 때문에 여러 곳에서 사용될 수 있습니다. 이런 경우에 프록시 패턴을 사용하면 핵심 비즈니스 로직에 영향을 주지 않고, 부가 기능을 추가하여 SRP와 OCP를 지킬 수 있게 됩니다.

하지만 단점으로는 이러한 프록시 패턴을 직접 구현하는 경우 코드의 복잡도가 증가하며 타겟 클래스에 대응하는 프록시 클래스를 매번 정의해야 하는 번거로움과 부가 기능을 부여하는 코드의 중복이 발생하게 됩니다.

이를 해결하는 방법이 두 가지 있습니다.

JDK Dynamic Proxy와 CGLIB

동적 프록시는 프록시 팩토리에 의해 런타임 시점에 동적으로 만들어집니다. 정적 프록시의 단점인 타겟마다 매번 같은 기능을하는 프록시 클래스를 작성하는 문제를 해결할 수 있게 됩니다.

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

// CGLIB
public interface MethodInterceptor extends Callback {
    Object intercept(Object var1, Method var2, Object[] var3, MethodProxy var4)
				throws Throwable;
}

두 기술은 클래스의 메타데이터를 조작할 수 있는 reflection 패키지를 사용합니다.

CGLIB는 인터페이스, 클래스에 관계 없이 적용할 수 있지만 JDK Dynamic Proxy는 인터페이스가 필요합니다.

예제

동적 프록시를 사용하게 되면 InvocationHandler 또는 MethodInterceptor를 통해 부가기능 로직을 작성할 수 있습니다. 핸들러의 메서드가 호출되면 작성된 내부 로직을 수행하고 invoke가 호출되면 타겟의 로직이 실행됩니다.

@Slf4j
public class DynamicProxyTest {

    interface Foo {
        void action();
    }

    class FooImpl implements Foo {

        @Override
        public void action() {
            log.info("Foo 호출");
        }
    }

    interface Bar {
        void action();
    }

    class BarImpl implements Bar {

        @Override
        public void action() {
            log.info("Bar 호출");
        }
    }

    @Test
    void jdkDynamicTest() {
        Foo fooProxy = (Foo) Proxy.newProxyInstance(
                DynamicProxyTest.class.getClassLoader(),
                new Class[]{Foo.class},
                new JdkDynamicHandler(new FooImpl())
        );

        fooProxy.action();

        Bar barProxy = (Bar) Proxy.newProxyInstance(
                DynamicProxyTest.class.getClassLoader(),
                new Class[]{Bar.class},
                new JdkDynamicHandler(new BarImpl())
        );

        barProxy.action();
    }

    class JdkDynamicHandler implements InvocationHandler {

        private final Object target;

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

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            log.info("JDK Dynamic Proxy 실행");

            long start = System.currentTimeMillis();

            Object result = method.invoke(target, args);
 
            long end = System.currentTimeMillis();

            log.info("JDK Dynamic Proxy 종료 {}", end - start);
            return result;
        }
    }

    @Test
    void cglibDynamicTest() {
        Foo fooTarget = new FooImpl();

        Enhancer fooEnhancer = new Enhancer();
        fooEnhancer.setSuperclass(Foo.class);
        fooEnhancer.setCallback(new CGLIBMethodInterceptor(fooTarget));

        Foo fooProxy = (Foo) fooEnhancer.create();
        fooProxy.action();

        Bar barTarget = new BarImpl();

        Enhancer barEnhancer = new Enhancer();
        barEnhancer.setSuperclass(Bar.class);
        barEnhancer.setCallback(new CGLIBMethodInterceptor(barTarget));

        Bar barProxy = (Bar) barEnhancer.create();
        barProxy.action();
    }

    class CGLIBMethodInterceptor implements MethodInterceptor {

        private final Object target;

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

        @Override
        public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
            log.info("CGLIB Dynamic Proxy 실행");

            long start = System.currentTimeMillis();

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

            long end = System.currentTimeMillis();

            log.info("CGLIB Dynamic Proxy 종료 {}", end - start);
            return result;
        }
    }
} 

JDK Dynamic Proxy의 경우는 JDK에 내장된 Proxy 클래스를 사용합니다. Proxy.newProxyInstance는 인터페이스 기반으로 프록시를 생성하기 때문에 인터페이스가 필수입니다.

CGLIB는 바이트코드를 조작하는데 때문에 위에서 언급된 것처럼 클래스와 인터페이스에 관계없이 프록시를 생성할 수 있습니다. 다만 타겟을 상속하여 프록시를 생성하기 때문에 final 메서드, 클래스에 대해선 제약이 있습니다.

정리

  • 프록시는 타겟에 대한 접근 제어와 부가 기능을 제공하는 역할을 하는 객체입니다
  • 프록시를 사용하여 핵심 로직과는 상관없는 코드를 분리할 수 있고, SRP와 OCP를 지킬 수 있게 됩니다
  • 정적인 프록시 생성은 부가 기능을 부여할 모든 객체의 프록시 객체를 작성해야 하는 번거로움이 있고 이를 해결하는 방법으로 reflection을 사용한 동적 프록시 기술이 있습니다
  • 동적 프록시 방법은 JDK에서 지원하는 InvocationHandler를 사용한 JDK Dynamic Proxy와 CGLIB 라이브러리가 제공하는 MethodInterceptor를 사용하는 방법이 있습니다
  • JDK Dynamic Proxy는 인터페이스 기반으로 프록시를 생성하기 때문에 인터페이스가 필수이며 CGLIB는 인터페이스와 클래스 타입 둘 다 사용될 수 있습니다

댓글남기기