객체지향 디자인 패턴에는 싱글톤이라는 패턴이 있다.

 

싱글톤이란?

어떠한 클래스의 인스턴스가 프로세스 내에 단 1개만 생성되도록 제한하는 디자인 패턴.

 

예를 들면, 게임 내에서 플레이어가 있고 이 플레이어가 지니고 있는 스텟이 있다고 했을 때, 플레이어의 스텟 정보는 다음 레벨로 넘어가더라도 유지되어야 한다.

 

하지만, 플레이어 객체 내부에 스텟 데이터를 저장해두었다면 레벨이 바뀌는 순간, 객체는 파괴되어버리고 스텟 데이터는 사라지게 된다. 다음 레벨에서 스폰되는 플레이어 객체는 기존과는 다른 스텟 데이터를 가지고 있게 되는 것이다.

 

이러한 이유로 게임 프로그래밍에선 데이터를 가능한 분리하는 것이 좋다. 

그렇다면, 스텟 정보를 다른 클래스로 분리를 시켰는데 이 클래스가 여러개가 있어야 할까?

플레이어의 Hp, 공격력 등의 정보를 담은 클래스의 인스턴스는 게임에 1개만 있으면 충분하다. 2개 3개가 되면 메모리만 낭비될 뿐이다.

 

이런 상황에 데이터 정보를 담은 클래스의 인스턴스를 단 1개로 제한하기 위해 사용할 수 있는 디자인 패턴이 싱글톤 패턴이다.


어떻게 사용하는가?

방식은 여러가지가 있다.

먼저 가장 간단한 방식이다.

 

1. 이른 초기화

class Singleton
{
    private:
        Singleton() {}
        ~Singleton() {}
        
        static Singleton instance;

    public:
        static Singleton &GetIstance()
        {
            return instance;
        }
};

Singleton Singleton::instance;

 

생성자와 소멸자를 private로 선언하여, 외부에서 생성 및 소멸을 할 수 없도록 한다.

이후, static으로 전역 변수를 선언하여 인스턴스를 생성해준다.

 

다른 클래스에서 싱글톤 객체를 참조하고 싶다면, Singleton::GetInstance() 함수를 호출하여 참조자를 반환받아 사용한다.

 

아주 간단하게 객체를 단 1개만 생성되도록 해보았다.

소멸자와 생성자가 private이기 때문에 객체의 생성은 내부에서만 가능하다.

내부에서는 static으로 단 1개만 생성하였기 때문에 프로세스 내에는 인스턴스가 1개만 존재하게 된다.

 

하지만, 이 경우 문제가 몇가지 있다.

 

1. 전역 변수의 생성, 초기화 시기는 정확히 정의되어 있지 않다.

C++에서 전역변수를 여러개 선언하게 된다면, 이 객체들의 생성 시기는 정확하게 정해져 있지 않다.

A, B, SingleTon 이렇게 3개의 전역 변수를 선언했다고 가정하면

A->B->SingleTon 순서로 생성될 수도 있지만, B->SingleTon->A 의 순서로 생성될 수도 있는 것이다.

 

만약, A의 생성자에서 SingleTon을 참조했다고 가정해보자.

SingleTon->A->B 의 순서로 전역 객체가 생성되었다면 별 다른 문제가 없지만

A->SingleTon->B의 순서로 생성된다면 문제가 발생한다. A에서 참조하고자 하는 대상이 아직 존재하지 않기 때문이다.

 

이런 경우는 흔하지 않은 경우지만, 이른 초기화 방식이 안전하지 않음을 명확히 알고 있어야 한다.

 

2. 프로세스가 시작되는 순간부터 끝나는 순간까지 메모리를 계속 점유한다.

싱글톤 객체가 자주 사용되고 계속 참조된다면 상관이 없지만, 자주 사용되지 않는다고 한다면 이는 비효율적일 수 있다.

처음부터 전역변수로 생성되고 프로세스가 끝날 때 까지 계속 메모리 상에 존재하기 때문에 메모리 낭비가 발생할 수 있는 것이다.

 

이러한 문제점을 해결하기 위해 나온 두 가지 방식이 있다.


2. 늦은 초기화 - 다이나믹 싱글톤

class Singleton
{
    private:
        Singleton() {}     
        ~Singleton() 
        {
            delete Instance;
            Instance = nullptr;
        }
        
        static Singleton* Instance;

    public:
        static Singleton* GetIstance()
        {
            if(Instance == nullptr)
            {
                Instance =  new Singleton();
            }
            
            return Instance;
        }
};

Singleton* Singleton::Instance = nullptr;

 

먼저, 늦은 초기화의 한 종류인 다이나믹 싱글톤이다.

 

이 방식은 GetInstance가 호출되는 시기에,  Instance이 nullptr인지 아닌지를 검사하게 된다.

Instance가 nullptr이라면 그 시기에 객체를 생성하여 참조할 수 있게 해준다.

즉, 이른 초기화 방식에서 발생할 수 있었던 생성 시기의 문제가 해결되는 것이다.

 

또한, GetInstance()를 호출하기 전까지 객체가 생성되지 않기 때문에 메모리를 효율적으로 사용할 수 있다.

 

다만, 이 방식도 두 가지 문제가 있다.

 

1. Thread-Safe 하지 않다는 것

 

멀티스레딩 환경에서 서로 다른 스레드가 동시에 GetInstance()함수를 호출한다고 가정해보자.

A스레드가 GetInstance()를 호출하고 Instance가 nullptr임을 확인하고 동적할당을 실행하였다.

 

동적할당이 완료되기 전 B스레드가 GetInstance()를 호출한다면 여전히 Instance는 nullptr이기 때문에

B스레드 또한 동적 할당을 실행할 것이다.

 

즉, 객체를 한개만 만들기 위한 디자인 패턴임에도 불구하고 멀티스레딩 환경에선 객체가 2개 이상 생성될 수 있다는 것이다. 물론, lock_guard, mutex등을 사용하여 방지할 수는 있지만 이러한 경우 성능 저하를 유발할 수 있기 떄문에 좋은 방법은 아니다.

 

2. 소멸자 호출 시기가 불 분명하다는 것

 

위에서 말했듯이 전역변수는 생성 시기가 명확하지 않다. 당연히, 소멸 시기또한 명확하지가 않다.

소멸자에서 메모리 해제를 해주고 있는데, 이는 소멸 시기에 따라 메모리 누수로 기록될 수 있다.

그렇기 때문에 프로세스가 끝나기 전에 명시적으로 메모리 해제 함수를 호출하여 직접 delete를 해주어야 하고, 여기서 번거로움이 발생할 수 있다.

 

이를 해결하기 위한 또 하나의 늦은 초기화 방식이 있다.

 

2. 늦은 초기화 - 마이어스 싱글톤

class Singleton
{
    private:
        Singleton() {}     
        ~Singleton() {}

    public:
        static Singleton& GetIstance()
        {
            static Singleton Instance;
            return Instance;
        }
};

 

이렇게 지역 스태틱 변수를 활용하는 것이다.

이는 동적할당을 하지 않기 때문에, 메모리 누수로 기록될 여지도 없으며, static 변수는 두 개 이상 생성 될 수 없기 때문에 thread-safe하다.

 

또한, GetInstance()가 호출되기 전까지 객체가 생성되지도 않으므로 메모리 낭비 또한 걱정할 필요 없는 방식이다.

 

하지만, 여전히 한 가지 문제는 남아있다.

 

소멸자 호출 시기가 불 분명하다는 것

 

위에서 여러 번 언급했듯이, 전역 객체는 소멸과 생성의 시기가 정확하지가 않다. 이렇게 GetInstance() 호출 시 생성되는 방식으로 생성 시기의 불분명함으로 발생하는 문제는 해결할 수 있지만, 소멸 시기의 불분명함으로 발생하는 문제는 해결할 수 없다. 

다른 전역 객체의 소멸자에서 GetInstance()를 호출하는 시기에 SingleTon 객체가 소멸했다면 여기서 문제가 발생한다.

(물론 이런 상황은 정말 흔하지는 않겠지만...)

 

 

이러한 소멸자의 문제를 해결하기 위해, 피닉스 싱글톤이라는 좀 더 어려운 방식이 존재한다.

 

3. 피닉스 싱글톤

class PhoenixSingleton
{
private:    
    static bool bDestroyed;   
    static PhoenixSingleton* pIns;     
    PhoenixSingleton() {};    
    PhoenixSingleton(const PhoenixSingleton& other);    
    ~PhoenixSingleton()   
    {        
        bDestroyed = true;    
    }
    
    static void Create()   
    {        
        static PhoenixSingleton ins;        
        pIns = &ins;    
    }
    
    static void KillPheonix()   
    {        
        pIns->~PhoenixSingleton();    
    }
    
public: 
    static PhoenixSingleton& GetInstance()   
    {        
        if (bDestroyed)        
        {            
            new(pIns) PhoenixSingleton;            
            atexit(KillPheonix);           
            bDestroyed = false;       
        }       
        else if (pIns == NULL)      
        {           
            Create();       
        } 
        
        return *pIns;  
    }
}; 

bool PhoenixSingleton::bDestroyed = false;
PhoenixSingleton* PhoenixSingleton::pIns = NULL;

 

 

싱글톤 객체가 소멸된 상태에서 호출되면 다시 객체를 되살리는 방식이다.

전역 변수인 bDestroyed를 이용하여 객체가 살아있나 죽어있나를 판단한 뒤, 객체가 죽어있는 상태라면

placement new를 사용하여 객체를 다시 되살린다. (전역 변수는 소멸 시기에 메모리를 초기화하지 않는다고 한다.)

이후 atexit함수를 이용하여, 프로그램 종료 시기에 소멸자가 호출될 수 있도록 한다.

placement new로 할당한 메모리는 delete로 해제하는 것이 아니라 소멸자를 직접 호출해야 한다.

 

이 방식도 완전히 문제가 없는 것은 아니라는데, 사실 그 정도까지 들어갈 필요가 있나 싶다.

피닉스 싱글톤을 사용하고 나서도 문제가 발생한다면, 싱글톤 구조를 고민하기보단 코드 설계를 제대로 했는가를 먼저 고민해보도록 하자...


 

이렇게 여러가지 싱글톤을 알아보았는데, 싱글톤은 사용하기에 따라 편리하고 유용한 디자인 패턴이기도 하지만

몇 가지 단점들로 인해 안티 패턴이라고 여겨지기도 한다.

 

1. 객체의 결합도가 높아진다. (의존성이 높아진다.)

싱글톤 같은 경우, 전역 함수를 통해 인스턴스를 받아온 뒤, 정적 메소드를 직접 호출하게 된다.

ex) InfoManager::GetInstance()->GetLevel();

 

이런 방식의 경우, 객체 간의 결합도가 높아지게 되고 싱글톤 객체의 코드를 수정하게 되면 이를 참조하는 수많은 객체의 코드를 모두 수정해야 할 수도 있다. 객체지향적인 측면에서 싱글톤 패턴은 그리 좋은 설계는 아니라는 것이다.

 

2. 멀티 스레딩 환경에서 안정적이지 못하다.

 

싱글톤 방식같은 경우, 하나의 객체를 여기저기서 참조하게 된다. 읽기만 하는 것이 아니라 경우에 따라 쓰기를 할 때도 있다. 멀티 스레딩 환경에선 하나의 메모리 영역을 여기저기서 공유하게 되니, 동기화에 대한 중요성이 커지게 된다. 이를 해결하기 위해, 여기저기에 동기화 처리를 해주게 되면 성능이 많이 저하될 수 있다. 

 

싱글톤 패턴은 이러한 문제점들 때문에 효율적으로 설계하기 참 까다로운 패턴이다.

구현이 간단하다고 해서 아무렇게나 사용하지 말고, 사용에 있어 고민을 많이 해보아야 할 듯 하다.

 

 

+ Recent posts