책정리/GoF 디자인 패턴

10장 인터페이스와 구현의 명확한 분리 문제(Bridge 패턴)

GONII 2019. 1. 26. 14:35
    객체는 외부에 공개되는 인터페이스와 내부적인 구현으로 이루어진다. 따라서 객체지향 설계는 기본적으로 인터페이스와 구현을 분리한 접근 방법이다. 이처럼 인터페이스와 구현을 독립적으로 분리해서 접근하게 되면 객체를 구현하는 방식이 바뀌더라도 객체를 사용하는 프로그램은 수정하지 않아도 되기 때문에 변경의 국지화(Localization of Change), 어느 곳의 변경으로 인해 영향을 받는 범위를 한정지을 있는 장점을 가진다.
    그러나 객체가 가지는 같은 장점은 때때로 깨뜨려지는 경우가 있을 있다. 예를 들어 하나의 클래스를 여러 가지 플랫폼에서 구현한다고 플랫폼마다 서로 다른 클래스를 정의하는 것은 이를 사용하는 프로그램이 플랫폼마다 다르게 작성되어야 함을 의미한다.
    • 문제 사례 설명
    검색 엔진을 만드는 회사가 있다고 생각해보자. 회사에서 만드는 검색 엔진은 마이크로소프트 윈도우 운영체제 상에서 실행되어야 하고, Unix 운영체제 상에서도 실행되어야 한다. 그러나 검색 엔진을 이용해서 검색 서비스를 개발하는 사람들에게는 플랫폼에 관계없이 동일한 인터페이스를 제공하려고 한다. 왜냐하면 회사의 검색 엔진을 사용하는 고객들은 운영체제 환경이 바뀐다고 해서 자신이 개발한 프로그램을 일일이 수정하기를 원하지 않기 때문이다.
    • 다양한 접근 방법 및 BRIDGE 패턴
    주어진 문제를 정리하면 검색 엔진을 위한 클래스 라이브러리를 만드는 있어 운영체제의 변화와는 무관한 형태로 클래스 라이브러리를 만들 있는 방법이 무엇인가 하는 것이다. 만약 운영체제에 따라 UnixSearchEngine, WindowsSearchEngine 같이 클래스르 정의하게 되면 다음과 같은 문제가 있을 것이다.
    • 무엇보다 가장 문제는 클래스들을 사용하는 응용 프로그램이 운영체제 환경이 바뀔때마다 수정되어야 한다는 점이다. 왜냐하면 운영체제 환경마다 사용하는 클래스가 다르기 때문이다.
    • 다른 문제로는 각각의 클래스들이 따로 따로 유지, 보수가 이루어져야 한다는 점이다. 이는 비단 유지, 보수에 따른 노력이 2배로 늘어난다는 문제 외에도 애초에 동일했던 인터페이스가 서로 별개로 유지 보수됨으로써 일관성을 잃을 가능성이 크다는 문제를 가지고 있다.
    • 기본적인 방법: #ifdef ~ #else ~ #endif 적용 방식
    동일한 클래스를 정의한다고 하더라도 클래스의 내부 구현은 달라질 밖에 없다. 왜냐하면 운영체제에 따라 사용하는 시스템 함수나 최적화 방법들이 다를 것이기 때문이다. 따라서 어떤 식으로든 플랫폼에 따른 구현 내용은 변경시켜주어야 한다.
    이와 같은 경우 프로그램 소스코드에서 흔히 있는 것이 바로 #ifdef ~ #else ~ #endif문장이다.
    [소스 10-1] #ifdef ~ #else ~ #endif 문장을 통한 플랫폼 구분 접근 방식
    class SearchEngine
    {
    public:
    bool Search(string s);
    };
     
    bool SearchEngine::Search(string s)
     
    #ifdef __WIN32__
    // MS Windows 환경에 맞는 소스
    #else
    // Unix 환경에 맞는 소스
    #endif
    [소스 10-1] 같은 형태의 접근 방식은 다음과 같은 문제가 있을 있다.
    • 먼저 프로그램의 가독성(Readability) 떨어진다. 왜냐하면 #ifdef ~ #else ~ #endif 문장은 다른 조건을 포함하여 여러 중첩해서 사용될 있고, 프로그램 중간 중간에 어디든지 나타날 있기 때문에 프로그램의 논리를 따라가는데 혼돈을 많이 주기 때문이다. 이처럼 가독성이 떨어지면 자연스럽게 프로그램에 대한 이해도 어려워지고, 나아가 프로그램의 유지, 보수가 힘들어지는 단점이 있다.
    • 다른 문제로는 새로운 플랫폼 환경에 적용하려면 프로그램 전체를 분석하면서 수정해야 한다는 것이다. 예를 들어 새로이 Linux 플랫폼에도 클래스 라이브러리를 적용하기로 했다고 가정해보자. 그러면 프로그램 상에서 #ifdef ~ #else ~ #endif 형태의 문장이 있는 곳을 일일이 찾아 수정해야 것이다. , 새로운 플랫폼 환경의 추가가 기존 소스코드와 독립적으로 이루어질 없다는 것이다.
    • 좀더 나은 방법: 플랫폼별 하위 클래스 정의
    플랫폼마다 클래스를 정의하되, 공통된 인터페이스를 유지할 있게 상위에 추상 클래스를 정의하는 [그림 10-1] 같은 형태는 어떨까?


    [그림 10-1] 처음에 언급했던 UnixSearchEngine WindowsSearchEngine 클래스를 각각 별개로 경우와는 분명히 다르다. 왜냐하면 추상 클래스로 SearchEngine 존재하기 때문이다. 이렇게 추상 클래스가 존재하게 되면 하위 클래스들은 추상 클래스에서 정의한 인터페이스를 공유하게 되므로 유지, 보수 기간 도중 플랫폼별로 클래스의 인터페이스 변경이 제각기 일어나는 것을 방지할 있다. 또한 응용 프로그램에서도 플랫폼에 무관하게 SearchEngine클래스가 제공하는 인터페이스를 사용하면 되기 때문에 플랫폼에 따라 일일이 소스코드를 수정하지 않아도 된다. 더구나 새로운 플랫폼을 추가하고자 경우에도 그에 맞추어 새로운 하위 클래스만 정의하면 되므로 편리하다.
    만약 검색 엔진을 사이트를 검색하기 위한 것과 내부 데이터베이스를 검색하기 위한 것으로 구분한다고 가정해보자. 경우 SearchEngine 클래스의 하위 클래스로 WebSearchEngine DBSearchEngine 클래스를 각각 정의할 있을 것이다. 그런데 여기서 문제는 새로 정의된 개의 하위 클래스들도 각각 구현 플랫폼에 따라 또다시 클래스가 나누어져야 한다는 점이다. [그림 10-2] 같은 형태가 것이다.


    이처럼 구현 플랫폼별로 하위 클래스를 정의하는 방식은 하위 클래스를 추가 정의하게 경우 가지 관점의 클래스 분류 기준이 뒤섞여 혼란을 초래할 있다. [그림 10-2]에서 보는 것과 같이 WebSearchEngine DBSearchEngine 클래스의 하위에 또다시 구현 플랫폼별로 하위 클래스를 정의해야 하며 이는 혼란을 야기할 있다. 왜냐하면 구조대로라면 논리적으로 새로운 하위 클래스를 정의할 때마다 플랫폼 개수만큼 새로운 클래스를 정의해야 하고 이로 인해 우리가 다루어야 클래스가 너무 많아질 것이기 때문이다. 더구나 이런 클래스 구조에 새롭게 Linux 같은 플랫폼을 추가로 적용하려고 하면 이미 정의된 클래스의 하위에 모두 Linux 관련된 하위 클래스들을 추가로 정의해야 하는 문제가 발생한다.
    같은 문제 발생의 근본 원인은 클래스 상속 구조의 정의 논리적인 관점 외에 구현 플랫폼이라는 기준을 복합적으로 사용했기 때문이다. , 플랫폼에 따라 달라져야 구현 클래스를 정의하기 위해 상속 관계를 사용했을 뿐만 아니라 클래스들간의 논리적인 의미 관계를 표현하기 위해서도 상속 관계를 사용했기 때문이다.
    • 패턴 활용 방법: BRIDGE 패턴
    구현 플랫폼별로 하위 클래스를 정의하는 방식의 문제점은 하위 클래스 정의 가지 관점이 뒤섞여 클래스 상속 관계가 복잡하고, 혼란스러워진다는 점이 있다.
    기본적으로 클래스 상속 관계를 정의함에 있어 분류 기준이 가지 이상이 되면 문제가 된다. 따라서 이런 문제를 해결하기 위한 방법은 분류 기준에 따라 각각 독립된 클래스 상속 관계를 정의하는 것이다. , 사용 용도에 따른 논리적 관점의 클래스 상속 구조와 구현 플랫폼별 클래스 상속 구조를 [그림 10-3] 같이 별개로 정의하는 것이다. 이때 사용 용도에 따른 논리적 관점의 클래스 객체는 구현 관련된 클래스의 객체를 데이터 멤버 형태로 참조하는 형태가 것이다.


    이처럼 클래스 구조를 정의함에 있어 외부에 공개되는 인터페이스 그에 따른 논리적 관점의 클래스 상속 구조와 이들을 구현하기 위한 클래스의 상속 구조를 독립적으로 정의하고, 논리적 관점의 클래스에서 구현 클래스를 참조하는 형태로 설계된 클래스 구조를 Bridge 패턴이라고 한다.
    이런 Bridge 패턴을 검색 엔진 문제에 적용하였을 경우 외부 프로그램들은 모두 SearchEngine 하위 클래스들을 참조할 것이기 때문에 구현 플랫폼에 무관하게 코딩이 가능하다. 따라서 구현 플랫폼이 변경되더라도 소스코드를 일일이 수정할 필요가 없다. 반면 실제적인 구현은 SearchEngineImp 하위 클래스들에 이루어지며, 새로운 구현 플랫폼이 필요할 경우 SearchEndgineImp 하위 클래스로 추가 정의만 하면 되기 때문에 편리하다. 더불어 논리적인 클래스 상속 구조와 구현 플랫폼에 따른 클래스 상속 구조가 독립되어 있으므로, 논리적인 클래스를 추가한다 하더라도 구현 클래스를 추가로 정의할 필요가 없는 장점도 있다.
    Bridge 패턴은 외부에 공개하기 위한 인터페이스와 그것을 구현하는 부분을 명백히 다른 클래스로 정의하는 방식을 통해 구현 방식과는 독립된 인터페이스를 제공하는 것을 목적으로 한다.
    • 샘플 코드
    [소스 10-2] 검색 엔진 문제에 Bridge패턴을 적용한 소스코드
    #include <iostream>
    #include <string>
     
    using namespace std;
     
    class BTree{};
     
    class SearchEngineImp
    {
    public:
    virtual bool Search(string s, string idxFn) = 0;
    virtual bool Search(string s, BTree& bTree) = 0;
    };
     
    class UnixSearchEngineImp : public SearchEngineImp
    {
    public:
    bool Search(string s, string idxFn)
    {
    // Unix환경에 맞추어 idxFn에서 문자열 검색
    return true;
    }
     
    bool Search(string s, BTree& bTree)
    {
    // Unix 환경에 맞춰 Btree에서 문자열 검색
    return true;
    }
    };
     
    class WindowsSearchEngineImp : public SearchEngineImp
    {
    public:
    bool Search(string s, string idxFn)
    {
    // MS Windows 환경에 맞추어 idxFn에서 문자열 검색
    return true;
    }
     
    bool Search(string s, BTree& bTree)
    {
    // MS Windows 환경에 맞춰 BTree에서 문자열 검색
    return true;
    }
    };
     
    class SearchEngine
    {
    public:
    SearchEngine() { pImp_ = 0; }
    virtual bool Search(string s) = 0;
    protected:
    SearchEngineImp* GetSearchEngineImp()
    {
    #ifdef __WIN32__
    pImp_ = new WindowsSearchEngineImp;
    #else
    pImp_ = new UnixSearchEngineImp;
    #endif
    return pImp_;
    }
     
    private:
    SearchEngineImp* pImp_;
    };
     
    class WebSearchEngine : public SearchEngine
    {
    public:
    WebSearchEngine(string idxFn) { indexFn_ = idxFn; }
    bool Search(string s)
    {
    return GetSearchEngineImp()->Search(s, indexFn_);
    }
    private :
    string indexFn_;
    };
     
    class DBSearchEngine : public SearchEngine
    {
    public:
    bool Search(string s)
    {
    return GetSearchEngineImp()->Search(s, bTree_);
    }
    private:
    BTree bTree_;
    };
     
    void main()
    {
    WebSearchEngine finder("inverted_file4web.idx");
    finder.Search("디자인 패턴");
    }
    [소스 10-2]에서도 #ifdef ~ #else ~ #endif 구문이 사용되었다. 그러나 구문은 GetSearchEngineImp() 멤버 함수 곳에서만 필요하며, 다른 곳에서는 사용될 필요가 없다. 왜냐하면 구현 환경이 달라질 경우 GetSearchEngineImp() 멤버 함수에서 생성하는 객체가 달라지고 이를 통해 구현 환경의 차이에 따른 처리가 이루어질 있기 때문이다.
    한편 [소스 10-2]에서 인터페이스 클래스에 해당하는 SearchEngine 하위 클래스들 최상위 클래스인 SearchEngine 클래스에서만 구현 클래스 객체에 대한 정보를 데이터 멤버로 가지면, 하위 클래스들은 이를 공유할 있다는 점을 주지하기 바란다. 이는 [그림 10-3]에서도 보인 바와 같이 상위 클래스간 관계를 통해 하위 클래스들이 이를 공유할 있음을 뜻한다.
    • 구현 관련 사항
      1. 먼저 구현 클래스의 상위에 추상 클래스를 두는 것은 필요한지 생각해보자.
    사실 구현 클래스가 개뿐이라면 굳이 추상 클래스가 있을 필요는 없다. 그러나 검색 엔진의 예에서도 보았듯이 일반적으로 구현 방법이나 환경 등의 기준에 따라 구현 클래스는 여러 개가 만들어질 있다. 그런데 만약 이들 구현 클래스들이 제각기 별개로 정의된다면 이를 사용하는 입장에서는 결국 각각을 구별해서 프로그래밍을 해야 하는 문제가 발생할 것이다. 이런 문제를 해결하기 위해서는 각각의 구현 클래스가 동일한 자료형으로 인식되도록 하고, 외부에서 제공하는 인터페이스도 동일하게 만들어주어야 한다. 이를 위한 가장 적절한 방법이 각각의 구현 클래스들의 상위에 추상 클래스를 정의하는 것이다. 따라서 구현 클래스가 하나 이상 늘어날 가능성이 있다면, 구현 클래스의 상위에 추상 클래스를 두는 것이 필요하다고 있다.
    1. 구현 클래스 여러 개가 존재할 인터페이스 클래스는 어떤 구현 클래스를 사용할 것이며 이를 언제, 어떻게 결정하는 것이 바람직한지에 대한 것이다.
    우선 생각해볼 있는 방법은 인터페이스 클래스의 생성자나 특정 멤버 함수 내에서 사용할 구현 클래스를 결정하는 방식이다. 검색 엔진 문제에서 GetSearchEngineImp() 멤버 함수가 여기에 해당한다고 있다. 이때 구현 클래스를 결정하는 기준은 생성자나 멤버 함수의 인자로 주어질 수도 있을 것이다.
    방법의 특징은 인터페이스 클래스에서 어떤 구현 클래스를 사용할지를 결정하는 방식이라는 점이다. 바꾸어 얘기하면 인터페이스 클래스가 모든 구현 클래스를 알고 있다는 가정을 내포하고 있는 것이다. 따라서 방법은 쉽고 간단한 반면 새로운 구현 클래스가 정의되었을 경우 이를 사용하기 위해서는 인터페이스 클래스의 구현도 수정해야 한다는 단점이 있다.
    다른 방법으로는 인터페이스 클래스에서 일정 기준을 정해두고 해당 기준을 넘어서기 전에 사용하는 클래스와 기준을 넘어섰을 사용하는 클래스를 구분해주는 방식이다. 예를 들어 행과 열의 개수가 많은 이차원 매트릭스(Matrix) 정보를 저장한다고 해보자. 이를 저장하기 위한 가장 일반적인 자료구조는 이차원 배열일 것이다. 그러나 이차원 매트릭스에서 유효한 값을 가진 셀이 일정 개수 이하면 이를 이차원 배열을 이용해서 저장하는 것은 많은 저장 공간을 낭비하는 셈이 된다. 따라서 저장해야 셀의 개수가 일정 개수 이하일 경우에는 이차원 배열 대신에 Linked List 사용해서 이차원 매트릭스의 정보를 저장하도록 만들 있다.
    1. 구현 클래스의 객체가 여러 인터페이스 객체에 의해 공유되어질 경우 어떤 방법으로 공유되는 구현 객체의 소멸 시점을 판단할 것인가 하는 것이다.
    일반적으로 구현 객체는 여러 인터페이스 객체에 의해 종종 공유될 있는 가능성을 가지고 있다. 문제는 이와 같이 구현 객체가 여러 개의 인터페이스 객체에 의해 공유될 경우 구현 객체를 소멸시켜줄 것인가다. 아마 가장 적절한 구현 객체의 소멸 시점은 이상 자신을 참조하는 인터페이스 객체가 없을 때일 것이다.
    이런 목적을 위해 고안된 방법이 바로 Reference Couning 기법이다. 방법은 구현 객체의 내부에 자신을 참조하는 인터페이스 객체의 개수를 관리하기 위한 데이터 멤버를 정의해두고, 자신을 참조하는 인터페이스 객체가 늘어나면 값을 증가시키고, 줄어들면 값을 감소시키는 방식으로 자신을 참조하는 인터페이스 객체의 개수를 관리하도록 만든 것이다. 이를 통해 구현 객체는 자신을 참조하는 인터페이스 객체의 개수가 0 자기 자신을 소멸할 있게 된다.
    1. 구현 클래스가 변경되었을 과연 인터페이스 클래스는 다시 컴파일 하지 않아도 되는가 하는 점이다.
    만약 구현 클래스의 인터페이스가 변경되지 않았다면 인터페이스 클래스의 컴파일은 불필요할 것이다. 왜냐하면 인터페이스 클래스에서 사용하는 것은 구현 클래스가 외부에 공개한 인터페이스만 사용하기 때문이다. 그러나 구현 클래스에 새로운 인터페이스가 추가되었거나 기존 인터페이스가 변경되었을 경우에는 인터페이스 클래스도 재컴파일할 밖에 없다. 그러나 중요한 것은 이용자가 개발한 응용 프로그램의 수정이나 재컴파일은 불필요하다는 점이다.
    1. 구현 클래스의 객체가 인터페이스 클래스의 데이터 멤버로 지정될 자료형은 반드시 최상위 구현 클래스에 대한 포인터 또는 참조 변수로 정의되어야 한다는 저미다.
    그렇지 않을 경우에는 인터페이스 클래스에서 참조하는 구현 클래스가 어느 하나로 한정될 밖에 없다는 문제를 안게 된다.
    • BRIDGE 패턴 정리
    Bridge 패턴은 인터페이스 클래스와 그것을 구현해주는 클래스들의 상속관계를 독립적으로 정의하고, 이들을 마치 다리처럼 연결해주는 형태로 클래스 구조를 가리키며, 일반적인 형태는 [그림 10-4] 같다.


    Bridge패턴이 유용한 경우
    • 인터페이스와 구현 방식이 완전 결합되는 것을 피하고자 , 예를 들어 어떤 구현 방식을 적용할지가 실행 시간에 선택되기를 원할 .
    • 인터페이스와 구현 방식이 각각 서로 다른 형태의 하위 클래스 구조를 가지면서 확장되기를 원할 . 인터페이스 클래스들간 상속 구조와 구현 클래스들간 상속 구조가 독립적이기를 원할 .
    • 인터페이스의 구현 방식이 변경되더라도 인터페이스를 사용하는 Client 소스코드는 다시 컴파일하지 않아야 ( 같은 유용성 때문에 Bridge 패턴은 라이브러리 구축에 가장 많이 사용된다. 왜냐하면 새로운 구현 알고리즘이 나와 라이브러리를 변경해서 버전업시키더라도 기존 라이브러리를 사용해서 작성된 응용 프로그램은 변경할 필요가 없기 때문이다.)
    • 인터페이스의 구현 방식을 Client에게 완전히 숨기고 싶을 . C++ 같은 경우 어떤 클래스의 구성 형태는 클래스의 인터페이스에 의해 대충 드러나는데, 만약 구현 클래스까지 공개하게 되면 구현 클래스의 내부를 어느 정도 짐작할 있게 가능성이 커진다. 이는 애써 만든 소프트웨어 구현 노하우가 쉽게 경쟁사로 넘어갈 있음을 의미하며, 이러한 문제를 해결하기 위해 대부분의 상용 라이브러리에서는 인터페이스 클래스만 공개하고 구현 클래스는 바이너리 형태로만 제공하는 경우가 많다.
    • 어떤 클래스의 상속 구조가 여러 개의 분류 기준에 의해 정의되어 복잡하고, 새로운 하위 클래스 정의가 힘들어 분류 기준마다 독립된 클래스 상속 구조를 정의하고 싶을
    • 하나의 구현 객체를 여러 개의 인터페이스 객체가 공유하게 만들면서도 Client 이를 알지 못하게 하고 싶을
    Bridge 패턴의 장점
    • 인터페이스와 구현을 분리시켜준다.
    • 실행 시간에 구현 객체를 바꾸거나 설정할 있게 해준다.
    • 인터페이스와 구현이 분리됨으로써 구현 내용이 변경되더라도 인터페이스 클래스와 Client 다시 컴파일할 필요가 없다. 이런 특성은 특히 서로 다른 버전의 클래스 라이브러리에 대해 호환성을 보장해야 경우 유용하다.
    • 인터페이스와 구현이 분리됨으로써 전반적인 설계가 계층화, 구조화될 있다.
    • Client 입장에서는 인터페이스와 어떤 객체로 구현이 이루어지는지만 알고, 구체적인 구현 내용은 필요가 없어서 좋다.
    • 인터페이스 클래스와 구현 클래스가 별도의 상속 구조를 가지므로, 서로 독립적으로 확장이 가능하다.
    • 구현의 자세한 부분, 예를 들어 객체를 공유한다든지, Reference Counting 기법을 사용한다든지 하는 것들을 Client에게 숨길 있다.
     
    Bridge패턴과 Adapter패턴 비교
    Bridge패턴과 Adapter패턴을 비교해보면 Adapter패턴은 서로 관계가 없었던 클래스들을 어느 순간 필요에 의해 서로 엮어주기 위해 사용하는 것인 반면, Bridge 패턴은 애초에 인터페이스와 구현을 서로 독립적으로 나누어 설계하기 위해 사용한다는 것이 가장 차이점이다.
    Bridge패턴 대신에 Object Adapter 패턴을 적용하는 경우 다음과 같은 차이가 존재한다.
    • 먼저 목적 측면에서 Bridge 패턴은 인터페이스와 구현을 분리하기 위한 것이고, Adapter패턴은 이미 존재하고 있는 객체를 이용해서 다른 객체의 인터페이스를 만들어주기 위한 것이다.
    • 설계 측면에서 보면 Bridge 패턴은 인터페이스와 구현이 서로 독립적으로 변경될 있게 처음부터 고려한 것이고, Adapter 패턴은 이미 존재하는 클래스를 어떻게 활용할 것인가 하는 측면에서 접근하는 것이 다른 점이다.
    • 구현 측면에서 본다면 Object Adapter 패턴은 구현 클래스의 객체를 데이터 멤버로 반드시 가지고 있을 필요가 없으며, Client 필요로 하는 인터페이스라도 Adaptee 해당하는 클래스가 기능을 제공하지 않을 있다. 반면 Bridge 패턴의 경우에는 항상 구현 클래스의 객체가 있어야 하고 이를 통해서만 구현이 이루어질 있다.
반응형