싱글톤 패턴(Singleton Pattern)

  • 싱글톤 패턴은 매우 인기있는 디자인 패턴 중 하나입니다.
  • 싱글톤은 자원의 공유를 위해 오직 하나만 존재하는 객체를 의미합니다.
  • 즉 객체의 중복 생성을 방지함과 동시에 전역으로 공유되어 어디서든 접근할 수 있습니다.

객체의 중복 생성 방지

  • 싱글톤 패턴은 프로그램 내에서 오직 하나의 객체만 존재해야 합니다.
  • 그렇다면 C++에서는 언제 객체가 생성될까요?
    • 생성자를 이용하여 생성된 객체
    • 객체를 복제하며 생성되는 객체
    • 객체를 대입하며 생성되는 객체
  • C++에서는 클래스의 메서드를 정의할 때, 사용자가 정의하지 않으면 자동으로 생성하는 메서드가 존재합니다.
class Singleton
{
public:
    // 1. 사용자가 생성자를 정의하지 않으면 컴파일러는 기본 생성자를 자동으로 만듭니다.
    Singleton()
    {
        // ...
    }

    // 2. 클래스 복제 생성자
    Singleton(const Singleton& singleton)
    {
        // ...
    }

    // 3. 클래스 대입 연산자
    Singleton& operator=(const Singleton& singleton)
    {
        // ...
    }
};

  • 이러한 클래스의 생성자, 복제 생성자, 대입 연산자는 C++을 더욱 강력하게 만드는 기능입니다.
  • 하지만 싱글톤 패턴에서는 임시 객체의 생성을 방지해야 할 필요가 있기 때문에, 위와 같은 객체 생성자/연산자를 제거합니다.
class Singleton
{
// 연산자를 private 접근제어 지시자로 외부에서 호출이 불가능하도록 만든다.
private:
    Singleton()
    {
        // 객체 생성의 초기화가 필요하면 여기서 진행한다.
    }

    Singleton(const Singleton& singleton) = delete;

    Singleton& operator=(const Singleton& singleton) = delete;
};
  • private 접근제어 지시자와 delete 키워드를 사용하여 외부에서 객체의 생성과 임시 객체의 생성을 방지합니다.

싱글톤 패턴의 두 가지 구현 방법

  • 싱글톤 패턴은 static 키워드를 사용하여 구현합니다.
    • 일반 클래스에서 static 키워드는 다수의 객체가 존재하더라도, 하나의 공통된 멤버 함수 또는 멤버 변수를 가르킵니다.
  • 싱글톤 패턴은 객체의 반환 형식에 따라 두 가지 구현 방법이 존재합니다.
    • 레퍼런스를 반환하는 방법
    • 스마트 포인터를 반환하는 방법

레퍼런스를 반환하는 싱글톤

  • 가장 일반적인 싱글톤의 구현 방법입니다.
class Config
{
public:
    // static 키워드는 오직 하나의 멤버 함수&변수를 가르킨다.
    static Config& getInstance()
    {
        static Config instance;
        return instance;
    }
private:
    Config()
    {
        // 객체 생성의 초기화가 필요하면 여기서 진행한다.
    }

    Config(const Config& config) = delete;

    Config& operator=(const Config& config) = delete;
};
  • 싱글톤 관련 글을 찾아보면 DCLP(Double-Checked Locking Pattern) 관련 내용을 볼 수 있습니다.
  • C++11 이후부터 static 키워드로 생성된 정적 지역 변수 초기화 코드가 멀티스레드 환경에서도 딱 한번 실행됩니다. 따라서 위와 같은 코드는 멀티스레드 환경에서 안전합니다.

멀티스레드 환경에서 불안전한 싱글톤

  • 그렇다면 무엇이 멀티스레드 환경에서 불안전할까요?
class Config
{
public:
    // static 키워드는 오직 하나의 멤버 함수&변수를 가르킨다.
    static Config& getInstance()
    {
        if (m_Instance == nullptr)
        {
            m_Instance = new Config();
        }
        return *m_Instance;
    }
private:
    Config()
    {
        // 객체 생성의 초기화가 필요하면 여기서 진행한다.
    }

    Config(const Config& config) = delete;

    Config& operator=(const Config& config) = delete;

    static Config* m_Instance;
};
  • 멀티스레드 환경에서 위와 같은 싱글톤이 있다고 생각해봅시다.
  1. A 스레드에서 Config::getInstance()를 호출하였습니다.
  2. if (m_Instance == nullptr) 조건을 만족하기 때문에(처음 호출되었기 때문에) 싱글톤 객체를 생성하려 if statement로 진입합니다.
  3. 싱글톤 객체가 생성되기 전에, B 스레드에서 Config::getInstance()를 호출하였습니다.
  4. 이 때, A 스레드와 B 스레드가 호출한 Config::getInstance()는 모두 if (m_Instance == nullptr) 조건을 만족합니다.
  5. 따라서 두 개의 싱글톤 객체가 생성됩니다.
  • 이와 같이 멀티스레드 환경에서 싱글톤 객체가 두 개 이상 생성될 가능성이 존재합니다.
  • 싱글톤 객체는 처음 호출될 때 객체를 생성하기 때문에, 언제 어디서 객체가 생성될지 모릅니다.
    • 이러한 이유에서 싱글톤 객체를 코드의 초기화 시점에 호출합니다.
  • 이렇게 불안전한 코드를 방지하고자 다음과 같은 기법으로 코드를 작성합니다.
class DCLPConfig
{
public:
    static DCLPConfig* getInstance()
    {
        if (m_Instance == nullptr)
        {
            std::lock_guard<std::mutex> lock(m_Mutex);

            if (m_Instance == nullptr)
            {
                m_Instance = new DCLPConfig();
            }
        }

        return m_Instance;
    }

private:
    static DCLPConfig* volatile m_Instance;
    static std::mutex m_Mutex;

    DCLPConfig()
    {
    }

    DCLPConfig(const DCLPConfig& config) = delete;

    DCLPConfig& operator=(const DCLPConfig& config) = delete;
};
  • 위 코드처럼 m_Instancenullptr 확인을 두 번 하기 때문에 DCLP(Double-Checked Locking Pattern)라는 이름이 붙었습니다.
  • 첫 번째 스레드에서 if (m_Instance == nullptr)를 만족하면, lock()을 수행하기 때문에, 다른 스레드에서 접근하더라도 std::lock_guard 아래로 넘어가지 않습니다.
  • 첫 번째 스레드가 m_Instance = new smartConfig();을 수행하고 종료되면, 기다리던 다른 스레드에서 다시 if (m_Instance == nullptr) 확인을 하기 때문에 객체가 중복해서 생성되지 않습니다.
  • 하지만, 불필요한 확인 절차가 늘어났다는 점과 스레드 lock()을 수행한다는 점에서 효율이 좋은 코드는 아닙니다.
  • 따라서 C++11 이상의 환경에서 static 키워드를 사용합니다.

스마트 포인터를 반환하는 방법

  • Mordern C++에서는 원시 포인터(Raw Pointer) 사용을 권하지 않습니다.
  • 원시 포인터는 어떠한 포인터로 사용이 가능한 필요 이상의 강력한 포인터입니다.
  • 스마트 포인터를 사용한 이벤트 리스너, 핸들러 등을 함께 사용할 경우 객체의 타입이 스마트 포인터여야 할 필요가 있습니다.
class SmartConfig
{
public:
    // static 키워드는 오직 하나의 멤버 함수&변수를 가르킨다.
    static std::shared_ptr<SmartConfig> getInstance()
    {
        static std::shared_ptr<SmartConfig> instance{new SmartConfig};
        return instance;
    }
private:
    SmartConfig()
    {
        // 객체 생성의 초기화가 필요하면 여기서 진행한다.
    }

    SmartConfig(const SmartConfig& smartConfig) = delete;

    SmartConfig& operator=(const SmartConfig& smartConfig) = delete;
};