Design Pattern

[Design Pattern] Proxy(프록시)

pseudocoder_ 2024. 5. 14. 23:35
728x90

프록시(Proxy)란

 

프록시는 사전적 의미로 대리인, 대리권한 등의 뜻을 지닌다. 즉 무언가를 대신해서 작업을 하거나, 중계를 한다는 뜻인데 이게 프로그래밍에서는 어떻게 적용되는 것일까?

 

정보처리기사를 공부해본 사람이라면 아마 한 번쯤 훑고 넘어간 개념일 것이다. 프로그래밍에서의 Proxy는 디자인 패턴의 GoF(Gang of Four)의 생성 패턴, 구조 패턴, 행동 패턴 중 구조 패턴에 속하는 디자인 패턴이다. 

 

프록시의 역할은 클라이언트로부터 메서드 호출과 같은 요청이 들어오면 해당 요청을 대신해서 처리하는 역할을 한다. 실제 Subject 객체는 프록시를 통해서 요청을 받아서 처리를 하게 된다. 이렇게 말로 설명해서는 어려우니 예시를 통해 알아보자.

 

손님이 웨이터를 통해 주문을 하고 웨이터는 주문을 받아 요리사에게 전달하는 상황이라고 가정해보자.

여기서 손님은 Client, 웨이터는 손님의 주문을 받는 Proxy 객체, 요리사는 요리를 하는 실제 Subject 객체가 되는 것이다. 즉, 손님이 요리사에게 무작정 가서 "저 이 메뉴 주문할게요!"라며 요청하는 것이 아니라, 웨이터를 통해 자신의 주문을 전달하므로써 요리사는 온전히 요리라는 하나의 업무에만 집중할 수 있게 되는 것이다.

 

프록시 패턴에서 수행하려고 하는 기능(Operation)은 인터페이스 내에 정의되며 클라이언트의 요청을 중계하기 위한 Proxy 객체, 요청의 최종 수행을 위한 대상 객체(Subject)는 기능의 정의되어 있는 인터페이스를 구현하는 구현체 클래스이다.

 

앞서 살펴본 예시를 코드화해서 살펴보면 아래와 같다.

 

1. 주문(Order Interface)

package proxyExample;

// 주문관련 기능 인터페이스
public interface Order {
    void order();
    void changeOrder();
    void cancelOrder();
}

 

2. 요리사(Subject implements Order)

package proxyExample;

public class Chef implements Order{

    @Override
    public void order() {
        System.out.println("요리사가 요리를 시작합니다.");
    }

    @Override
    public void changeOrder() {
        System.out.println("요리사가 요리를 변경합니다.");
    }

    @Override
    public void cancelOrder() {
        System.out.println("요리사가 요리를 취소합니다.");
    }
}

 

3. 웨이터(Proxy Class implements Order)

package proxyExample;

// 웨이터의 역할을 한다.
public class OrderProxy implements Order {

    private final Order chef;

    public OrderProxy(Chef chef) {
        this.chef = chef;
    }
    @Override
    public void order() {
        System.out.println("주문을 요리사에게 전달합니다.");
        chef.order();
    }

    @Override
    public void changeOrder() {
        System.out.println("주문 변경을 요리사에게 전달합니다.");
        chef.changeOrder();
    }

    @Override
    public void cancelOrder() {
        System.out.println("주문 취소를 요리사에게 전달합니다.");
        chef.cancelOrder();
    }
}

 

4. 손님(Client)

package proxyExample;

public class Client {
    public static void main(String[] args) {
        Order order = new OrderProxy(new Chef());
        order.order();
        order.changeOrder();
        order.cancelOrder();
    }
}

 

왜 사용하는가?

프록시 패턴을 사용했을 때의 장점에는 크게 두 가지가 있다.

1. SRP(Single Responsibility Principle: 단일 설계 법칙)를 지킬 수 있다.

2. OCP(Open Closed Principle: 개방-폐쇄 원칙)를 지킬 수 있다.

3. 보안성 및 유연성 향상에 도움이 된다.

4. 캐싱을 통한 부하를 줄일 수 있다.

더보기

SRP(Single Responsibility Principle): 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다. 클래스가 제공하는 모든 기능은 이 책임과 주의 깊게 부합해야 한다.

프록시 객체의 역할을 하는 웨이터가 없어진다면 어떻게 될까? 요리에만 집중해야 할 요리사가 주문까지 받으면서 두 가지 역할을 수행하게 된다. 이는 객체지향 설계 SOLID 원칙 중 하나인 SRP(Single Responsibility Principle: 단일 설계 법칙)에 위배된다.

 

더보기

OCP(Open Closed Principle): 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다는 프로그래밍 원칙이다. 기존의 코드를 변경하지 않고도 새로운 기능을 추가할 수 있어야 한다.

또한 프록시 패턴을 사용했을 때의 다른 장점 하나는 기존 코드를 건드리고 수정하지 않고 부가 기능을 추가할 수 있다. 새로운 기능이 추가되더라도 핵심 기능을 수행하는 대상 객체(Subject)를 수정할 필요 없이 새로운 프록시 객체를 생성하거나 인터페이스에 메서드만 추가하면 끝이다.

 

그 밖에도 추가적인 장점을 보자면...

 

1. 프록시 객체를 통해 대상 객체에 접근하기 때문에 JPA의 Lazy Loading(지연 로딩)과 같이 클라이언트가 타겟에 접근하는 방법을 제어하기 위한 접근 제어에도 활용이 가능하다.

2. 원본 객체에 직접 접근하지 않기 때문에 보안성이 향상된다. 프록시 객체를 통한 원본 객체 접근 권한 제한이 가능하며, 접근 로그를 남기는 등의 보안 조치를 취할 수 있다.

3. 원본객체에 간접적으로 접근하기 때문에 객체 간의 결합도를 줄여 유연성을 향상시킬 수 있다.

4. 기존의 리소스가 무거울 경우, 캐싱과정을 통해 부하를 줄일 수 있다.

 

프록시의 단점

코드가 많아지고 복잡도가 증가한다. 기능 추가를 위해 인터페이스를 생성하고, 또 인터페이스 구현체인 프록시 객체를 생성해서 각 기능들을 오버라이딩을 통해 재정의를 해야한다. 또한 프록시 객체의 각 메서드마다 같은 기능을 수행하는 경우가 있을 경우, 중복 코드 존재하게 된다. 코드가 많아질 수 밖에 없다..

 

이러한 단점을 보완하기 위해 자바에는 JDK 다이나믹 프록시라는 것이 존재한다.

 

JDK Dynamic Proxy

JDK Dynamic Proxy는 자바에서 기본적으로 제공되는 기능인 Reflection을 이용하여 기존에 미리 생성했던 프록시 객체를 동적으로 런타임 시점에 생성하여 활용한다. 따라서 프록시 객체 클래스파일을 생성할 필요가 없다! 🤓Reflection 관련 게시글 보러가기

프록시 객체 역할을 하던 OrderProxy가 없어졌다.

 

프록시 클래스 없이 어떻게 동적으로 프록시 객체를 다루는지 알아보자.

package proxy._03_dynamic;

import java.lang.reflect.Proxy;

public class DynamicProxyClient {
    public static void main(String[] args) {
        DynamicProxyClient dynamicProxyClient = new DynamicProxyClient();
        dynamicProxyClient.dynamicProxy();
    }

    // 다이나믹 프록시 생성
    private void dynamicProxy() {
        // 프록시 객체를 생성(OrderService가 target으로 들어감)
        Order orderServiceProxy = getOrderServiceProxy(new OrderService());
        orderServiceProxy.order();
        orderServiceProxy.changeOrder();
    }

    /*
    아래 코드가 실행이 될 때 프록시 인스턴스가 동적으로 생성된다.
    첫 번째 parameter: 클래스 로더
    두 번째 parameter: 동적으로 생성되는 해당 프록시가 구현해야 할 인터페이스 타입
    세 번째 parameter: InvocationHandler를 구현한 객체(람다식으로 구현)

    target은 기존 프록시 객체가 참조하는 Subject(대상 객체)를 타겟으로 넘겨줘야한다.
     */
    private Order getOrderServiceProxy(Order target) {
        return (Order) Proxy.newProxyInstance(
                this.getClass().getClassLoader(), // 첫 번째 parameter
                new Class[]{Order.class}, // 두 번째 parameter
                (proxy, method, args) -> { // 세 번째 parameter(InvocationHandler 타입의 parameter)
                    // 메서드에 따라 다른 기능을 하도록 구현한다.
                    if (method.getName().equals("order")) {
                        System.out.println("요리사에게 주문 메뉴를 전달합니다.");
                    } else if (method.getName().equals("changeOrder")) {
                        System.out.println("주문 변경사항을 요리사에게 전달합니다.");
                    }
                    method.invoke(target, args); // target의 method를 호출한다. args는 method의 parameter이다.
                    return null;
                }
        );
    }
}

 

Reflection API의 Proxy.newProxyInstance()를 통해 프록시 객체를 동적으로 생성하고 getClass(), getName()을 이용하여 런타임 시점에서 호출되는 메서드와 클래스를 확인한다.

 

세 번째 parameter인 InvacationHandler에 메서드에 따라 구현하고 싶은 기능을 추가할 수 있다.

 

InvocationHandler

람다식으로 작성된 invocationHandler를 anonymous class로 변경하면 아래와 같은 코드로 변환된다. 변환된 코드를 보면 invoke() 라는 메서드를 오버라이딩하는 것을 확인할 수 있다.

private Order getOrderServiceProxy(Order target) {
        return (Order) Proxy.newProxyInstance(
                this.getClass().getClassLoader(), // 첫 번째 parameter
                new Class[]{Order.class}, // 두 번째 parameter
                new InvocationHandler() { // 세 번째 parameter(InvocationHandler 타입의 parameter)
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        // 메서드에 따라 다른 기능을 하도록 구현한다.
                        if (method.getName().equals("order")) {
                            System.out.println("요리사에게 주문 메뉴를 전달합니다.");
                        } else if (method.getName().equals("changeOrder")) {
                            System.out.println("주문 변경사항을 요리사에게 전달합니다.");
                        }
                        method.invoke(target, args); // target의 method를 호출한다. args는 method의 parameter이다.
                        return null;
                    }
                }
        );
    }

 

문서를 통해 InvocationHandler가 뭔지 확인해보자.

InvocationHandler는 모든 프록시 인스턴스가 가지는 invocation handler 구현체의 인터페이스이다. 프록시 인스턴스의 메서드가 호출되면 invocation handler의 invoke 메서드를 통해 작성한 코드가 실행되는 것으로 보인다.
proxy: 메서드를 수행한 프록시 인스턴스 / method: 프록시 인스턴스에서 호출한 메서드에 대응되는 인터페이스의 메서드 / args: 메서드 호출 시에 입력한 인자들이 저장되어 있는 배열 형태의 객체

 

즉 InvocationHandler의 invoke() 메서드가 호출된 메서드, 프록시 인스턴스, 넘겨준 파라미터를 모두 확인하는 역할을 하는 것을 알 수 있다.

 

Dynamic Proxy의 단점

Reflection API 자체가 느리기 때문에 Reflection API를 사용하는 JDK Dynamic Proxy 또한 느릴 수 밖에 없다. 속도와 편의성의 밸런스를 잘 맞춰서 사용하도록 하자!

 

References

https://www.youtube.com/watch?v=8zExKZxSaHE&ab_channel=%EB%B0%B1%EA%B8%B0%EC%84%A0

 

https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%ED%94%84%EB%A1%9D%EC%8B%9CProxy-%ED%8C%A8%ED%84%B4-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EB%B0%B0%EC%9B%8C%EB%B3%B4%EC%9E%90#dynamic_proxy

 

💠 프록시(Proxy) 패턴 - 완벽 마스터하기

Proxy Pattern 프록시 패턴(Proxy Pattern)은 대상 원본 객체를 대리하여 대신 처리하게 함으로써 로직의 흐름을 제어하는 행동 패턴이다. 프록시(Proxy)의 사전적인 의미는 '대리인'이라는 뜻이다. 즉, 누

inpa.tistory.com

https://www.youtube.com/watch?v=MFckVKrJLRQ&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC

https://www.baeldung.com/java-proxy-pattern

728x90