설정, 로거, 입력 장치처럼 프로그램 전체에 하나만 있어야 하는 객체가 있다. 전역 변수로 두면 누구나 새로 만들 수 있고, 생성 시점도 통제할 수 없다. 싱글톤(Singleton)은 생성자를 막고 정적 접근 함수 하나로 유일한 인스턴스를 돌려준다. 기본 형태인 Meyers singleton은 C++ 고급 문법 시리즈의 protected 생성자 편에서 다뤘으므로, 이 글에서는 스레드 경쟁, 전역 객체 초기화 순서, 소멸 순서, CRTP로 재사용하기, 테스트용 교체를 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (2) — 시계와 타이머 콜백(Clock)
- C++ 예제로 배우는 소프트웨어 설계 기법 (3) — 윈도우 클래스와 메시지 처리(Window)
- C++ 예제로 배우는 소프트웨어 설계 기법 (5) — 도형 편집기(Shape)
- C++ 예제로 배우는 소프트웨어 설계 기법 (6) — 입력 검증으로 보는 템플릿 메서드와 전략(LineEdit)
검사 후 생성은 스레드 안전하지 않다
#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 기반 클래스로 모으고, 테스트가 필요하면 인터페이스로 접근하게 한다.