책정리/GoF 디자인 패턴

15장 대리 객체를 통한 작업 수행 문제(Proxy 패턴)

GONII 2019. 1. 30. 22:26
    프로그램에서 객체들은 저마다 수행해야 역할이 존재하는데 몇몇 객체들의 경우에는 너무 많은 역할을 수행해야 하거나, 부여된 역할을 수행하기 위해 여러 가지 복잡한 과정을 거쳐야 있다. 같은 경우 그냥 해당 객체가 모든 역할이나 작업을 수행하도록 하면 객체의 구현이나 유지, 보수는 굉장히 어려워질 것이다.
    반면 실세계의 대리 체제처럼 복잡하거나 다양한 작업을 대신 수행해줄 있는 객체를 정의하고 사용한다면 전반적인 구현이나 유지, 보수 등이 오히려 손쉬울 있다. 왜냐하면 복잡하고 다양한 작업이 대리 객체에 의해 숨겨질 있기 때문이다.
    • 문제 사례 설명
    인터넷에서 웹으로 만화 서비스를 제공하는 경우
    만화 서비스는 텍스트로 구성된 일반적인 웹페이지에 비해 데이터량이 많은 이미지 파일로 서비스가 제공된다. 따라서 기본적으로 만화는 서비스 요청에 따른 반응 시간이 오래 걸린다. 그리고 서비스를 사용하는 사람들이 많으면, 인터넷 서비스의 특성상 서비스를 이용하는 시간대가 몰려 특정 시간대에 서비스 요청이 폭주할 가능성이 높다.
    그러므로 서비스를 제공하는 입장에서 적은 자원을 사용하면서 서비스 요청이 폭주하는 시간대에도 서비스 요청에 따른 반응 시간을 안정되고 빠르게 유지하도록 만들어야 한다. 문제가 가장 관건이 되는 이유는 통상적으로 이용자들은 인터넷 상의 만화도 일반 만화책을 때와 마찬가지로 빠르게 다음 페이지로 넘어가기를 바라며, 그렇지 못한 경우에는 굉장히 불편하고 답답해하기 때문이다.
    어떻게 한정된 자원으로 안정되고 빠른 서비스를 제공할 있을까?
    • 다양한 접근 방법 및 PROXY 패턴
    주어진 문제를 간단히 정리하면 이미지 파일 형태로 서비스되는 만화 서비스에 대해 이용자가 몰려 서비스 요청이 폭주하더라도 추가적인 자원의 투입없이 서비스 처리에 걸리는 시간을 최대한 단축해서 이용자가 느끼는 서비스 반응 시간을 안정되고 빠르게 가져갈 있는 방법이 무엇인가 하는 것이다.
    같은 종류의 문제를 해결하기 위해서는 먼저 서비스 요청이 어떤 단계의 작업을 거쳐 처리되는지를 세분화시키고, 단계별 작업에 소요되는 시간을 측정해볼 필요가 있다. 왜냐하면 우리가 해결해야 하는 과제는 서비스 요청을 처리하는데 걸리는 전체적인 시간을 단축해서 서비스 요청이 폭주하더라도 서비스 요청 처리가 빠르게 이루어지도록 하는 것이며, 이를 위해서는 개별 서비스 요청 처리가 어떤 단계를 거치고 단계마다 얼마만큼의 시간이 소요되는지를 필요가 있기 때문이다.
    가장 많은 시간을 소모하는 것은 크게 가지로 요약할 있다.
    하나는 네트워크를 통해 서버에서 웹브라우저로 이미지 파일을 전송하는 것이며, 다른 하나는 서버 프로그램이 디스크로부터 이미지 파일의 내용을 읽어내는 작업이다. 따라서 만화 서비스의 경우 이용자의 서비스 요청이 폭주하더라도 서비스 속도를 안정되고 빠르게 유지하기 위해서는 가지 작업에 걸리는 소요 시간을 줄여주는 것이 바람직하다.
    그런데 이미지 파일을 서버에서 웹브라우저로 전송하는데 걸리는 시간은 네트워크 자체의 용량이나 속도를 늘리지 않는 크게 줄어들지 않는다. 따라서 한정된 자원을 사용하면서 서비스 반응 속도를 높이기 위해서는 웹서버가 디스크로부터 이미지 파일을 읽어들이는데 걸리는 시간을 줄이는 것이 가장 효과적이라고 있다.
    • 기본적인 방법: 단순 캐싱 방식
    디스크로부터 이미지파일을 읽어들이는 시간이 많이 걸리는 이유는 디스크의 처리 속도가 느리기 때문이다. 더구나 디스크의 경우에는 이미지 파일을 읽어들이는 요청이 한꺼번에 폭주하더라도 요청들을 동시에 처리할 없다는 단점이 존재한다.
    따라서 이미지 파일을 읽어들이는 시간을 단축하기 위해서는 디스크보다 훨씬 빠른 처리 속도를 가진 기억 공간을 활용할 필요가 있다. 더불어 기억 공간을 동시에 접근할 있다면 모든 서비스 요청들이 평균적으로 비슷한 반응 속도를 보일 있을 것이다.
    이런 목적에 가장 알맞은 기억공간은 주기억장치로 분류되는 메모리 공간일 것이다. 그런데 메모리의 경우 디스크에 비해 사용 가능한 공간이 상대적으로 훨씬 적다. 따라서 서비스에서 제공하는 모든 이미지 파일을 메모리 상에 올려 관리한다는 것은 불가능하다.
    이같은 상황에서 쉽게 떠올릴 있는 방법이 최근에 사용된 이미지 파일을 위주로 내용을 메모리 상에 캐싱하고 있다가 서비스 요청이 있을 경우 이를 활용하는 방식이다. 최근에 서비스 요청된 이미지 파일들은 내용을 메모리 상에 캐싱하고 있다가 이용자가 캐싱된 이미지 파일을 요청하는 경우에는 메모리 상에 있는 내용을 읽어서 서비스하고, 그렇지 않은 경우에는 디스크로부터 이미지 파일을 읽어 서비스하는 것이다. 여기서 관건은 이러한 캐싱 방법을 어떤 식으로 구현하도록 설계하는 것이 바람직하겠는가 하는 점이다. 어떤 식의 클래스 구조가 이미지 파일을 캐싱하고 이를 사용하는데 적합할 것인가 하는 것이다.
    이미지 파일을 캐싱하기 위한 클래스 구조를 생각해내기 위해 우선 이미지 파일을 캐싱해서 사용할 경우 Client 모듈의 기본적인 동작을 생각해보자. Client 모듈은 먼저 이용자에 의해 요청된 이미지 파일이 메모리 상에 캐싱되어 있는지를 점검할 것이다. 이때 이미지 파일이 이미 캐싱되어있다면 그것을 읽어 서비스하면 것이다. 반면 요청한 이미지 파일이 캐싱되어 있지 않다면 Client 모듈은 디스크로부터 이미지 파일을 직접 읽어서 서비스 해야 한다. 이렇게 디스크로부터 읽혀진 이미지 파일은 메모리 상에 캐싱해서 관리되도록 해야 것이다. 물론 캐싱된 이미지 파일이 너무 많을 경우 가장 오래 사용되지 않은 캐싱 정보를 삭제하고 새로운 이미지 파일을 캐싱해야 것이다.
    이미지 파일을 캐싱해서 사용할 경우 Client 모듈의 같은 동작 형태를 감안한다면 이미지 파일 캐싱을 위한 클래스는 크게 개가 필요할 것이다. 하나는 이미지 파일을 캐싱하고 관리하기 위한 클래스이고, 다른 하나는 메모리 상에 캐싱되어 있지 않은 이미지 파일을 디스크로부터 읽어들이기 위한 클래스이다.
    이미지 파일을 캐싱하고 관리하기 위한 클래스는 캐싱된 이미지 파일을 찾아주는 역할뿐만 아니라 새로운 이미지 파일을 메모리 상에 캐싱하도록 등록해주는 역할과 인터페이스도 제공해야 것이다. 또한 내부적으로 한정된 메모리 자원을 고려해서 캐싱되는 이미지 파일의 개수를 일정하게 관리하는 기능도 필요할 것이다. 일정 개수 이상 이미지 파일이 캐싱될 경우에는 사용한 오래된 이미지 파일은 캐싱에서 삭제시키는 작업도 수행할 것이다. 한편 이미지 파일을 디스크로부터 읽어들이기 위한 클래스는 실제 파일 입출력을 통해 디스크로부터 파일을 읽어들여 내용을 되돌리는 역할을 수행할 것이다.


    [그림 15-1] 가상 코드에서 보듯이 Client입장에서는 위와 같은 클래스 구조를 사용할 경우 불편할 밖에 없다. 외냐하면 Client 일일이 원하는 이미지 파일이 캐싱되어 있는지를 확인해야 하고, 원하는 이미지 파일이 없으면 ImageFile 클래스 객체를 이용해서 이를 디스크로부터 읽어들인 메모리상에 캐싱하도록 등록해야 하기 때문이다.
    • 패턴 활용 방식: PROXY 패턴
    Client 모듈이 이처럼 복잡하고 지저분해지는 이유는 무엇일까?
    근본적인 이유는 Client 모듈이 개의 클래스에 대해 모두 알고 있기 때문이다. 사실 Client입장에서는 이미지 파일을 캐싱하든, 그렇지 않고 직접 디스크로부터 이미지 파일을 읽어오든 상관없이 이미지 파일 내용만 얻으면 된다. 그런데 [그림 15-1] 클래스 구조는 Client 이미지 파일들이 캐싱되고 있다는 사실을 알고 있으면서 이미지 파일에 대한 요청이 있으면 이미지 파일이 캐싱된 곳에서 원하는 정보를 먼저 찾고, 없으면 디스크에서 찾는 식의 작업을 하고 있는 것이다. Client 자신이 필요도 없는 정보를 알고 있으면서 정보를 기반으로 작업을 수행하기 때문에 문제가 되는 것이다. 정확히 말하자면 클래스간의 정보 은닉 경계와 역할 구분이 명확히 이루어지지 않았다는 것을 의미한다.
    이런 문제의 원인을 해소할 있는 클래스 구조는 무엇일까?
    우선 Client 이미지 파일이 캐싱되어 있든, 되어있지 않든 상관없이 이용자가 요청한 이미지 파일의 내용만 얻을 있으면 된다.
    가지 가능성을 생각할 있다. [그림 15-1]에서 Client 하던 역할을 대신 수행할 별도의 클래스를 정의하는 벙법과 [그림 15-1] 클래스 어느 하나가 캐싱된 정보로부터 이미지 파일을 찾는 일과 캐싱되지 않은 이미지 파일을 원할 경우 디스크로부터 이미지 파일을 찾아 내용을 캐싱하고 Client에게 되돌려주는 역할을 함께 수행하는 것이다. 가지 방법 바람직한 방법은 새로운 클래스의 정의없이 모든 작업을 수행하도록 하는 방법이다. 왜냐하면 클래스가 늘어날수록 전체적인 설계가 복잡해지기 때문에 가능하면 적은 클래스로 원하는 작업을 수행하게 하는 것이 바람직하기 때문이다.
    그렇다면 [그림 15-1] 어떤 클래스에게 Client 원하는 모든 역할을 수행하게 만드는 것이 좋을까?
    이는 [그림 15-1] 클래스 구조에서 Client 동작하는 순서를 고려해보면 해답을 찾을 있다. [그림 15-1]에서 Client 먼저 ImageFileCache클래스 객체에게 원하는 이미지 파일이 캐싱되어 있는지를 확인하고, 캐싱되어 있지 않다면 ImageFile 클래스 객체를 이용해서 디스크로부터 이미지 파일을 읽어들이는 형태를 취하고 있다. 따라서 [그림 15-1]에서 이미지 파일이 캐싱되어 있지 않을 경우 이를 처리하는 역할만 ImageFileCache 클래스가 같이 수행한다면 Client 원하는 모든 역할을 ImageFileCache 클래스가 처리할 있게 된다.


    [그림 15-2] 클래스 구조는 다른 문제가 없을까?
    우리가 문제를 고려했던 방법은 처음부터 이미지 파일을 메모리 상에 캐싱하는 방법을 적용하려고 경우 클래스 구조를 활용하면 좋을 것인가 하는 것이었다. 그런데 만약 모든 Client 프로그램이 디스크로부터 이미지 파일을 읽어가도록 개발되어 있는데 서비스 반응 속도 향상을 위해 [그림 15-2] 같은 클래스 구조를 적용하려고 한다면 어떻게 될까?
    경우 발생하는 가장 문제는 Client프로그램을 모두 수정해야 한다는 것이다. 왜냐하면 ImageFileCache클래스와 ImageFile클래스가 제공하는 인터페이스가 서로 다르기 때문이다. 따라서 [그림 15-2] 클래스 구조가 다각도의 문제를 해결하는데 적용되도록 하기 위해서는 ImageFileCache 클래스와 ImageFile클래스가 외부로 공개하는 인터페이스를 동일하게 만들어주는 것이 좋다. 이를 위해 상위에 공통된 인터페이스를 가진 추상클래스를 정의해주는 것이 좋은 방법이다.
    이처럼 기존의 이미지 파일을 메모리 상에 캐싱하는 프로그램을 작성할 경우 뿐만 아니라 기존 디스크로부터 이미지 파일을 읽어서 서비스 요청을 처리하던 것을 속도 개선 차원에서 이미지 파일을 캐싱하는 형태로 프로그램을 수정하려고 경우에도 쉽게 적용 가능하려면 [그림 15-2] 클래스 구조를 [그림 15-3] 같은 형태로 변경시켜야 것이다.


    [그림 15-3]에서처럼 기본적으로 어떤 역할을 수행하고 있는 클래스가 존재할 클래스가 제공하는 기능이나 역할을 그대로 활용하면서 부가적인 기능이나 역할을 수행해주기 위해 새로운 클래스를 정의하고 Client 모든 요청을 새로 정의한 클래스 객체를 거쳐 원래의 클래스 객체에게 전달하는 방식의 클래스 구조를 Proxy 패턴이라고 한다. Proxy패턴은 기존 클래스 대신에 Client로부터 서비스 요청을 받아 처리해줄 있는 역할 대행 클래스를 정의하는 방법이라고 있다.
    • 샘플 코드
    [소스 15-1] 인터넷 만화 서비스에서 이미지 파일 캐싱을 위한 Proxy 패턴 적용 샘플 코드
    #include <iostream>
    #include <fstream>
    #include <string>
    #include <map>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    using namespace std;
     
    #define MAX_CACHE_COUNT        50
     
    class File
    {
    public:
    virtual int GetFile(string fn, void* pOut) = 0;
    };
     
    class ImageFile : public File
    {
    public:
    int GetFile(string fn, void* pOut)
    {
    struct stat statBuf;
    if (stat(fn.data(), &statBuf) < 0)
    {
    cout << "File Not Found" << endl;
    return 0;
    }
     
    int fd = open(fn.data(), O_RDONLY);
    if (fd < 0)
    {
    cout << "File Open Error" << endl;
    return 0;
    }
     
    pOut = new char[statBuf.st_size];
    ssize_t byteCnt = read(fd, pOut, statBuf.st_size);
    close(fd);
     
    if (byteCnt != statBuf.st_size)
    {
    cout << "Error while File Reading" << endl;
    return 0;
    }
     
    return byteCnt;
    }
    };
     
    class ImageFileCache : public File
    {
    public:
    int GetFile(string fn, void* pOut)
    {
    pOut = fileCache_[fn];
     
    struct stat realStat;
    if (stat(fn.data(), &realStat) < 0)
    {
    cout << "File Not Found" << endl;
    return 0;
    }
     
    struct stat* pFileStat = fileStat_[fn];
    if (pOut == NULL || pFileStat == 0
    || realStat.st_mtime != pFileStat->st_mtime)
    {
    // 캐싱이 안되어 있거나, 캐싱된 정보가 실제 파일과 다를 때
    ImageFile f;
    void* pFileOut;
    int fileSize = f.GetFile(fn, pFileOut);
     
    if (fileSize <= 0)
    {
    pOut = NULL;
    return 0;
    }
     
    RegisterCache(fn, &realStat, pFileOut);
    pOut = pFileOut;
    }
    else
    {
    // 최근 읽은 시간 수정
    for (int i = 0; i < MAX_CACHE_COUNT; i++)
    {
    if (lruInfo_[i].fn_ == fn)
    {
    lruInfo_[i].lastReadTime_ = time(0);
    break;
    }
    }
    }
    return realStat.st_size;
    }
    protected:
    void RegisterCache(string fn, struct stat* pFileStat, void* pFile)
    {
    int cachePos = 0;
    time_t oldestReadTime = time(0);
    for (int i = 0; i < MAX_CACHE_COUNT; i++)
    {
    if (lruInfo_[i].lastReadTime_ == 0)
    {
    // cache 공간이 남아 있는 상태
    cachePos = i;
    break;
    }
    else if (oldestReadTime > lruInfo_[i].lastReadTime_)
    {
    cachePos = i;
    oldestReadTime = lruInfo_[i].lastReadTime_;
    }
    else
    { }
    }
     
    if (lruInfo_[cachePos].lastReadTime_ != 0)
    {
    // 이전 캐싱 정보 삭제
    struct stat* pOldFileStat = fileStat_[lruInfo_[cachePos].fn_];
    void* pOldImageFile = fileCache_[lruInfo_[cachePos].fn_];
     
    delete fileStat_[lruInfo_[cachePos].fn_];
    delete fileCache_[lruInfo_[cachePos].fn_];
     
    fileStat_.erase(lruInfo_[cachePos].fn_);
    fileCache_.erase(lruInfo_[cachePos].fn_);
    }
     
    lruInfo_[cachePos].fn_ = fn;
    lruInfo_[cachePos].lastReadTime_ = time(0);
     
    struct stat* pNewStat = new struct stat;
    bcopy(pFileStat, pNewStat, sizeof(struct stat));
     
    fileStat_[fn] = pNewStat;
    fileCache_[fn] = (char*)pFile;
    }
    private:
    class LRUInfo
    {
    public:
    LRUInfo() { lastReadTime_ = 0; }
    string fn_;
    time_t lastReadTime_;
    };
     
    LRUInfo lruInfo_[MAX_CACHE_COUNT];
    map<string, struct stat*> fileStat_;
    map<string, char*> fileCache_;
    };
     
    void main()
    {
    File* pFileServer = new ImageFileCache;
     
    void* pImgData;
    int fileSize = pFileServer->GetFile("./comic/img/hero1.jpg", pImgData);
    }
    • 구현 관련 사항
    Proxy 패턴에서 고려해야 사항
    • Proxy클래스 하나가 여러 클래스의 역할을 대행하려면 우선 Proxy 클래스가 역할 대행을 클래스들이 공통 부모 클래스를 가져야 한다. 왜냐하면 Proxy 클래스가 여러 클래스의 역할을 대행하려면 클래스의 객체를 모두 참조 가능해야 하는데 이를 위해서는 공통된 자료형이 필요하기 때문이다.
    • Proxy 클래스가 여러 클래스의 역할을 대행하려면 대행할 클래스의 객체를 적절히 생성할 있는 방법이 있어야 한다. 왜냐하면 Proxy 클래스 객체는 역할 수행 도중에 원래 클래스의 객체를 참조하게 되는데, 이때 원래 클래스의 객체가 생성 가능해야 하기 때문이다.
    • 여러 클래스 어떤 클래스의 객체를 생성할지를 어떻게 판단할 것인가이다. 여러 종류의 파일에 대해 캐싱을 지원하기 위한 문제의 경우 가지 가능한 방법은 파일명의 확장자를 기준으로 MINE타입을 찾아내서 생성할 객체의 클래스를 결정하는 것이다. 물론 방식의 경우 MINE 타입이 추가되면 이를 반영하기 위해 기존 소스코드를 수정해야 한다는 단점은 존재한다.
    • 객체를 곧바로 생성하지 않고, 객체가 실제로 사용되는 시점까지 생성을 연기하려고 때에도 유용하게 사용될 있다.
    • 프로그래밍 언어의 특성을 이용해서 Proxy패턴을 구현하는 방법에 대한 것이다. 예를 들어 C++ 경우 객체 내부의 멤버를 접근하기 위한 연산자인 operation->() Overloading 시킴으로써 객체가 참조될 때마다 추가적인 작업을 하도록 만들 있다.


    • PROXY 패턴 정리
    Proxy 패턴은 Proxy클래스와 RealSubject클래스가 동일한 인터페이스를 제공하는 ㅡ것이 특징인데, 이는 기존 소스코드가 RealSubject 클래스를 사용하는 형태로 작성되어 있을 경우 이를 크게 수정하지 않고도 Proxy 클래스를 사용하는 형태로 쉽게 변경시킬 있도록 하기 위한 것이다.


     
    Proxy패턴은 활용 목적에 따라 다음과 같이 세분화시켜 분류하기도 한다.
    • Remote Proxy
    서로 다른 주소 공간에 있는 객체에 대해 마치 같은 주소 공간에 있는 것처럼 동작하게 만들고 싶을 사용하는 Proxy패턴
    이와 같은 Remote Proxy 대표적인 예가 CORBA WSA(Web Application Server) 등에서 제공하는 Container 있을 것이다. Remote Proxy 내부적으로는 호스트ID 해당 호스트에서의 주소 같은 형태로 객체를 참조할 있게 하면서도 외부적으로는 객체가 서로 다른 기계 상에 존재한다는 사실을 숨겨주는 역할을 한다.
    • Virtual Proxy
    객체를 생성하는 비용이 많이 드는 경우 필요로 하는 시점까지 객체의 생성을 미루고 대신 해당 객체가 생성된 것처럼 동작하도록 만들고 싶을 사용하는데 Proxy패턴
    • Protection Proxy
    객체에 대한 접근 권한을 제어하거나 객체마다 접근 권한을 달리하고 싶을 사용하는 Proxy 패턴
    Decorator패턴과 유사하게 내부적으로 RealSubject 객체에 대한 접근을 가로채어 중간에 권한 점검을 수행하는 것으로 생각하면 된다.
    • Smart Reference
    일반적으로 포인터에 추가적인 기능을 부여하고자 경우 사용하는 Proxy패턴 Smart Reference 대표적인 예는 다음과 같다.
    • 객체가 참조될 때마다 Reference Count 관리하고 있다가 해당 객체가 이상 참조되지 않을 자동으로 소멸시키기 위한 목적으로 사용. 이런 목절으로 사용되는 Proxy 포인터 클래스를 Smart Pointer라고 부르기도 한다.
    • 객체가 생성될 때가 아니라 객체가 처음 참조될 메모리로 객체를 로딩하고 싶을 사용
    • 객체가 참조되고 있는 동안 객체들이 이상 해당 객체를 참조하지 못하게 만들 경우 사용. Concrrent 프로그래밍 시에 자주 사용될 있다.
    • 포인터를 통해 객체를 접근할 추가적인 작업을 수행하기 위해 사용
     
    밖에도 Proxy패턴을 이용하면 크고 복잡한 객체를 복제하는 비용을 줄일 있다. 예를 들어 객체 복사 생성자(Copy Constructor) 불릴 때마다 크고 복잡한 객체를 매번 복제해야 한다면 많은 비용이 것이다. 그렇지만 Proxy 패턴을 사용하여 복사 생성자가 불리는 시점에는 단순히 객체에 대한 Reference Count값만 증가시켜주는 객체에 수정이 이루어질 경우에만 실제 객체를 복사한다면 비용을 훨씬 줄일 있을 것이다. 이같은 목적으로 Proxy패턴을 적용하는 것을 특별히 Copy on Write방식이라고 한다.
    Proxy패턴은 Client 기존 클래스 사이에 중간 매개체로 Proxy클래스를 정의하고, 클래스의 객체로 하여금 기존 클래스의 기능이나 역할을 대행하게 만드는 것이라고 있다. 과정에서 Proxy 클래스 객체는 기존 클래스가 제공하는 기능이나 역할 이외의 부가적인 기능이나 역할도 수행할 있다는 장점을 가지고 있다.
반응형