본문 바로가기
Design Pattern

[Design Pattern] 프록시 패턴(Proxy Pattern)

by jinjin98 2022. 10. 3.

 

클라이언트( Client )와 서버( Server )라고 하면 개발자들은 보통 서버 컴퓨터를 생각합니다.

사실 클라이언트와 서버의 개념은 상당히 넓게 사용됩니다.

클라이언트는 의뢰인이라는 뜻이고, 서버는 '서비스나 상품을 제공하는 사람이나 물건'을 뜻합니다.

따라서 클라이언트와 서버의 기본 개념을 정의하면 클라이언트는 서버에 필요한 것을 요청하고,

서버는 클라이언트의 요청을 처리하는 것입니다.

이 개념을 객체에 도입하면, 요청하는 객체는 클라이언트가 되고, 요청을 처리하는 객체는 서버가 됩니다.

 

 

클라이언트와 서버 개념에서 일반적으로 클라이언트가 서버를 직접 호출하고, 처리 결과를 직접 받습니다.

이것을 직접 호출이라고 합니다.

 

 

그런데 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서

대신 간접적으로 서버에 요청할 수 있습니다. 이것을 간접 호출이라고 합니다.

여기서 대신 서버에 요청하는 대리자를 영어로 프록시(Proxy) 라고 합니다.

간접 호출을 하면 대리자가 중간에서 접근 제어, 캐싱, 부가 기능 추가 등 여러가지 일을 할 수 있습니다.

객체에서 프록시가 되려면, 클라이언트는 서버에게 요청을 한 것인지, 프록시에게 요청을 한 것인지

알지 못해야 합니다. 그러므로 서버와 프록시는 같은 인터페이스를 사용해야 합니다.

이렇게 하면 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도

클라이언트 코드를 변경하지 않고 동작할 수 있습니다.

 

 

클래스 의존관계를 보면 클라이언트는 ServerInterface 에만 의존합니다.

그리고 서버와 프록시가 같은 인터페이스를 사용합니다. 따라서 DI(의존 주입) 을 사용해서 대체가 가능합니다.

 

 

런타임 객체 의존 관계를 보면 런타임(애플리케이션 실행 시점)에 클라이언트 객체에 DI를 사용해서 Client -> Server 에서

Client -> Proxy 로 객체 의존관계를 변경해도 클라이언트 코드를 전혀 변경하지 않아도 됩니다.

클라이언트 입장에서는 변경 사실 조차 알 수 없습니다.

DI를 사용하면 클라이언트 코드의 변경 없이 유연하게 프록시를 주입할 수 있습니다.

 

 프록시의 주요 기능

 

프록시를 통해서 할 수 있는 일은 크게 2가지로 구분할 수 있습니다.

 

1. 접근 제어

권한에 따른 접근 차단

캐싱

지연 로딩

 

2. 부가 기능 추가

원래 서버가 제공하는 기능에 더해서 부가 기능을 수행합니다.

예) 요청 값이나, 응답 값을 중간에에서 변형합니다

예) 실행 시간을 측정해서 추가 로그를 남깁니다.

 

프록시 객체가 중간에 있으면 크게 접근 제어와 부가 기능 추가를 수행할 수 있습니다.

 

GOF 디자인 패턴

 

둘다 프록시를 사용하는 방법이지만 GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라서

프록시 패턴과 데코레이터 패턴으로 구분할 수 있습니다.

프록시 패턴: 접근 제어가 목적

데코레이터 패턴: 새로운 기능 추가가 목적

둘 다 프록시를 사용하지만, 의도가 다르다는 점이 핵심합니다.

용어가 프록시 패턴이라고 해서 이 패턴만 프록시를 사용하는 것은 아닙니다. 데코레이터 패턴도 프록시를 사용합니다.

여기서는 예제를 통해 프록시를 알아보겠습니다.

 

public interface IService {
    String runSomething();
}

 

public class Proxy implements IService{
    Server server;

    @Override
    public String runSomething() {
        System.out.println("프록시 호출");

        server = new Server();

        return server.runSomething();
    }
}

 

public class Server implements IService{

    @Override
    public String runSomething() {
        return"서비스 호출";
    }
}

 

public class Client {
    public static void main(String[] args) {
        IService proxy = new Proxy();
        System.out.println(proxy.runSomething());
    }
}

 

 

IService 라는 인터페이스를 생성하고 Proxy 와 Server 클래스 둘 다 IService 를 구현했습니다.

Client 에서 Proxy 객체를 생성해 호출해주면 Proxy 안에서 Client 가 원래 호출하려던 Server 객체를 생성해

Client 대신  Server 를 호출해줍니다.

 

이번에는 캐시를 이용한 프록시 예제를 살펴보겠습니다.

 

public interface Subject {
    String operation();
}

 

@Slf4j
public class CacheProxy implements Subject {

    private Subject target;

    private String cacheValue;

    public CacheProxy(Subject target) {
        this.target = target;
    }

    @Override
    public String operation() {

        log.info("프록시 호출");

        if (cacheValue == null) {
            cacheValue = target.operation();
        }

        return cacheValue;
    }
}

 

@Slf4j
public class RealSubject implements Subject {

    @Override
    public String operation() {
        log.info("실제 객체 호출");

        sleep(1000);

        return "data";
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

public class ProxyPatternClient {

    private Subject subject;

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

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

 

@Test
void cacheProxyTest() {
    RealSubject realSubject = new RealSubject();

    CacheProxy cacheProxy = new CacheProxy(realSubject);

    ProxyPatternClient client = new ProxyPatternClient(cacheProxy);

    client.execute();
    client.execute();
    client.execute();
}

 

 

위 예제와 다르게 Lombok 과 Junit 을 사용해 예제를 만들었습니다.

Subject 라는 인터페이스를 생성해 프록시 역할을 하는 CacheProxy 클래스와

실제 호출하려는 객체인 RealSubject 클래스가 구현하도록 했습니다.

CacheProxy 에서는 RealSubject 객체를 생성자로 의존 주입 받아 필드에 담아둡니다.

cacheValue 에는 값이 없으면 실제 객체( target )를 호출해서 값을 구합니다.

그리고 구한 값을 cacheValue 에 저장하고 반환합니다.

만약 cacheValue 에 값이 있으면 실제 객체를 호출하지 않고, 캐시 값을 그대로 반환합니다.

따라서 처음 조회 이후에는 캐시( cacheValue ) 에서 매우 빠르게 데이터를 조회할 수 있습니다.

데이터가 한번 조회하면 변하지 않는 데이터라면 어딘가에 보관해두고

이미 조회한 데이터를 사용하기 때문에 성능상 매우 효율적입니다.

테스트 코드에서는 realSubject 와 cacheProxy 를 생성하고 둘을 연결합니다.

결과적으로 cacheProxy 가 realSubject 를 참조하는 런타임 객체 의존관계가 완성됩니다.

그리고 마지막으로 client 에 realSubject 가 아닌 cacheProxy 를 주입합니다.

이 과정을 통해서 client -> cacheProxy -> realSubject 런타임 객체 의존 관계가 완성됩니다.

캐시 프록시를 도입하지 않는다면 RealSubject 의 Thread.sleep() 메서드로 인해 총 3초가 걸리지만

캐시 프록시를 도입해서 최초에 한 번만 1초가 걸리고 이후에는 거의 즉시 반환하게 됩니다.

 

이 포스팅은 자바 객체 지향의 원리와 이해의 내용을 참고하여 작성했습니다.

댓글