책정리/GoF 디자인 패턴

12장 특정 객체의 기능 동적 추가, 삭제 문제(Decorator 패턴)

GONII 2019. 1. 30. 22:24
    • 문제 사례 설명
    게임 프로그램에서 아이템 취득에 따른 기능의 추가나 삭제는 어떤식으로 제공되었을까?
    • 다양한 접근 방법 및 DECORATOR 패턴
    다른 종류의 기능 아이템에 대해서는 공격 방향의 추가, 삭제와 비슷하게 설계를 고려해보면 것이다.
    기본적으로 게임 플레이어가 게임을 시작하면 전방으로만 총을 있다. 그러다 아이템을 먹으면 전후좌우 모두 공격 있게 된다. 그러다 실수로 추가 공격 방향 삭제 아이템을 취득하게 되면 초기 상태로 되돌아가 전방 공격만 가능하게 된다.
    • 기본적인 방법: 클래스 재정의를 통한 객체 기능 조정 방법
    공격 방향의 추가, 삭제 문제만 놓고 본다면 객체 내부에 공격 방향에 대한 데이터 멤버를 두고, 값의 변화에 따라 객체의 공격 방향을 조정해주는 것이 가장 손쉬운 방법이 있다. , [소스 12-1] 같이 클래스 정의 시에 미리 객체의 공격 방향을 저장할 있는 데이터 멤버를 정의해두고 값을 참조해서 객체의 공격 방향을 결정하게 만드는 것이다. 물론 이때 아이템 취득에 따라 공격 방향을 추가, 삭제하기 위한 인터페이스는 제공될 것이며, 이를 통해 객체의 공격 방향 상태 정보가 동적으로 변경될 있을 것이다.
    [소스 12-1] 객체 상태 관리를 통한 객체 기능 조정 소스 코드
    #include <iostream>
    using namespace std;
     
    #define FRONT_DIRECTION        1
    #define SIDE_DIRECTION        2
    #define REAR_DIRECTION        4
     
    class Item
    {
    public:
    Item(int dir) { direction_ = dir; }
    int GetDirection() { return direction_; }
    private:
    int direction_;
    };
     
    class Airplane
    {
    public:
    void AddItem(Item* pItem)
    {
    direction_ += pItem->GetDirection();
    }
    void Attack()
    {
    if(direction_ & SIDE_DIRECTION)
    {
    // 측면 공격
    cout << "측면 공격 " << endl;
    }
     
    if (direction_ & REAR_DIRECTION)
    {
    // 후방 공격
    cout << "후방 공격" << endl;
    }
     
    // 전방공격
    cout << "전방 공격" << endl;
    }
     
    private:
    int direction_;
    };
     
    void main()
    {
    Item side(SIDE_DIRECTION);
    Airplane onePlayer;
     
    onePlayer.AddItem(&side);
    onePlayer.Attack();
    }
    방법은 근본적으로 특정 객체에게만 기능을 추가하거나 삭제하려는 취지에 벗어난다고 있다. 왜냐하면 방법은 특정 객체의 기능을 추가, 삭제하는 것이 아니라, 클래스 정의를 통해 클래스에 속하는 모든 객체들에게 새로운 기능을 추가한 것이기 때문이다. [소스 12-1]에서 Airplane 클래스는 공격 방향을 추가, 삭제할 있는 기능을 클래스에 속하는 모든 객체들에게 부여한 것이며, 특정 객체에게 공격 방향을 추가 또는 삭제하는 것이 아니라는 점이다.
    [소스 12-1] 같은 해결 방법은 게임에서 제공되는 아이템들이 어떤 기능과 관련되는 것인지를 미리 알고 클래스를 설계할 경우에만 유용한 방법이라고 있다. 만약 그렇지 않다면, 새로운 기능을 제공하는 아이템이 등장할 때마다 Airplane 클래스는 내부 자료구조와 외부에 제공하는 인터페이스를 변경시켜야 것이다. 따라서 새로운 아이템이 등장할 때마다 방법을 사용하면 클래스 수정 작업을 병행해야 하는 문제에 부딪힐 것이다.
    • 또 다른 방법: 클래스 상속을 통한 객체 기능 조정 방법
    기존 클래스 설계를 변경시키지 않고 객체의 기능을 확장시킬 있는 가지 방법은 클래스 상속을 이용하는 방식이다. 이때 하위 클래스 객체는 상위 클래스 객체보다 확장된 기능을 가지는 반면, 상위 클래스 객체는 역으로 하위 클래스 객체보다 축소된 기능을 가진다고 있다. 따라서 클래스 상속을 이용하면 객체의 기능 추가 또는 삭제를 수행할 있을 것이다.


    [그림 12-1] 클래스 구조에 따라 객체가 생성되었다고 가정해보자. 처음 아무런 아이템도 획득하지 못한 객체는 Airplane 클래스의 객체여야 것이다. 그런데 만약 게임 도중 측방 공격을 있는 아이템을 취득했다고 해보자. 그러면 처음 객체는 측방 공격을 수행할 있는 SideAttackAirplane 클래스의 객체로 변경되어야 것이다. 이를 위해서는 처음 객체를 소멸시키고 다시 객체를 생성하든지, 처음 객체를 강제로 SideAttackAirplane 객체로 Down Casting 해야 것이다.
    그런데 우리가 원하는 것은 특정 객체를 소멸시키고 새로운 객체를 만드는 형태를 통해 기능을 추가하거나 삭제하도록 만드는 것이다. 아니다. 또한 Down Casting 잘못된 자료형의 변환을 초래하여 프로그램이 정상적으로 수행되지 못하게 만들 있다. 그리고 여러 기능들이 복합된 객체를 생성하려면 AllAttackAirplane 클래스처럼 다중 상속을 클래스를 정의해야 하는데, 복합될 있는 기능 요소들이 늘어나면 정의해야 하위 클래스의 개수도 기하급수적으로 늘어난다는 것이 클래스 설계에서 커다란 장애가 것이다. 따라서 클래스 상속을 활용하는 방법도 특정 객체에게 기능을 추가하거나 삭제하는데에는 부적합하다고 있다.
    • 패턴 활용 방법: DECORATOR 패턴
    일단 클래스를 변경시키지 않는 이상 객체 스스로 기능을 추가하거나 삭제할 수는 없다. 따라서 객체 차원에서 기능을 추가하거나 삭제할 있는 유일한 방법은 다른 객체를 이용해서 원래 객체를 꾸며주는 형태를 취하는 것이다. , 새로운 기능을 가진 객체가 원래 객체를 꾸며주면서 새로운 기능도 수행해주고 원래 객체가 가진 기능도 수행해주면 마치 새로운 기능이 추가된 것처럼 보이도록 만들 있는 것이다. 마찬가지로 이렇게 꾸며주고 있던 객체를 없애게 되면 추가되었던 기능이 삭제되는 효과를 있을 것이다.


    이처럼 객체들간의 참조 연결 고리를 이용해서 객체의 기능을 추가하거나 삭제하게 된다면, 특정 객체에 대해 동적으로 기능을 추가, 삭제하는 것이 얼마든지 가능할 것이다.
    여기서 [그림 12-2] 같이 객체들간 참조 연결 고리를 만들고 이를 실제로 동작하게 하기 위해서는 어떤 형태의 클래스 구조가 필요할까?
    우선 외부 Client 입장에서는 객체들의 참조 연결 고리가 어떻게 형성되는지 상관없이 동일한 형태로 기능 수행을 요청할 있어야 한다. 왜냐하면 이는 소스코드의 수정없이 동적으로 객체의 기능을 추가하거나 삭제할 있는 전제 조건이기 때문이다. 이를 위해서는 객체 참조 연결 고리에 포함되는 객체들이 모두 동일한 자료형으로 표현되어야 하며 이들이 외부에 제공하는 인터페이스도 모두 동일해야 한다. 클래스 설계 이런 조건을 만족시켜주기 위해서는 객체들이 속하는 클래스의 상위에 공통 부모 클래스를 정의하고 상속 관계에 놓여진 클래스들이 모두 동일한 인터페이스를 가지도록 하면 것이다.
    이런 점들을 고려해서 주어진 문제의 게임 프로그램에 대해 클래스를 설계해보면 [그림 12-3] 같은 형태가 있다.


    여기서 Airplane 클래스는 상속 구조 내의 어떤 객체라도 동일하게 참조할 있도록 만들어주는 자료형의 역할과 함께 외부에 공통된 인터페이스를 제공하는 역할을 수행한다. 반면 FrontAttackAirplane 클래스는 [그림 12-2] 마지막에 놓인 객체처럼 이상 다른 객체에게 기능을 추가해주지 않기 위한 클래스이며, SideAttackAirplane RearAttackAirplane 클래스는 반대로 다른 객체에게 기능을 추가해주기 위한 객체들을 위한 클래스다. SideAttack클래스와 RearAttack 클래스가 Airplane 클래스를 곧바로 상속하는 형태로 클래스를 설계하는 것도 가능하지만, 이렇게 경우에는 클래스가 각각 다른 객체를 참조하기 위한 데이터 멤버를 정의해야 하고, 다른 객체들을 참조하는 객체들이 공통으로 가져야 하는 특성이나 인터페이스도 유지, 관리하는 것이 힘들어 있다.
     
    이처럼 특정 객체에게 동적으로 기능을 추가하거나 추가했던 기능을 삭제하기 위해 사용되는 [그림 12-3] 같은 클래스 구조를 Decorator패턴이라고 한다. 이때 Decorator 다른 객체에게 새로운 기능을 추가하는 것이 꾸며주는 것과 비슷하기 때문에 붙여진 이름이다.
    • 샘플 코드
    [소스 12-2] 게임 프로그램에서 공격 방향의 동적 추가, 삭제를 수행하는 샘플 코드
    #include <iostream>
    using namespace std;
     
    class Airplane
    {
    public:
    virtual void Attack() = 0;
    };
     
    class FrontAttackAirplane : public Airplane
    {
    public:
    void Attack()
    {
    // 전방 공격
    cout << "전방 공격" << endl;
    }
    };
     
    class Decorator : public Airplane
    {
    public:
    Decorator(Airplane* pObj) { pComponent_ = pObj; }
    virtual ~Decorator() = 0;
     
    virtual void Attack()
    {
    if (pComponent_ != 0)
    pComponent_->Attack();
    }
     
    private:
    Airplane* pComponent_;
    };
     
    class SideAttackAirplane : public Decorator
    {
    public:
    SideAttackAirplane(Airplane* pObj)
    : Decorator(pObj)
    {
    }
     
    void Attack()
    {
    Decorator::Attack();
    // 측면 공격
    cout << "측면 공격" << endl;
    }
    };
     
    class RearAttackAirplane : public Decorator
    {
    public:
    RearAttackAirplane(Airplane* pObj)
    : Decorator(pObj)
    {
    }
     
    void Attack()
    {
    Decorator::Attack();
    // 후방 공격
    cout << "후방 공격" << endl;
    }
    };
     
    void main()
    {
    Airplane* pFrontAttackAirplane = new FrontAttackAirplane;
    Airplane* pSideAttackAirplane = new SideAttackAirplane(pFrontAttackAirplane);
    Airplane* pRearAttackAirplane = new RearAttackAirplane(pSideAttackAirplane);
     
    pRearAttackAirplane->Attack();
    delete pRearAttackAirplane;
     
    pSideAttackAirplane->Attack();
    }
    [소스 12-2] [그림 12-3] 클래스 구조를 그대로 구현한 것이라고 있다. 다만 주지할 사항은 SideAttack RearAttack 클래스의 객체를 생성하는 방식에 대한 것이다. 여기서 클래스의 생성자는 인자로 참조할 객체를 직접 전달받고 있다. 이런 방식은 Decorator 하위 클래스에 별도로 참조할 객체를 설정하기 위한 인터페이스를 정의하지 않아도 되게 하는 장점이 있는 반면, 객체 생성 시에 항상 어떤 객체를 참조하는지를 정해주어야 하는 불편함이 존재한다. 이런 불편함을 없애려면 Decorator 클래스에 별도로 참조할 객체를 설정할 있는 인터페이스를 추가하면 되지만 이것은 상속 관계에 놓여져 있는 클래스들은 모두 동일한 인터페이스를 가지는 것이 바람직하다는 클래스 원칙을 깨뜨리는 결과를 낳으며, 이는 외부 Client입장에서 객체들을 서로 구분해야 하는 불편을 초래할 있다. 물론 이런 문제는 Airplane 클래스에도 참조할 객체를 설정하는 인터페이스를 두는 대신 아무런 수행도 하지 않게 하면 해결될 수도 있을 것이다.
    • 구현 관련 사항
    Decorator 패턴에서 상속 구조 상에 놓인 클래스들은 모두 최상위 클래스의 인터페이스를 지원해야 한다. 왜냐하면 Client 입장에서 가장 중요한 것은 동적으로 객체의 기능이 추가되거나 삭제되는 과정에서 참조하는 객체가 바뀌더라도 소스 코드가 변경되면 되는데, 이를 위해서는 Client 어떤 객체를 참조하든지 동일한 자료형과 인터페이스로 동작할 있어야 하기 때문이다.
    Decorator 패턴에서 최상위 클래스는 최대한 가볍게 유지되는 것이 좋다. 최상위 클래스는 있으면 데이터 멤버를 정의하지 않는 것이 좋고, 외부에 제공할 인터페이스도 가능하면 최소로 정의하는 것이 바람직하다. 왜냐하면 Decorator 패턴을 활용해서 객체에 기능을 추가, 삭제하려면 많은 객체가 생성되는데, 최상위 클래스에 데이터 멤버가 많이 정의되어 있으면 객체 생성에 따라 불필요하게 메모리를 많이 사용하게 있기 때문이다.
    고려해야 다른 사항은 중간 클래스를 두는 문제다. 중간 클래스로 Decorator 클래스를 정의하지 않는다면 하위 클래스들이 바로 최상위 클래스를 상속하게 되는데 이는 하위 클래스들이 공통으로 관리해야 인터페이스나 데이터 멤버들이 있을 경우 문제가 있다. 왜냐하면 하위 클래스들은 계속해서 추가될 있는데, 최상위 클래스는 Decorator 역할을 하는 하위 클래스 뿐만 아니 일반 하위 클래스도 대표하기 때문에 Decorator 역할의 하위 클래스들을 위한 공통 인터페이스와 데이터 멤버를 정의하기 힘드므로 새로 추가되는 하위 클래스에서 공통된 인터페이스와 데이터 멤버를 매번 정의해야 하기 때문이다.
    밖에 Decorator 패턴의 구현에서 중간 클래스인 Decorator 추상클래스로 정의된다는 점을 주의할 필요가 있다.
    • DECORATOR 패턴 정리
    Decorator 패턴은 특정 객체에게 동적으로 새로운 기능을 추가하거나, 이미 추가했던 기능을 삭제하기 위하여 객체가 다른 객체를 참조할 있게 고안된 것이며 일반적인 클래스 구조는 [그림 12-5] 같다. 이때 객체들은 참조 연결 고리를 형성하며, 이를 따라가면서 자신이 수행해야 고유 기능을 수행해주므로, Client 입장에서는 전체적으로 기능들이 추가되어 동작하는 것처럼 보여지게 된다.


    Decoreator패턴이 유용한 경우
    • 다른 객체에게 영향을 주지 않고, 특정 객체에게 동적으로 새로운 기능을 추가하고자 . 특히 Client 측에서는 이렇게 새로운 기능이 추가된 객체와 그렇지 않은 객체를 따로 구분하고 싶지 않을 .
    • 특정 객체에게 동적으로 추가된 기능을 삭제하고 싶을 . , 경우 추가된 기능에 해당하는 객체가 아닌 원래 객체가 가진 기능은 삭제할 없다.
    • 클래스 상속을 통한 기능 확장이 불가능하거나 어려울 . 예를 들어 서로 독립된 상속 관계가 많아 이들을 조합하면, 너무 많은 하위 클래스가 만들어질 우려가 있는 경우 또는 클래스 정의가 숨겨져 있어 상속이 불가능할 .
    Decorator 패턴의 , 단점
    • 객체에 기능을 추가하고자 정적인 상속 관계를 이용하는 것보다 훨씬 유연한다. 왜냐하면 Decorator 패턴은 동적으로 원하는 객체에게 기능을 추가하거나 삭제할 있는데 반해, 정적인 상속 관계는 새로운 기능이 필요할 때마다 클래스를 정의해야 하는 문제가 있기 때문이다.
    • 동일한 기능을 반복하는 것이 간편해진다. 왜냐하면 Decorator패턴에서는 동일한 기능을 반복 수행하고 싶을 반복 수행하고 싶은 횟수만큼 객체를 생성한 이들을 하나의 연결 고리로 연결해서 원하는 기능 수행을 요청하면 연결 고리를 따라가면서 동일한 기능이 쉽게 반복 수행되기 때문이다. 반면 클래스 상속 관계 등을 활용한다면 이러한 기능 반복은 쉽지 않을 것이다.
    • 필요한 만큼 노력을 들이는 접근 방식이다. , Decorator 패턴은 복잡한 클래스들을 정의함으로써 미리 예측 가능한 모든 특성들을 지원하는 것이 아니라, 처음에는 간단한 클래스만을 정의해두었다가 필요할 경우 새로운 클래스들을 조금씩 확장시켜나가는 방식이다.
    결과적으로 응용프로그램은 자신이 사용하지 않는 특성에 대해서는 신경쓸 필요가 없으며, 미리 예측하지 못한 특성을 지원하기 위해 새로운 클래스를 독립적으로 정의하는 것도 매우 쉽다.
    • Client 입장에서는 대개 Decorator객체와 그렇지 않은 객체를 구분할 필요가 없지만, 그렇다고 객체가 동일한 객체는 아니라는 점을 명심해야 한다.
    • 클래스 수는 줄어드는 반면 객체의 수는 많아질 있다. 또한 이들 객체들은 서로 연결된 형태에 따라 서로 다른 객체임에도 불구하고 비슷비슷하게 보일 있다. 따라서 Decorator 패턴을 적용한 소스코드는 Decorator 패턴에 대해 알고 있지 못할 경우 이해하거나 디버깅하기가 쉽지 않을 있다.
    Decorator패턴과 Strategy 패턴의 차이
    • Decorator패턴은 이미 존재하는 객체에게 새로운 기능을 더하는 역할을 한다. Strategy 패턴은 객체 내부의 구체적인 동작이나 알고리즘을 외부 객체에게 위임시키기 위한 것이다.
    • Strategy패턴은 기본적으로 회귀적인 참조를 가지는 형태가 아니나, 구체적인 내부 구현을 위임시키는 것이 반복될 경우에는 [그림 12-6] 같이 객체간 참조 연결고리가 길게 형성될 있다.
    • Decorator 패턴의 경우에는 새로 더해지는 객체가 어떤 것이 것인지 마지막 객체가 굳이 필요가 없지만, Strategy 패턴에서는 처음 객체가 중심이며 어떤 객체들로 구현을 위임시킬 있는지를 알고 있어야하고 이를 유지, 관리해야 한다.
    • Strategy 패턴의 경우 Strategy 클래스가 자기 자신의 인터페이스를 따로 가질 있는데 반해 Decorator 패턴은 Component 클래스의 인터페이스를 따라야 한다는 차이가 있다.


     
반응형