C++ 예제로 배우는 소프트웨어 설계 기법 (4) — 싱글톤(Singleton)

설정, 로거, 입력 장치처럼 프로그램 전체에 하나만 있어야 하는 객체가 있다. 전역 변수로 두면 누구나 새로 만들 수 있고, 생성 시점도 통제할 수 없다. 싱글톤(Singleton)은 생성자를 막고 정적 접근 함수 하나로 유일한 인스턴스를 돌려준다. 기본 형태인 Meyers singleton은 C++ 고급 문법 시리즈의 protected 생성자 편에서 다뤘으므로, 이 글에서는 스레드 경쟁, 전역 객체 초기화 순서, 소멸 순서, CRTP로 재사용하기, 테스트용 교체를 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

검사 후 생성은 스레드 안전하지 않다

#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>

std::atomic<int> g_created{0};

class Config {
    Config() { ++g_created; std::this_thread::sleep_for(std::chrono::milliseconds(5)); }   // 느린 초기화
    static inline Config* inst_ = nullptr;
public:
    static Config& instance()
    {
        if (!inst_) inst_ = new Config;           // 검사와 생성 사이에 다른 스레드가 끼어든다
        return *inst_;
    }
};

int main()
{
    std::vector<std::thread> ts;
    for (int i = 0; i < 8; ++i) ts.emplace_back([] { Config::instance(); });
    for (auto& t : ts) t.join();
    std::printf("  constructed %d times\n", g_created.load());
}
$ g++ -std=c++20 -Wall -pthread -o nv naive.cpp
$ for i in 1 2 3; do ./nv; done
  constructed 8 times
  constructed 8 times
  constructed 8 times

8개 스레드가 모두 inst_ == nullptr을 본 뒤 각자 new를 해서 객체가 8번 만들어졌다. 7개는 그대로 누수된다.

call_once와 함수 내 static

#include <atomic>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>

std::atomic<int> g_created{0};

class Config {
    Config() { ++g_created; std::this_thread::sleep_for(std::chrono::milliseconds(5)); }
    static inline Config* inst_ = nullptr;
    static inline std::once_flag once_;
public:
    static Config& instance()
    {
        std::call_once(once_, [] { inst_ = new Config; });
        return *inst_;
    }
};

class Config2 {                                   // Meyers singleton: 컴파일러가 같은 보장을 해준다
    Config2() { ++g_created; std::this_thread::sleep_for(std::chrono::milliseconds(5)); }
public:
    static Config2& instance() { static Config2 c; return c; }
};

template <typename F>
void hammer(const char* name, F f)
{
    g_created = 0;
    std::vector<std::thread> ts;
    for (int i = 0; i < 8; ++i) ts.emplace_back(f);
    for (auto& t : ts) t.join();
    std::printf("  %-10s constructed %d times\n", name, g_created.load());
}

int main()
{
    hammer("call_once", [] { Config::instance(); });
    hammer("static", [] { Config2::instance(); });
}
$ g++ -std=c++20 -Wall -pthread -o co call_once.cpp
$ ./co
  call_once  constructed 1 times
  static     constructed 1 times

std::call_once나 함수 내 static 지역 변수(C++11부터 초기화 스레드 안전 보장)는 동시에 호출돼도 한 번만 생성한다. 직접 짜는 이중 검사 잠금(double-checked locking)은 필요 없다.

전역 객체 초기화 순서 문제

전역 객체로 만든 로거를 다른 파일의 전역 객체 생성자가 쓰는 경우다.

#pragma once
#include <string>
#include <vector>

struct Logger {
    std::vector<std::string> lines;
    Logger();
    void write(const char* s) { lines.push_back(s); }
};
#ifdef MEYERS
Logger& logger();
#else
extern Logger g_logger;                           // 전역 객체로 만든 싱글톤
#endif
#include <cstdio>
#include "logger.h"

Logger::Logger() { std::printf("  Logger ctor (lines=%zu)\n", lines.size()); }
#ifdef MEYERS
Logger& logger() { static Logger l; return l; }
#else
Logger g_logger;
#endif
#include <cstdio>
#include "logger.h"

struct AutoRegister {                             // 다른 파일의 전역 객체가 생성자에서 logger 사용
    AutoRegister()
    {
#ifdef MEYERS
        logger().write("plugin registered");
#else
        g_logger.write("plugin registered");
#endif
        std::puts("  AutoRegister ctor");
    }
} g_auto;
#include <cstdio>
#include "logger.h"

int main()
{
#ifdef MEYERS
    std::printf("  main: logger has %zu lines\n", logger().lines.size());
#else
    std::printf("  main: logger has %zu lines\n", g_logger.lines.size());
#endif
}
$ g++ -std=c++20 -o a logger.cpp plugin.cpp main_order.cpp && ./a
  Logger ctor (lines=0)
  AutoRegister ctor
  main: logger has 1 lines
$ g++ -std=c++20 -o b plugin.cpp logger.cpp main_order.cpp && ./b
  AutoRegister ctor
  Logger ctor (lines=0)
  main: logger has 0 lines
$ g++ -std=c++20 -DMEYERS -o c plugin.cpp logger.cpp main_order.cpp && ./c
  Logger ctor (lines=0)
  AutoRegister ctor
  main: logger has 1 lines

파일 사이 전역 객체 초기화 순서는 정해져 있지 않아 링크 순서만 바꿔도 결과가 달라진다(static initialization order fiasco). b에서는 생성 전 로거에 쓴 내용이 생성자에 덮여 사라졌다. 함수 내 static(-DMEYERS)은 첫 사용 시점에 생성되므로 링크 순서와 무관하다.

소멸 순서와 죽은 참조

#include <cstdio>
#include <string>

class Log {
    std::string prefix_ = "[log] ";
    bool alive_ = true;
public:
    static Log& instance()
    {
#ifdef LEAKY
        static Log* l = new Log;                  // 일부러 해제하지 않는다
        return *l;
#else
        static Log l;
        return l;
#endif
    }
    ~Log() { alive_ = false; std::fprintf(stderr, "  ~Log\n"); }
    void write(const char* s) { std::fprintf(stderr, "  %s%s (alive=%d)\n", prefix_.c_str(), s, alive_); }
};

class Cache {
public:
    static Cache& instance() { static Cache c; return c; }
    ~Cache() { Log::instance().write("cache flushed"); }   // 소멸 중 다른 싱글톤 사용
};

int main()
{
    Cache::instance();                            // Cache가 먼저 생성 -> 나중에 소멸
    Log::instance().write("start");
}
$ g++ -std=c++20 -Wall -o dr dead_ref.cpp && ./dr
  [log] start (alive=1)
  ~Log
  [log] cache flushed (alive=0)
$ g++ -std=c++20 -Wall -DLEAKY -o dr2 dead_ref.cpp && ./dr2
  [log] start (alive=1)
  [log] cache flushed (alive=1)

static 객체는 생성의 역순으로 소멸하므로, 먼저 생성된 Cache의 소멸자가 이미 소멸한 Log를 사용했다. 힙에 만들고 해제하지 않는 leaky singleton(-DLEAKY)은 프로세스가 끝날 때까지 살아 있어 이 문제가 없다.

CRTP로 싱글톤 재사용하기

#include <cstdio>

template <typename T>
class Singleton {
protected:
    Singleton() = default;
    ~Singleton() = default;
public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    static T& instance() { static T inst; return inst; }
};

class Cursor : public Singleton<Cursor> {
    friend class Singleton<Cursor>;               // 기반 클래스만 생성자를 부를 수 있게
    Cursor() { std::puts("  Cursor()"); }
public:
    int x = 0, y = 0;
};

class Keyboard : public Singleton<Keyboard> {
    friend class Singleton<Keyboard>;
    Keyboard() { std::puts("  Keyboard()"); }
public:
    bool caps = false;
};

int main()
{
    Cursor::instance().x = 10;
    Keyboard::instance().caps = true;
    std::printf("  x=%d caps=%d same=%d\n", Cursor::instance().x, Keyboard::instance().caps,
                &Cursor::instance() == &Cursor::instance());
#ifdef ERR
    Cursor c2;                                    // 직접 생성 시도
#endif
}
$ g++ -std=c++20 -Wall -o cr crtp_singleton.cpp
$ ./cr
  Cursor()
  Keyboard()
  x=10 caps=1 same=1
$ g++ -std=c++20 -DERR -c crtp_singleton.cpp 2>&1 | grep error
crtp_singleton.cpp:35:12: error: ‘Cursor::Cursor()’ is private within this context

복사 금지와 instance()를 Singleton<T>에 한 번만 쓰고, 파생 클래스는 생성자를 private으로 두고 기반 클래스만 friend로 열어준다. 직접 생성은 컴파일 에러가 된다.

테스트에서 교체할 수 있게

#include <cstdio>
#include <memory>
#include <string>

struct Clock {                                    // 싱글톤이 구현할 인터페이스
    virtual ~Clock() = default;
    virtual int hour() const = 0;
};

struct SystemClock : Clock {
    int hour() const override { return 14; }      // 실제로는 시스템 시간
};

class Services {                                  // 접근점은 하나, 구현은 교체 가능
    static inline std::unique_ptr<Clock> clock_ = std::make_unique<SystemClock>();
public:
    static Clock& clock() { return *clock_; }
    static void set_clock(std::unique_ptr<Clock> c) { clock_ = std::move(c); }   // 테스트용
};

std::string greet() { return Services::clock().hour() < 12 ? "good morning" : "good afternoon"; }

struct FakeClock : Clock {
    int h;
    explicit FakeClock(int h) : h(h) {}
    int hour() const override { return h; }
};

int main()
{
    std::printf("  real : %s\n", greet().c_str());
    Services::set_clock(std::make_unique<FakeClock>(9));
    std::printf("  fake : %s\n", greet().c_str());
}
$ g++ -std=c++20 -Wall -o tt testable.cpp
$ ./tt
  real : good afternoon
  fake : good morning

싱글톤을 쓰는 코드는 숨은 의존성 때문에 테스트하기 어렵다. 접근점은 하나로 두되 반환 타입을 인터페이스로 하면 테스트에서 가짜 구현으로 바꿀 수 있다.

구현스레드 안전 생성생성 시점소멸
————
전역 객체해당 없음 (main 전 생성)파일 간 순서 미정역순, 순서 미정
포인터 + if (!p)아니오첫 호출직접 관리
std::call_once예첫 호출직접 관리
함수 내 static예첫 호출main 종료 후 생성 역순
leaky (new, 해제 안 함)함수 내 static 포인터면 예첫 호출안 함 (OS가 회수)

주의사항

상황문제대응
———
직접 짠 lazy 초기화여러 번 생성, 누수함수 내 static 또는 std::call_once
다른 파일의 전역 객체가 사용생성 전 사용전역 객체 대신 함수 내 static
다른 static 객체 소멸자에서 사용소멸된 객체 사용leaky singleton 또는 사용 금지
leaky singleton소멸자가 실행되지 않음 (파일 flush 등 누락)정리가 필요하면 명시적 shutdown()
공유 라이브러리마다 템플릿 싱글톤 인스턴스화라이브러리마다 별도 인스턴스instance()를 한 라이브러리에서만 정의·export
싱글톤에 상태를 많이 둠숨은 전역 상태, 테스트 곤란인터페이스로 접근, 의존성 주입 고려

마무리

  • 유일한 인스턴스는 함수 내 static으로 만들면 스레드 안전한 지연 생성과 초기화 순서 문제가 함께 해결된다.
  • 소멸 순서가 문제 되면 leaky singleton을 쓰고, 필요한 정리 작업은 따로 호출한다.
  • 반복되는 싱글톤 코드는 CRTP 기반 클래스로 모으고, 테스트가 필요하면 인터페이스로 접근하게 한다.

참고

답글 남기기