C/C++ 디자인 패턴 (5) — 싱글톤(Singleton)

플러그인 레지스트리, 로거, 설정 저장소처럼 프로그램 전체에 딱 하나만 있어야 하고 어디서든 접근할 수 있어야 하는 객체가 있다. 전역 변수로 두면 누구나 새로 만들거나 덮어쓸 수 있다. 싱글톤(Singleton)은 클래스가 스스로 인스턴스 생성을 통제해 “하나뿐”을 보장하고, 그 인스턴스에 접근하는 전역 접근점을 제공한다. 이 글에서는 GoF 관점의 구조와 최소 구현, 사용할 서브클래스를 실행 시점에 고르는 변형, 공유 라이브러리 경계에서 인스턴스가 여러 개 생기는 문제, 모노스테이트 대안, C의 pthread_once 구현을 다룬다.

검사 후 생성의 경쟁 조건, call_once, 전역 객체 초기화·소멸 순서, CRTP 재사용, 테스트 교체는 설계 기법 (4) 싱글톤에서 실측했으니 여기서는 반복하지 않는다.

이 시리즈의 다른 글

구조

요소역할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)를 다룬다.

참고

답글 남기기