1. 개념 이해
싱글톤 패턴이란?
프로그램 전체에서 인스턴스가 단 하나만 존재하도록 보장하는 디자인 패턴
하나의 클래스를 기반으로 여러 개의 개별적인 인스턴스를 만들 수 있지만, 그렇게 하지 않고 하나의 클래스를 기반으로 단 하나의 인스턴스를 만들어 이를 기반으로 로직을 만드는데 쓰이며,
하나의 인스턴스를 만들어 놓고 해당 인스턴스를 다른 모듈들이 공유하며 사용하기 때문에 인스턴스를 생성할 때 드는 비용이 줄어드는 장점이 있다.
따라서 보통 싱글톤 패턴이 적용된 객체가 필요한 경우는 그 객체가 리소스를 많이 차지하는 역할을 하는
무거운 클래스일 때 적합하다.
대표적으로 보통 설정(Config) 관리자, 로그(Logger) 시스템, DB 커넥션 풀, 네트워크 소켓 매니저, 데이터 베이스 연결 모듈에 많이 사용된다.
데이터 베이스 연결 모듈을 예로 들자면, 데이터 베이스에 접속하는 작업(I/O 바운드)은 무거운 작업에 속하며 또한 한 번만
객체를 생성하고 돌려쓰면 되는 것을 굳이 여러 번 생성할 필요가 없기 때문이다.
이러한 객체들은 재생성을 하여 사용될 일도 없으며 사용해도 리소스 낭비이다. 따라서 코드 내에서 유일해야 하는 것을 싱글톤 객체로 만들면 된다고 보면 된다.
2. C++ 에서의 싱글톤 구현
기본 구현 (Eager Initialization)
가장 단순한 형태의 싱글톤은 다음과 같다.
class Singleton {
private:
static Singleton* instance;
// 생성자를 private으로 → 외부에서 new 불가
Singleton() {}
public:
// 복사 생성자, 대입 연산자 삭제
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
};
// 정적 멤버 초기화
Singleton* Singleton::instance = nullptr;
핵심은 세 가지로 볼 수 있다.
1. 생성자를 private으로 선언 = 외부에서 new Singleton()을 호출할 수 없게 막는다.
2. 복사 생성자와 대입 연산자를 delete - Singleton copy = *Singleton::getInstance(): 같은 복사를 원천 차단한다.
3. static 매서드로 접근 - 유일한 인스턴스에 대한 전역 접근점을 제공한다.
하지만 이 방식에는 치명적인 문제가 있다.
⚠️ 문제점: 멀티스레드 환경에서의 Race Condition
위 기본 구현은 멀티 스레드 환경에서 안전하지 않다. 두 스레드가 동시에 getInstance()를 호출하면 인스턴스가 두 번 생성될 수 있다.
// 🚨 Race Condition 시나리오
// Thread A: if (instance == nullptr) → true 진입
// Thread B: if (instance == nullptr) → true 진입 (A가 아직 생성 안 함)
// Thread A: instance = new Singleton() → 첫 번째 생성
// Thread B: instance = new Singleton() → 두 번째 생성! (메모리 누수)
이 문제를 해결하기 위해 등장한 것이 Double-Checked Locking 패턴이다.
보완 1: Double-Checked Locking
#include <mutex>
class Singleton {
private:
static Singleton* instance;
static std::mutex mtx;
Singleton() {}
public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton* getInstance() {
if (instance == nullptr) { // 1차 검사 (lock 없이)
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) { // 2차 검사 (lock 안에서)
instance = new Singleton();
}
}
return instance;
}
};
1차 검사는 이미 인스턴스가 생성된 이후에는 매번 lock을 걸지 않기 위한 성능 최적화이고, 2차 검사는 실제로 lock을 획득한 후 다른 스레드가 먼저 생성하지 않았는지 확인하는 것이다.
스레드 안전 문제는 해결했지만, 여전히 아쉬운 점이 있다.
- 코드가 복잡하다 — mutex 관리, 이중 검사 로직이 필요하다
- 명령어 재배치(instruction reordering) 위험 — C++ 메모리 모델상 컴파일러나 CPU가 명령어 순서를 바꿀 수 있어 미묘한 버그가 발생할 수 있다
- 메모리 관리가 수동 — new로 생성했으므로 어딘가에서 delete를 해줘야 한다
이런 복잡성을 근본적으로 해결하는 방법이 C++11에서 등장했다.
보완 2: Meyer's Singleton (C++11 이상) 권장
C++11 부터는 지역 정적 변수 (Local Static Variable)의 초기화가 스레드 안전하도록 표준에서 보장한다.
이를 활용한 것이 Scott Meyers가 제안한 방식이다.
class Singleton {
private:
Singleton() {}
public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton& getInstance() {
static Singleton instance; // C++11: 스레드 안전 보장
return instance;
}
};
이 방식의 장점은 명확하다.
1. 스레드 안전: 컴파일러가 알아서 보장해 준다 (C++11)
2. Lazy Initialization: getInstance()가 처음 호출될 때 생성된다.
3. 메모리 누수 없음: new를 쓰지 않으므로 프로그램 종료 시 자동 소멸
4. 코드가 간결: mutex나 포인터 관리가 필요 없다
기본 구현의 스레드 안전 문제, Double-Checked Locking의 복잡성과 명령어 재배치 위험을 모두 해결한 방식이다.
C++11 이상을 사용한다면 사실상 정답이라고 볼 수 있다.
3. 싱글톤 패턴 자체의 한계
구현 방식을 아무리 잘 선택해도, 싱글톤 패턴 자체가 갖는 구조적인 문제들이 있다.
소멸 순서 문제 (Dead Reference Problem)
싱글톤이 하나만 존재할 때는 문제없지만, 여러 싱글톤이 서로 의존하는 경우 소멸 순서가 꼬일 수 있다.
class Logger {
public:
static Logger& getInstance() {
static Logger instance;
return instance;
}
void log(const std::string& msg) { /* ... */ }
~Logger() { /* 파일 닫기 등 정리 작업 */ }
};
class Database {
public:
static Database& getInstance() {
static Database instance;
return instance;
}
~Database() {
// 🚨 이 시점에 Logger가 이미 소멸되었을 수 있다!
Logger::getInstance().log("Database 종료");
}
};
정적 객체의 소멸 순서는 생성 순서의 역순이다. 만약 Logger가 Database 보다 먼저 생성되었다면 Logger가 나중에 소멸되므로 문제없지만, 그 반대라면 이미 소멸된 Logger에 접근하게 된다.
이런 의존 관계가 있다면 초기화 시점에서 순서를 명시적으로 제어해야 한다.
class Database {
public:
static Database& getInstance() {
// Logger를 먼저 초기화 → 나중에 소멸되도록 보장
Logger::getInstance();
static Database instance;
return instance;
}
};
테스트가 어렵다
싱글톤은 전역 상태를 갖기 때문에 단위 테스트에서 독립적인 환경을 만들기 어렵다. 테스트마다 상태를 리섹해야 하고, mock 객체로 교체하기도 까다롭다.
의존성이 감춰진다
함수 시그니처만 봐서는 해당 함수가 싱글톤에 의존하는지 알 수 없다. 이는 코드의 결흡도를 높이고 유지보수를 어렵게 만든다.
// 싱글톤 직접 접근 — 의존성이 코드 내부에 숨어있다
class Service {
void doWork() {
Database::getInstance().query("SELECT ...");
}
};
// 의존성 주입(DI) — 의존성이 명시적으로 드러난다
class Service {
Database& db;
public:
Service(Database& db) : db(db) {}
void doWork() {
db.query("SELECT ...");
}
};
이 때문에 현대 C++ 개발에서는 싱글톤 대신 의존성 주입(Dependency Injection) 패턴을 선호하는 추세다.
마무리
싱글톤을 쓸지 판단할 때는 "이 객체가 정말 프로그램 전체에서 단 하나여야 하는가?"를 먼저 자문해 보자. 그렇다면 Meyer's Singleton으로 구현하되, 여러 싱글톤 간의 의존 관계와 테스트 용이성을 함께 고려하는 것이 좋다.
