책정리/GoF 디자인 패턴

7장 최대 N개로 객체 생성을 제한하는 문제(Singleton패턴)

GONII 2019. 1. 26. 14:33
    생성되는 객체의 최대 개수를 제한하는데 있어서 객체의 생성을 요청하는 쪽에서는 일일이 신경쓰지 않아도 되도록 만들어주는 방법은 어떤 것들이 있는지 알아본다.
    • 문제 사례 설명
    게임 개발자는 생성 가능한 유닛의 최대 개수를 제한할 필요가 있다. 왜냐하면 게임에 등장하는 유닛이 너무 많아지면 컴퓨터 자원을 과도하게 사용하게 되고, 이는 게임 전체의 속도를 느리게 만들어 게임에 대한 박진감을 떨어뜨릴 있기 때문이다. 더구나 게이머 입장에서는 유닛이 너무 많으면 이들을 일일이 컨트롤하기 힘들기 때문에 게임에 대한 흥미가 반감될 있을 것이다.
    이처럼 객체를 생성하더라도 최대 N개까지만 객체가 생성되게 제한할 필요가 있는 경우 객체 생성을 제한할 있는 방법은 무엇일까?
    • 다양한 접근 방법 및 SINGLETON 패턴
    문제의 핵심은 어떻게 하면 생성되는 전체 유닛의 개수를 N 이하로 한정시킬 것인가이다. 이때 주의할 것은 유닛의 종류와 무관하게 전체적으로 생성되는 객체의 개수가 N 이하로 한정되어야 한다는 점이다.


    • 기본적인 방법: 전역 변수에 의한 객체 생성, 관리
    일반적으로 이런 문제가 주어졌을 가장 흔히 생각할 있는 방법이 생성되는 모든 유닛을 전역(Global) 변수 형태로 관리하는 것이다. [소스7-1] 같은 형태가 것이다.
    [소스 7-1] 게임 프로그램에서 전역 변수를 통해 유닛 생성 개수를 제한하는 소스코드
    #include <iostream>
    using namespace std;
     
    #define N_UNIT                        100
    #define ATTACK_UNIT                1
    #define PROTECT_UNIT        2
     
    class GameUnit
    {
    public:
    virtual void Display(int x, int y) = 0;
    virtual void DoAction() = 0;
    };
     
    class AttackUnit : public GameUnit
    {
    public:
    void Display(int x, int y) {}
    void DoAction() {}
    };
     
    class ProtectUnit : public GameUnit
    {
    public:
    void Display(int x, int y) {}
    void DoAction() {}
    };
     
    // Global Variable
    GameUnit* pUnitArray[N_UNIT];
     
    void InitUnitArray()
    {
    for (int i = 0; i < N_UNIT; i++)
    pUnitArray[i] = 0;
    }
     
    GameUnit* CreateNewUnit(int unitType)
    {
    for (int i = 0; i < N_UNIT; i++)
    {
    if (pUnitArray[i] == 0)
    pUnitArray[i] = new AttackUnit;
    else
    pUnitArray[i] = new ProtectUnit;
     
    return pUnitArray[i];
    }
    return 0;
    }
     
    void DestroyUnit(GameUnit* pUnit)
    {
    for (int i = 0; i < N_UNIT; i++)
    {
    if (pUnitArray[i] == pUnit)
    {
    delete pUnitArray[i];
    pUnitArray[i] = 0;
    return;
    }
    }
    }
     
    void main()
    {
    InitUnitArray();
     
    GameUnit* pUnit1 = CreateNewUnit(ATTACK_UNIT);
    if (pUnit1 == 0)
    cout << "No More Create Unit" << endl;
     
    GameUnit* pUnit2 = CreateNewUnit(PROTECT_UNIT);
    if (pUnit2 == 0 )
    cout << "No More Create Unit" << endl;
     
    if ( pUnit1 != 0 )
    DestroyUnit(pUnit1);
     
    if ( pUnit2 != 0 )
    DestroyUnit(pUnit2);
    }
    [소스 7-1]에서 CreateNewUnit() 함수는 게임 유닛에 대한 생성 요청에 대해 전역 변수인 pUnitArray 배열 변수에 공간이 있을 때에만 게임 유닛을 생성해주는 방식을 취하고 있다. pUnitArray 배열 변수는 N_UNIT 으로 정의된 수만큼의 공간을 가진다. 따라서 같은 방식을 활용한다면 제한된 개수 내로 객체가 생성되게 관리하는 것이 가능할 것이다.
    하지만 이와 같은 전역 변수에 의한 방식은 클래스 자체가 최대 N개까지의 객체만 생성되게 보장해주는 것이 아니라, 이를 사용하는 측에서 최대 N개까지의 객체만 생성되게 일일이 신경써서 프로그램을 해야 한다는 문제가 있다. 예를 들어 [소스 7-1] 같이 전역 변수가 정의 되고, CreateNewUnit() 같은 함수가 주어진다고 하더라도 개발자가 임의로 AttackUnit이나 ProtectionUnit 클래스의 생성자를 호출해서 객체를 생성하는 것은 얼마든지 가능하며, 이로 인해 어느 순간에 최대 N 이상의 게임 유닛 객체가 존재할 가능성은 항상 존재한다.
    따라서 이런 가능성을 아예 배제하기 위해서는 클래스 자체적으로 최대 N개의 객체만 생성할 있게 제한할 있어야 한다.
    • 패턴 활용 방법: SINGLETON 패턴
    클래스 자체적으로 최대 N개의 객체만 생성되게 제한하려면 먼저 객체가 임의로 생성될 있는 경로를 없애야 한다. 이를 위해서는 클래스마다 객체 생성자를 외부로 공개하면 안된다. 대신 [소스 7-1] CreateNewUnit() 함수처럼 객체가 제한적으로 생성되도록 별도의 멤버 함수를 두고 이를 통해서만 객체를 생성해줄 필요가 있다.
    한편 생성된 객체들으 ㄴ전체적으로 관리되어야 한다. 왜냐하면 생성된 객체들이 관리되고 있어야 다른 객체 생성 요청이 있을 경우, 최대 N개의 제한을 넘어서는지를 판단할 있기 때문이다. 따라서 생성된 객체를 관리하기 위한 별도의 자료구조가 필요하다. 이는 [소스 7-1] pUnitArray 같은 역할을 수행할 것이다. 그런데 이때 생성된 객체들을 관리하는 자료구조는 개별 객체의 데이터 멤버로 존재해서는 것이다. 왜냐하면 개별 객체의 데이터 멤버는 해당 객체가 생성되어 소멸되기 전까지만 유효하므로 전체적인 객체의 생성, 소멸을 관리하기에는 부적합하기 때문이다. 따라서 생성된 객체를 관리하기 위한 자료구조는 클래스 차원에서 클래스 변수 형태로 정의되어야 것이다.
    이와 같은 고려 사항들을 감안하여 최대 N개의 유닛만 생성되게 제한하는 게임 프로그램에 대한 클래스 구조와 인터페이스를 살펴보면 [그림 7-2] 같다.


    [그림 7-2]에서 클래스의 생성자는 모두 protected 영역에 포함되어 있다.(멤버 함수명 앞에 #으로 표시됨) 이는 외부에서 직접 생성자를 호출하여 객체를 생성하는 것을 막기 위해서다. 물론 외부에서 호출하는 것을 막기 위해서는 private 여역에 생성자를 정의할 수도 있으나 이렇게 하면 하위 클래스에서도 상위 클래스의 생성자를 호출하지 못하는 상황이 발생하고 이는 클래스의 객체 생성을 원천적으로 불가능하도록 만들기 때문에 문제가 된다. 따라서 클래스의 생성자는 protected 영역에 놓여지는 것이 적당하다.
    복사 생성자(Copy Constructor) protected 영역에 정의되어야 한다. 만약 그렇지 않을 경우에는 복사 생성자를 통해 임의로 객체가 생성될 있는 가능성이 존재하기 때문이다.
    pUnitArray 배열 변수와 CreateInstance() 멤버 함수는 각각 클래스 변수와 클래스 멤버 함수로 정의되었다. 여기서 주의할 것은 pUnitArray 배열 변수 뿐만 아니라 CreateInstance() 멤버 함수도 클래스 멤버 함수로 정의되었다는 사실이다. 왜냐하면 CreateInstance() 멤버 함수 또한 어느 객체를 위해 존재하는 것이 아니라 클래스 전체적으로 객체를 생성해주기 위해 존재하는 것이기 때문이다.
    이렇게 정의된 클래스 구조를 이용하게 되면 모든 객체의 생성이 CreateInstance() 멤버 함수를 통해서만 가능해지고, CreateInstance() 멤버 함수는 생성된 객체의 개수를 관리할 있게 되므로 정확히 N개까지만 객체가 생성되도록 제한이 가능해진다. 이처럼 객체가 생성되는 개수를 제한하는 형태의 설계를 Singleton패턴이라고 한다. Singleton이라는 이름은 극단적으로 제한되는 객체의 개수가 1개일 때를 감안한 것이다.
    • 샘플 코드
    유닛의 개수를 N 이하로 제한하기 위해 Singleton패턴을 적용한 게임 프로그램의 실제 구현 형태는 [소스 7-2] 같다.
    [소스 7-2] Singleton 패턴을 통해 최대 N개의 유닛만 생성토록 제한한 게임 프로그램의
    #include <iostream>
    using namespace std;
     
    #define N_UNIT 100
     
    class GameUnit
    {
    public:
    static void InitUnitArray()
    {
    for (int i = 0; i < N_UNIT; i++)
    pUnitArray_[i] = 0;
    }
    static GameUnit* CreateInstance() { return 0; }
     
    static  void DestroyUnit(GameUnit* pUnit)
    {
    for (int i = 0; i < N_UNIT; i++)
    {
    if (pUnitArray_[i] == pUnit)
    {
    delete pUnitArray_[i];
    pUnitArray_[i] = 0;
    return;
    }
    }
    }
     
    virtual void Display(int x, int y) = 0;
    virtual void DoAction() = 0;
    protected:
    GameUnit(){}
    GameUnit(const GameUnit& rhs) {}
    static GameUnit* pUnitArray_[N_UNIT];
    };
     
    // 클래스 변수 정의
    GameUnit* GameUnit::pUnitArray_[N_UNIT];
     
    class AttackUnit : public GameUnit
    {
    public :
    static GameUnit* CreateInstance()
    {
    for (int i = 0; i < N_UNIT; i++)
    {
    if (pUnitArray_[i] == 0)
    {
    pUnitArray_[i] = new AttackUnit;
    return pUnitArray_[i];
    }
    }
    return 0;
    }
    void Display(int x, int y){}
    void DoAction(){}
    protected:
    AttackUnit(){}
    AttackUnit(const AttackUnit& rhs){}
    };
     
    class ProtectUnit : public GameUnit
    {
    public:
    static GameUnit* CreateInstance()
    {
    for (int i = 0; i < N_UNIT; i++)
    {
    if (pUnitArray_[i] == 0)
    {
    pUnitArray_[i] = new ProtectUnit;
    return pUnitArray_[i];
    }
    }
    return 0;
    }
    void Display(int x, int y){}
    void DoAction(){}
    protected:
    ProtectUnit(){}
    ProtectUnit(const ProtectUnit& rhs){}
    };
     
    void main()
    {
    GameUnit::InitUnitArray();
     
    GameUnit* pUnit1 = AttackUnit::CreateInstance();
    if (pUnit1 == 0)
    cout << "No More Create Unit" << endl;
     
    GameUnit* pUnit2 = ProtectUnit::CreateInstance();
    if (pUnit2 == 0)
    cout << "Nor MOre Create Unit" << endl;
     
    GameUnit::DestroyUnit(pUnit1);
    GameUnit::DestroyUnit(pUnit2);
    }

    [소스 7-2]에서 살펴보면 객체를 생성할 때 생성자를 호출하는 것이 아니라 생성하고 싶은 객체의 종류에 해당하는 클래스의 CreateInstance() 멤버 함수를 호출하는 형태임을 알 수 있다. 이는 각 클래스의 생성자가 모두 protected 영역에 숨겨져 있어 직접적으로 호출할 수 없기 때문이다. 한편 각 클래스의 CreateInstance() 멤버 함수는 클래스 변수인 pUnitArray_ 배열 변수를 참조해서 빈 공간이 있을 경우에만 객체를 생성시켜주기 때문에 공격 유닛이든 방어 유닛이든 전체적으로 최대 N개의 유닛 이하로 객체가 생성되게 보장해준다.
    [소스 7-2]에서처럼 클래스를 구현하게 되면 객체를 생성하는 입정에서는 객체를 생성하기 위해 호출하는 함수가 다소 달라졌을 일일이 생성된 객체의 최대 개수가 N 이하인지를 신경 필요가 없다. 왜냐하면 클래스 자체에서 N 이상의 객체 생성을 방지해주기 때문이다.
    • 구현 관련 사항
    Singleton 패턴의 구현 시에 추가로 고려해야 사항들은 다음과 같다.
    먼저 [소스 7-2]에서 보듯이 Singleton 패턴의 구현 모든 생성자는 protected 영역에 정의되어야 한다. 이는 생성자를 통해 임의로 객체를 생성하려는 시도가 있을 경우 컴파일러가 자동으로 걸러내도록 하기 위해서다. 이때 특히 일반 생성자 뿐만 아니라 복사 생성자도 반드시 protected 영역에 정의해주어야 한다. 만약 그렇지 않을 경우에는 복사 생성자를 통해 임의로 객체를 생성하는 것이 가능해지기 때문에 문제가 있다.
    [소스 7-3] 복사 생성자를 정의하지 않거나 공개할 경우 Singleton 패턴의 동작 오류
    #include <iostream>
    using namespace std;
     
    class Singleton
    {
    public:
    static Singleton* CreateInstance()
    {
    if (pInstance_ == 0)
    pInstance_ = new Singleton;
    return pInstance_;
    }
    int GetData() { return data_; }
    void SetData(int val) { data_ = val; }
    protected:
    Singleton(){ data_ = 10; }
    private:
    static Singleton* pInstance_;
    int data_;
    };
     
    Singleton* Singleton::pInstance_ = 0;
     
    void main()
    {
    Singleton* p = Singleton::CreateInstance();
    Singleton a(*p); // 복사 생성자 호출
     
    a.SetData(20);
     
    cout << "p->data = " << p->GetData() << endl;
    cout << "a. data = " << a.GetData() << endl;
    }
    C++에서는 복사생성자를 명시적으로 정의하지 않아도 컴파일러가 기본적으로 public 영역에 복사 생성자를 제공하게 되며, 이로 인해 최대 개수에 제한을 받지 않는 객체가 생성될 있는 상황이 벌어지게 된다. 따라서 제대로 Singleton패턴을 구현하기 위해서는 반드시 복사 생성자도 protected 영역에 정의해줄 필요가 있다.
    Singleton 패턴의 구현에서 또하나 고려해야 사항은 생성되는 객체가 어떤 하위 클래스의 객체인지와는 무관하게 동일한 자료형으로 관리가 이루어져야 한다는 사실이다. 예를 들어 [소스 7-2]에서 보듯이 생성되는 객체는 AttackUnit 객체일 수도 있고, ProtectUnit 객체일 수도 있다. 나아가 만약 GameUnit 클래스가 추상 클래스가 아니면 생성되는 객체가 GameUnit 객체일 수도 있다. 따라서 Singleton 패턴에서 생성된 객체들을 저장, 관리하기 위한 자료구조는 클래스 상속 구조 상에 있는 어떤 클래스의 객체든지 저장, 관리할 있어야 한다. 이런 점을 고려할 생성된느 객체들을 관리하기 위해 사용 가능한 자료 구조는 [소스 7-2] pUnitArray_배열 변수에서도 보듯이 바로 최상위 클래스에 대한 포인터 변수일 것이다.
    Singleton 패턴의 구현에서 고려해야 다른 사항은 바로 여러 개의 하위 클래스가 있을 특정 하위 클래스의 객체를 생성하게 하는 방법이 무엇인가 하는 점이다. 모든 하위 클래스에 대해서도 생성자들이 숨겨져 있고, CreateInstance() 멤버 함수가 따로 정의되어 있는 경우에는 [소스 7-2]에서 살펴보았듯이 객체를 생성하는 측에서 원하는 클래스를 명시하고 클래스의 CreateInstance() 멤버 함수를 호출하는 것이 유일한 방법일 것이다. , AttackUnit::CreateInstance() 같은 형태로 호출하게 것이다.
    그러나 만약 최상위 클래스의 생성자만 숨기고 하위 클래스의 생성자들은 숨기지 않는 형태의 구현에서는 다음과 같이 가지 다른 방법을 생각할 있다.
    • 먼저 환경 변수나 함수 인자에 값에 따라 생성할 객체의 클래스를 정해줄 있다. 방식은 간단한 반면 새로운 하위 클래스가 추가될 경우 CreateInstance() 멤버 함수 내부를 수정해야 하는 문제가 따른다.
    • 다른 방법으로는 미리 하위 클래스마다 객체를 생성하여 레지스트리 등에 등록해두었다가 객체 생성 요청이 주어지면 원하는 객체를 생성해주는 방식이다. , [소스 7-5] 같은 형태가 것이다. 방식은 CreateInstance() 함수 내에서 모든 하위 클래스들을 필요가 없다는 장점이 있는 반면 사전에 생성할 하위 클래스의 객체를 등록해야 한다는 단점이 있다. 때문에 [소스 7-5]에서는 MySingleton 클래스의 객체를 static 변수로 정의하고 있다.
    • 레지스트리에 등록해두는 것과 유사한 방식으로 초기에 객체를 생성해두고, 객체 생성 요청이 있을 경우 이를 복제해주는 형태의 Prototype 패턴 방식도 적용 가능한 방식 하나다.
    [소스 7-5] 레지스트리에 객체를 등록해두었다가 객체 생성을 수행해주는 방식
    #include <iostream>
    #include <string>
    #include <map>
    using namespace std;
     
    class Singleton
    {
    public:
    static void Register(const char* name, Singleton*);
    static Singleton* CreateInstance(string name);
     
    private :
    static Singleton* pInstance_;
    static map<string, Singleton*> registry_;
    };
     
    void Singleton::Register(const char* name, Singleton* pObj)
    {
    registry_[name] = pObj;
    }
     
    Singleton* Singleton::CreateInstance(string name)
    {
    if (pInstance_ == 0)
    {
    pInstance_ = registry_[name];
    }
     
    return pInstance_;
    }
     
    class MySingleton : public Singleton
    {
    public :
    MySingleton()
    {
    Singleton::Register("MySingletion", this);
    }
    };
     
    static MySingleton myObj;
     
    void main()
    {
    Singleton* pObj = Singleton::CreateInstance("MySingleton");
    }
    Singleton 패턴의 구현 시에 고려해볼 다른 사항은 CreateInstance() 멤버 함수가 되돌려주는 자료형이 포인터라는 점이다. 만약 되돌리는 자료형이 포인터가 아니라면 각각의 하위 클래스에서 생성된 객체가 온전히 관리되지 못하게 것이다.
    • SINGLETON 패턴 정리
    [그림 7-3]에서 Singleton 클래스의 하위 클래스는 가지 형태로 구현될 있는데, 하나는 모든 생성자를 protected 영역에 숨기는 방식이고, 다른 하나는 특별히 생성자들을 숨기지 않고 외부에 공개하는 방식이다.


    Singleton패턴이 유용한 경우
    • 어떤 클래스의 객체가 최대 N 이하로만 존재해야 경우 이를 관리하기에 유용하다. ㅌ특히 N 값은 1 경우가 많다.
    • 상속 관계에 놓인 클래스들에 대해 전체적으로 생성되는 객체의 최대 개수를 제한하고자 경우 유용하다.
    Singleton 패턴의 장점
    • 전역 변수 방식은 최대 N 이하의 객체만 생성되어 존재한다는 사실을 확실히 보장할 없다. 특히 여러 사람이 공동 작업을 하는 경우에는 이런 문제가 발생될 소지가 크다. 반면 Singleton 패턴 방식은 클래스 차원에서 최대 N 까지만 객체가 생성되게 보장해주기 때문에 이와 같은 문제는 걱정할 필요가 없다.
    • 전역 변수 방식으로 객체를 생성하기 위해서는 처음 객체를 초기화시키기 위한 충분한 정보를 알고 있어야 한다. 반면 Singleton 패턴의 경우에는 CreateInstance() 클래스 멤버 함수가 불려질 때까지는 객체가 생성되지 않으므로, 처음부터 객체를 생성하기 위해 모든 정보를 필요가 없다.
    • C++ 컴파일러 등에서는 전역 변수에 해당하는 객체들을 생성하는 순서가 일정하게 정해져 있지 않으므로, 전역 변수 객체들간의 생성 순서가 지정되어야 경우에는 문제가 발생할 소지가 있다. 그러나 Singleton패턴에서는 이런 문제가 발생하지 않는다.
    • 전역 변수 방식의 경우에는 해당 객체가 사용되든 안되든 무조건 객체를 생성하기 때문에 비효율적이다.
반응형