본문 바로가기

study/design pattern

[디자인패턴][구조패턴] 프록시 Proxy - C++

728x90

[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.


Proxy pattern

Decorator 패턴은 객체의 기능을 수정/확장하는 여러 다른 방법들을 제시해준다. Proxy 패턴도 객체의 기능을 수정/확장한다는 목적은 비슷하지만 기존 API 사용 방식을 정확히 동일하게 유지하면서 내부 동작만 다르게 한다는 점이 다르다.

Proxy는 API를 일관되게 유지하기 위한 것은 아니다. 같은 API에 대해서 서로 다른 종류의 서로 다른 목적의 완전히 다른 프록시들이 여러 개발자에 의해 만들어질 수 있기 때문이다.


스마트 포인터

 

가장 단순하면서도 직접적인 프록시 패턴은 스마트 포인터라 할 수 있다. 스마트 포인터는 포인터의 참조 횟수를 관리하고, 몇몇 연산자를 오버라이딩하는 래퍼(wrapper)이다. 스마트 포인터는 일반적인 포인터를 사용할 때와 완전히 동일한 방식으로 인터페이스를 유지하며 사용할 수 있다.

struct BankAccount {
    void deposit(int amount) {}
};

BankAccount *ba = new BankAccount;
ba->deposit(123);
auto ba2 = make_shared<BankAccount>();
ba2->deposit(123); //< Same API

 

스마트 포인터는 일반 포인터가 사용될 자리에 대신 들어갈 수도 있다. *ba와 같은 역참조에서도 마찬가지로 일반 포인터, 스마트 포인터 둘 다 포인터가 가리키는 실 객체를 얻는다. 이런식으로 스마트 포인터가 일반 포인터 대신 사용될 수 있다.

 

다른 점이라고 하면 스마트 포인터는 delete를 호출할 필요가 없다는 것이다. 그런 부분들을 제외하고는 일반 포인터와 최대한 가깝게 동작하도록 구현되어 있다.


속성 프록시

다른 프로그래밍 언어에서는 속성(property)을 get/set 메서드가 지원하는 필드로 특별히 취급되며, 언어 차원에서 지원되는 게 있다. C++은 그러한 기능이 없으며, 어떤 필드에 특별히 지정된 접근자, 변경자를 부여하고 싶다면 속성 프록시를 만들어야 한다. 속성 프록시는 속성을 가장한 어떤 클래스이다.

template<typename T> struct Property {
    Property(const T initVal) {
        *this = initVal;
    }
    
    // Getter
    operator T() {
        return value;
    }
    
    // Setter
    T operator =(T newVal) {
        return value = newVal;
    }
    
    T value;
}

 

보통 위 코드에서 추가적으로 커스터마이징이 된다. 커스터마이징이 필요 없다면 사실 프록시를 쓸 이유가 없다. 그냥 일반적인 Getter / Setter를 사용할 수도 있기 때문이다.

Property<T> 클래스가 단순히 T가 차지할 자리를 대체한다. 필드의 값이 사용될 때 Property에서 T로, 또는 T에서 Property로 타입이 변환되면서 동작하게 된다.

struct Creature {
    Property<int> strength(10);
    Property<int> agility(5);
};

int main() {
    Creature creature;
    creature.agility = 20;
    auto x = creature.strength;
}

가상 프록시

nullptr이나 초기화되지 않은 포인터를 역참조하면 크래시가 발생한다. 근데, 어떤 경우에는 객체를 생성하되 불필요하게 일찍 자원이 할당되는 것을 원하지 않을 수도 있다. 이때의 접근 방법은 "느슨한 인스턴스화(lazy instantiation)"라고 한다. 정확히 어느 시점에 인스턴스화가 필요한지 안다면 사전에 계획해서 특별히 준비할 수 있다. 정확한 시점을 모른다면 이미 존재하는 것으로 간주되는 객체를 대리하는 프록시를 만들어 "느긋한" 동작을 하게 할 수 있다. 따라서 가상 프록시는 실제로는 존재하지 않지만 나타낼 수는 있는 것이다.

가상 프록시를 이용하면 실제 인스턴스에 접근하는 대신 무언가 가상의 것에 접근하게 된다.

struct Image {
    virtual void draw() = 0;
}

위의 draw() 메서드는 이미지에 대한 전형적인 인터페이스이다. 만약 Bitmap을 "성급한" 동작 방식으로 구현할 것이라면 당장 이미지 출력이 필요 없더라도 바로 파일을 로딩해줘야 한다.

struct Bitmap : Image {
    Bitmap(const std::string &file) {
        std::cout << "Loading image from " << file << std::endl;
        m_file = file;
    }
    
    void draw() override {
        std::cout << "Drawing image " << m_file << std::endl;
    }
    
    std::string m_file;
}

int main() {
    // test.png 파일 로딩
    Bitmap img("test.png");
}

위 동작은 일반적으로 원하는 방식이 아니다. 보통은 실제 그림을 그리는 draw() 메서드가 호출될 때 그림 파일이 로딩되기를 원한다.

 

그렇게 "느긋한" 동작 방식으로 바꾸고 싶지만 Bitmap이 외부 라이브러리여서 코드 수정이 불가능하다고 해보자. 또한 상속도 어떤 이유로 할 수 없다고 해보자.

 

여기서 가상 프록시를 활용할 수 있다. Bitmap과 동일 인터페이스를 제공하면서 Bitmap 기능을 활용하되 동작 방식만 바꾼다.

struct LazyBitmap : Image {
    LazyBitmap(const std::string file) : bmp(nullptr), filename(file) {}
    
    ~LazyBitmap() {
        delete bmp;
    }
    
    void draw() override {
        if ( !bmp ) {
            bmp = new Bitmap(filename);
        }
        
        bmp-> draw();
    }
    
private:
    Bitmap *bmp;
    std::string filename;
}

위 코드에서 알 수 있듯이 LazyBitmap의 생성자는 원래 객체 Bitmap 생성자보다 훨씬 더 가볍다. 단지 그림 파일 이름만 저장하고 실제 파일 로딩 작업은 수행하지 않는다. "느긋한" 동작은 draw()에서 일어난다.


커뮤니케이션 프록시

Bar라는 타입의 객체에서 멤버 함수 foo()를 호출한다고 하자. 보통 Bar의 객체가 그 객체를 이용하는 코드가 구동되는 컴퓨터와 같은 컴퓨터 안에 존재한다고 가정할 수 있다. 그리고 Bar::foo()의 구동도 같은 프로세스 안에서 된다고 가정한다.

 

Bar 객체와 그 멤버들을 네트워크로 연결된 다른 원격의 컴퓨터에 옮기는 것으로 설계 차원이 결정되었다면 어떨까? 기존 코드가 예전처럼 문제없이 잘 동작할 수 있어야 한다. 이때 커뮤니케이션 프록시가 필요하다. 커뮤니케이션 프록시는 원격에서 작업을 수행하고 결과를 모아 로컬에 중계해주는 컴포넌트이다.

 

단순한 핑-퐁 서비스를 살펴보자.

struct Pingable {
    virtual std::wstring ping(const std::wstring &message) = 0;
};

 

만약 핑-퐁이 하나의 프로세스 안에서 일어난다면 다음과 같이 Pong 클래스를 구현할 수 있다.

struct Pong : Pingable {
    std::wstring ping(const std::wstring &message) override {
        return message + L" pong";
    }
}

위 코드에서 ostringstream&를 사용하지 않고 매 응답마다 새로운 문자열을 생성하고 있다는 점을 살펴보자. 이러한 API는 웹 서비스의 동작 방식을 따라한 것이다.

 

핑-퐁 서비스는 다음과 같이 사용될 수 있다. 하나의 프로세스에서 어떻게 동작하는지 알 수 있다.

void tryit(pingable &pp) {
    std::wcout << pp.ping(L"ping") << "\n";
}

Pong pp;
for ( int i = 0; i < 3; ++i ) {
    tryit(pp);
}

 

위 코드대로라면 "ping pong"이 세 번 출력될 것이다.

이제 핑 서비스를 멀리 떨어진 웹 서버로 옮기는 작업을 해보자. 어쩌면 옮겨간 컴퓨터에서는 C++ 대신 ASP.NET과 같은 다른 플랫폼을 사용하기로 결정했을 수도 있다. 이러한 상황에서 커뮤니케이션 프록시가 사용될 수 있다. RemotePong을 만들어보자. 이 프록시는 Pong을 대체할 수 있다.

참고로 마이크로소프트의 REST SDK를 이용하면 원격 통신 구현이 쉬워진다고 한다.

struct RemotePong : Pingable {
    std::wstring ping(const std::wstring &message) override {
        std::wstring result;
        http_client client(U("http://localhost:9149/"));
        uri_builder builder(U("/api/pingpong/"));
        builder.append(message);
        pplx::task<std::wstring> task = client.request(
            methods::GET, builder.to_string()).
                then([=](http_response r) {
                    return r.extract_string();
                });
        task.wait();
        return task.get();
    }
}

람다도 쓰고 처음 보는 타입들이 많아서 이해하기 어렵다.

아무튼 위 코드는 REST에 대한 처리뿐만 아니라 SDK에서 제공되는 병렬 처리 런타임 기능도 제공한다고 한다. 이를 이용하면 사용자 코드는 다음과 같이 한 곳만 수정되게 된다.

RemotePong pp;
for ( int i = 0; i < 3; ++i ) {
    tryit(pp);
}

요약

몇 가지의 프록시들을 살펴보았다. Decorator 패턴과 달리 프록시는 어떤 객체에 새로운 멤버를 추가하는 방식으로 기능을 확장하지 않는다. 이미 존재하는 멤버들의 동작을 목적에 맞게 변형한다.

실제 상황에서는 더 많은 종류의 프록시들이 있을 수 있다. 틀에 박히지 않고 문제 상황에 맞춰 수행해야 하는 경우도 있다.

728x90