플러그인 레지스트리, 로거, 설정 저장소처럼 프로그램 전체에 딱 하나만 있어야 하고 어디서든 접근할 수 있어야 하는 객체가 있다. 전역 변수로 두면 누구나 새로 만들거나 덮어쓸 수 있다. 싱글톤(Singleton)은 클래스가 스스로 인스턴스 생성을 통제해 “하나뿐”을 보장하고, 그 인스턴스에 접근하는 전역 접근점을 제공한다. 이 글에서는 GoF 관점의 구조와 최소 구현, 사용할 서브클래스를 실행 시점에 고르는 변형, 공유 라이브러리 경계에서 인스턴스가 여러 개 생기는 문제, 모노스테이트 대안, C의 pthread_once 구현을 다룬다.
검사 후 생성의 경쟁 조건, call_once, 전역 객체 초기화·소멸 순서, CRTP 재사용, 테스트 교체는 설계 기법 (4) 싱글톤에서 실측했으니 여기서는 반복하지 않는다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (3) — 팩토리 메서드(Factory Method)
- C/C++ 디자인 패턴 (4) — 프로토타입(Prototype)
- C/C++ 디자인 패턴 (6) — 어댑터(Adapter)
- C/C++ 디자인 패턴 (7) — 브리지(Bridge)
구조
| 요소 | 역할 | C++ 구현 |
|---|---|---|
| 유일한 인스턴스 | 클래스가 직접 보관 | 함수 내 static 지역 변수 |
| 생성 통제 | 외부 생성·복사를 막음 | private 생성자, 복사 연산 = delete |
| 전역 접근점 | 인스턴스를 돌려주는 정적 연산 | static Registry &instance() |
최소 구현
// registry.hpp — 헤더 전용 싱글톤 (함수 내 static)
#pragma once
#include <string>
#include <vector>
class Registry {
public:
static Registry &instance()
{
static Registry r; // C++11 이후 초기화는 스레드 안전
return r;
}
void add(std::string name) { names_.push_back(std::move(name)); }
const std::vector<std::string> &names() const { return names_; }
Registry(const Registry &) = delete;
Registry &operator=(const Registry &) = delete;
private:
Registry() = default;
std::vector<std::string> names_;
};// basic.cpp — GoF 싱글톤의 최소형: 인스턴스 하나 + 전역 접근점
#include <iostream>
#include "registry.hpp"
int main()
{
Registry::instance().add("a");
Registry::instance().add("b");
std::cout << "same object: " << std::boolalpha
<< (&Registry::instance() == &Registry::instance())
<< ", entries=" << Registry::instance().names().size() << '\n';
#ifdef SHOW_ERROR
Registry copy = Registry::instance(); // 복사 금지
Registry other; // 생성자 private
#endif
}$ g++ -std=c++20 -Wall -Wextra -O2 -o basic basic.cpp && ./basic
same object: true, entries=2
$ g++ -std=c++20 -DSHOW_ERROR -c basic.cpp 2>&1 | grep error
basic.cpp:13:44: error: use of deleted function ‘Registry::Registry(const Registry&)’
basic.cpp:14:18: error: ‘constexpr Registry::Registry()’ is private within this context
복사와 직접 생성이 모두 컴파일 시점에 막힌다. 함수 내 static은 C++11부터 초기화가 스레드 안전하다.
서브클래스를 고르는 싱글톤
GoF는 싱글톤의 장점으로 “서브클래스로 확장할 수 있다”를 든다. 접근점은 Logger::instance() 하나로 두고, 어떤 구현을 단일 인스턴스로 쓸지는 첫 호출에서 환경에 따라 정한다.
// subclass.cpp — GoF 변형: 어떤 서브클래스를 단일 인스턴스로 쓸지 첫 호출 때 고른다
#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
class Logger {
public:
static Logger &instance()
{
static std::unique_ptr<Logger> inst = create(); // 첫 호출에서 한 번만
return *inst;
}
virtual ~Logger() = default;
virtual void log(const std::string &msg) = 0;
protected:
Logger() = default;
private:
static std::unique_ptr<Logger> create();
};
class ConsoleLogger : public Logger {
public:
void log(const std::string &m) override { std::cout << "[console] " << m << '\n'; }
};
class JsonLogger : public Logger {
public:
void log(const std::string &m) override { std::cout << "{\"msg\":\"" << m << "\"}\n"; }
};
std::unique_ptr<Logger> Logger::create()
{
const char *fmt = std::getenv("LOG_FORMAT");
if (fmt && std::string(fmt) == "json")
return std::make_unique<JsonLogger>();
return std::make_unique<ConsoleLogger>();
}
int main()
{
Logger::instance().log("started");
Logger::instance().log("ready");
}$ g++ -std=c++20 -Wall -Wextra -O2 -o subclass subclass.cpp && ./subclass && LOG_FORMAT=json ./subclass
[console] started
[console] ready
{"msg":"started"}
{"msg":"ready"}
호출하는 쪽 코드는 같고, 실행 환경만 바꿔 다른 구현이 선택됐다. 선택은 첫 호출 한 번뿐이라 실행 중에는 바뀌지 않는다.
공유 라이브러리에서 싱글톤이 여러 개가 되는 문제
헤더 전용 싱글톤을 여러 공유 라이브러리가 각자 포함하면, “하나뿐”은 링커와 동적 로더의 협조에 달려 있다. 두 플러그인 라이브러리와 실행 파일이 같은 registry.hpp를 쓰는 구성을 심볼 가시성만 바꿔 빌드했다.
// plugin_a.cpp — 공유 라이브러리 A가 싱글톤에 등록한다
#include "registry.hpp"
extern "C" __attribute__((visibility("default"))) void plugin_a_init()
{
Registry::instance().add("plugin_a");
}
extern "C" __attribute__((visibility("default"))) const void *plugin_a_registry()
{
return &Registry::instance();
}// app.cpp — 두 라이브러리를 링크한 실행 파일에서 싱글톤 개수를 확인한다
#include <iostream>
#include "registry.hpp"
extern "C" void plugin_a_init();
extern "C" void plugin_b_init();
extern "C" const void *plugin_a_registry();
extern "C" const void *plugin_b_registry();
int main()
{
plugin_a_init();
plugin_b_init();
Registry::instance().add("app");
std::cout << "app : " << &Registry::instance() << '\n'
<< "lib a: " << plugin_a_registry() << '\n'
<< "lib b: " << plugin_b_registry() << '\n'
<< "app sees " << Registry::instance().names().size() << " entries:";
for (const auto &n : Registry::instance().names())
std::cout << ' ' << n;
std::cout << '\n';
}#!/bin/bash
# build.sh <default|hidden> — 같은 소스를 심볼 가시성만 바꿔 빌드한다
set -e
vis=$1
flags="-std=c++20 -O2 -fPIC"
[ "$vis" = hidden ] && flags="$flags -fvisibility=hidden -fvisibility-inlines-hidden"
g++ $flags -shared -o libplugin_a.so plugin_a.cpp
g++ $flags -shared -o libplugin_b.so plugin_b.cpp
g++ -std=c++20 -O2 -o app app.cpp -L. -lplugin_a -lplugin_b -Wl,-rpath,'$ORIGIN'plugin_b.cpp는 이름만 다르고 plugin_a.cpp와 같다.
$ ./build.sh default && ./app
app : 0x583e29ab8160
lib a: 0x583e29ab8160
lib b: 0x583e29ab8160
app sees 3 entries: plugin_a plugin_b app
$ ./build.sh hidden && ./app
app : 0x564487506160
lib a: 0x713aec044060
lib b: 0x713aec03f060
app sees 1 entries: app
기본 가시성에서는 세 주소가 같고 등록 3건이 모두 보인다. -fvisibility=hidden으로 빌드하면 실행 파일과 두 라이브러리가 각자 인스턴스를 가져, 앱은 자기가 넣은 1건만 본다.
$ ./build.sh default && nm -DC libplugin_a.so | grep 'Registry::instance'
0000000000004058 u guard variable for Registry::instance()::r
0000000000004060 u Registry::instance()::r
$ ./build.sh hidden && nm -DC libplugin_a.so | grep -c 'Registry::instance'
0
기본 빌드에서는 함수 내 static이 u(GNU unique global) 심볼로 내보내져 동적 로더가 하나로 합친다. 숨김 빌드에서는 내보내는 심볼이 없어 합칠 대상이 사라진다.
모노스테이트: 인스턴스는 여럿, 상태는 하나
인스턴스를 하나로 제한하는 대신 모든 데이터를 static으로 두는 방법이다. 사용하는 쪽은 평범하게 객체를 만들어 쓰므로 싱글톤인지 의식하지 않는다.
// monostate.cpp — 모노스테이트: 객체는 여러 개, 상태는 하나
#include <iostream>
#include <string>
class Config {
public:
void set(const std::string &k, int v) { if (k == "workers") workers_ = v; }
int workers() const { return workers_; }
private:
inline static int workers_ = 4; // 모든 인스턴스가 공유
};
void configure() { Config c; c.set("workers", 16); }
int readWorkers() { Config c; return c.workers(); }
int main()
{
std::cout << "before: " << readWorkers() << '\n';
configure();
std::cout << "after : " << readWorkers() << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o monostate monostate.cpp && ./monostate
before: 4
after : 16
C 구현: 파일 static과 pthread_once
C에서는 static 전역 변수가 번역 단위 밖으로 보이지 않으므로, 접근 함수만 공개하면 그대로 싱글톤이 된다. 지연 초기화는 pthread_once가 한 번만 실행됨을 보장한다.
/* once.c — C 싱글톤: 파일 static + 접근 함수 + pthread_once 지연 초기화 */
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
struct config {
int workers;
const char *log_dir;
};
static struct config *g_config; /* 이 파일 밖에서는 보이지 않는다 */
static pthread_once_t g_once = PTHREAD_ONCE_INIT;
static int g_init_calls;
static void config_init(void)
{
g_config = malloc(sizeof(*g_config));
if (!g_config)
abort();
g_config->workers = 4;
g_config->log_dir = "/var/log/app";
g_init_calls++;
}
const struct config *config_get(void)
{
pthread_once(&g_once, config_init);
return g_config;
}
static void *reader(void *arg)
{
*(const struct config **)arg = config_get();
return NULL;
}
int main(void)
{
pthread_t t[8];
const struct config *seen[8];
for (int i = 0; i < 8; i++)
pthread_create(&t[i], NULL, reader, &seen[i]);
for (int i = 0; i < 8; i++)
pthread_join(t[i], NULL);
int same = 1;
for (int i = 1; i < 8; i++)
same &= seen[i] == seen[0];
printf("threads=8 init_calls=%d same_instance=%d workers=%d\n",
g_init_calls, same, config_get()->workers);
free(g_config);
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -pthread -fsanitize=thread -o once once.c && ./once
FATAL: ThreadSanitizer: unexpected memory mapping 0x5a90fbfe6000-0x5a90fbfe7000
스레드 8개가 동시에 처음 접근해도 초기화는 1번 실행됐고, ThreadSanitizer도 경쟁을 보고하지 않았다.
대안 비교
| 방식 | “하나뿐” 보장 | 테스트 교체 | 적합한 경우 |
|---|---|---|---|
| 전역 변수 | 없음(누구나 생성 가능) | 어려움 | 상수, 간단한 C 프로그램 |
| 싱글톤 | 클래스가 보장 | 별도 장치 필요 | 프로세스 자원(로거, 레지스트리) |
| 모노스테이트 | 상태만 하나 | 어려움 | 기존 코드를 바꾸지 않고 상태를 공유 |
| 의존성 주입 | 조립하는 쪽이 하나만 만듦 | 쉬움 | 새로 설계하는 대부분의 경우 |
주의사항
- 숨겨진 전역 상태다: 함수 시그니처에 드러나지 않는 의존성이 생긴다. 단위 테스트가 서로의 상태를 오염시키는 원인이 되므로 꼭 필요한 곳에만 쓴다.
- 공유 라이브러리와
-fvisibility=hidden: 위 실측처럼 라이브러리마다 인스턴스가 생긴다. 싱글톤의instance()를 한 라이브러리에만 정의(비인라인)하고 그 심볼을 내보내는 것이 확실한 해결책이다. - “하나뿐”의 범위는 프로세스:
fork()뒤에는 자식이 복사본을 갖고, 여러 프로세스 사이에서는 당연히 하나가 아니다. - 서브클래스 선택은 첫 호출에 고정된다: 환경 변수를 나중에 바꿔도 반영되지 않는다. 바꿔야 한다면 싱글톤이 아니라 교체 가능한 서비스 객체가 필요한 상황이다.
- C에서 해제:
pthread_once로 만든 객체는 해제 시점이 정의되지 않는다. 종료 시 정리가 필요하면atexit()등록이나 명시적 shutdown 함수를 둔다.
마무리
싱글톤의 구현은 함수 내 static 몇 줄이면 끝나지만, “하나뿐”이라는 약속은 빌드 옵션과 라이브러리 경계까지 따라다닌다. 생성 패턴은 여기까지이고, 다음 글부터는 구조 패턴으로 넘어가 인터페이스가 맞지 않는 클래스를 이어 주는 어댑터(Adapter)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Singleton
- GCC Wiki — Visibility
- man7.org — pthread_once(3p)