이전 글에서 시리즈의 개요를 살펴보았고, 이번에는 GoF 디자인 패턴 중 하나인 싱글톤(Singleton) 패턴을 다룹니다. 싱글톤 패턴은 "프로그램 전역에서 단 하나의 인스턴스만 존재하며, 어디서든 접근 가능한 객체"를 보장하는 설계 기법입니다.
전통적 C++(C++98/03) 환경에서 싱글톤 구현은 멀티스레드 안전성, 초기화 시점 관리, 전역 상태로 인한 테스트 난이도 등의 문제로 유명했습니다. C++11 이후 표준에서 정적 지역 변수 초기화의 스레드 안전 보장, std::call_once 등의 도구가 등장하면서 구현 난이도가 감소했지만, 여전히 싱글톤은 "전역 상태"를 갖는다는 본질적 단점을 완전히 해결하지는 못했습니다.
모던 C++20 이상의 기능을 활용하면 어떤 측면이 더 개선될 수 있을까요? 이번 글에서는 전통적인 싱글톤 구현 방식과 모던 C++을 통한 개선점을 비교한 뒤, 여전히 남는 고민거리와 대안을 살펴보겠습니다.
패턴 소개: 싱글톤(Singleton)
의도: 특정 클래스의 인스턴스를 하나만 만들고, 이 인스턴스에 전역적으로 접근하도록 하는 패턴입니다.
예시 사용처: 글로벌 설정 매니저, 로그 관리자, 디바이스 핸들러 등을 전역적으로 공유할 필요가 있을 때.
고전적 문제점:
- 스레드 안전성: 멀티스레드 환경에서 Lazy Initialization(지연 초기화) 시 한 번만 초기화하도록 만드는 것이 어려웠음.
- 초기화 순서 이슈: 전역적으로 필요하나 다른 정적 객체보다 먼저/나중에 초기화되어야 하는 경우, 초기화 순서가 애매해질 수 있음.
- 테스트성 감소: 전역 인스턴스로 인해 테스트 더블(Stub, Mock) 삽입이 어렵고, 상태가 전역적으로 공유되어 테스트 격리가 힘듦.
- 멤버 관리 어려움: 수명 관리, 종료 시점 해제(Destruction)도 고려해야 하며, 불필요한 리소스 점유 문제 발생 가능.
기존 C++ 스타일 구현 (C++11/14/17)
C++11 이후 가장 흔히 사용되는 싱글톤 구현은 정적 지역 변수를 활용하는 방식입니다.
class ClassicSingleton {
public:
static ClassicSingleton& getInstance() {
static ClassicSingleton instance; // C++11부터 스레드 안전 초기화 보장
return instance;
}
void doSomething() { /* ... */ }
private:
ClassicSingleton() = default;
ClassicSingleton(const ClassicSingleton&) = delete;
ClassicSingleton& operator=(const ClassicSingleton&) = delete;
};
이점:
- static 지역 변수를 통한 lazy initialization이 스레드 안전해져 코드가 간단해짐.
- 별도의 mutex나 std::call_once를 명시적으로 사용하지 않아도 됨.
단점 여전:
- 전역 상태로 인한 테스트 어려움.
- 초기화 시점은 getInstance() 호출하는 순간이지만, 호출 순서에 따라 다른 정적 객체와 상호 의존성이 있다면 여전히 문제 발생 가능.
- 종료 시점 명확치 않음: 정적 객체 파괴 순서가 정해져 있지 않아, shutdown 과정에서 다른 정적 객체가 접근할 경우 문제가 생길 수 있음.
모던 C++20 이상의 개선점
1. constinit와 정적 초기화로 초기화 순서 문제 완화
C++20 constinit 키워드를 사용하면, 다음과 같이 컴파일 타임에 확정적으로 초기화되는 싱글톤을 만들 수도 있습니다.
#include <iostream>
class ModernSingleton {
public:
static ModernSingleton& getInstance() {
return instance;
}
void doSomething() const {
std::cout << "Modern Singleton doing something.\n";
}
private:
ModernSingleton() = default;
static constinit ModernSingleton instance; // 반드시 컴파일타임 초기화
};
constinit ModernSingleton ModernSingleton::instance;
고찰:
- constinit는 '이 변수는 반드시 상수 초기화를 해야 한다'는 의도를 명시합니다.
- 이를 통해 전역 인스턴스 초기화 시점이 명확해지고, 런타임에 불필요한 초기화 경합 최소화 가능.
- 단, 모든 초기화 로직이 상수 표현이 가능해야 하므로, 복잡한 런타임 의존 초기화에는 부적합.
2. std::call_once, std::unique_ptr 조합으로 명시적 자원 관리
Lazy Initialization이 필요한 경우 std::call_once와 std::unique_ptr를 결합할 수 있습니다.
#include <mutex>
#include <memory>
class ModernLazyUniqueSingleton {
public:
static ModernLazyUniqueSingleton& getInstance() {
std::call_once(initFlag, [](){
instance = std::make_unique<ModernLazyUniqueSingleton>();
});
return *instance;
}
void doSomething() const { /* ... */ }
private:
ModernLazyUniqueSingleton() = default;
static std::once_flag initFlag;
static std::unique_ptr<ModernLazyUniqueSingleton> instance;
};
std::once_flag ModernLazyUniqueSingleton::initFlag;
std::unique_ptr<ModernLazyUniqueSingleton> ModernLazyUniqueSingleton::instance;
고찰:
- std::unique_ptr로 동적 할당된 인스턴스를 관리하므로 종료 시점에 자원이 자동 반환됨.
- std::call_once로 초기화 로직이 한 번만 실행되어 스레드 안전성 확보.
- 이전 시대(예: raw pointer + double-checked locking)와 비교하면 코드가 훨씬 간결하고 안전.
3. Concepts, std::expected로 에러 처리 명확화
싱글톤 초기화 로직이 복잡해 외부 리소스(파일, 네트워크) 의존 시 실패 가능하다면 std::expected를 사용해 초기화 성공/실패를 명확히 표현할 수 있습니다. 싱글톤 호출부에서 std::expected를 반환하도록 설계하면, 전역적 상태를 쓰면서도 에러 처리를 타입 안전하게 수행할 수 있습니다.
#include <expected>
#include <mutex>
class ConfigSingleton {
public:
// 초기화 과정에서 에러 발생 가능성을 고려해 std::expected 반환
static std::expected<ConfigSingleton*, std::string> getInstance() {
std::call_once(initFlag, [](){
auto result = loadConfig();
if (result) {
instance = new ConfigSingleton(*result);
} else {
initError = result.error();
}
});
if (instance) return instance;
return std::unexpected(initError);
}
void doSomething() { /* ... */ }
private:
ConfigSingleton(ConfigData data) : configData(data) {}
static std::expected<ConfigData, std::string> loadConfig() {
// 파일 로드 실패 시 std::unexpected("error message") 반환
}
static std::once_flag initFlag;
static ConfigSingleton* instance;
static std::string initError;
ConfigData configData;
};
std::once_flag ConfigSingleton::initFlag;
ConfigSingleton* ConfigSingleton::instance = nullptr;
std::string ConfigSingleton::initError;
고찰:
- 전통적인 싱글톤은 getInstance()가 항상 성공한다고 가정. 에러 시 throw나 abort를 사용.
- 모던 C++: std::expected로 실패를 명시적으로 표현, 호출자가 오류 처리 로직을 선택할 수 있음.
- 전역 상태지만, 에러 처리만큼은 타입 안전하고 명확해짐.
4. 모듈(Modules)과 싱글톤
C++20 모듈을 도입하면, 싱글톤을 구현한 코드를 모듈 인터페이스로 제공할 수 있습니다. 이는 헤더 파일에 매크로 가드, 인클루드 순서 문제 등을 줄여 빌드 시간을 개선하고, 명확한 인터페이스/구현 분리를 가능하게 합니다.
// 모듈 인터페이스 예 (my_singleton.ixx)
export module my_singleton;
import <mutex>;
export class MyModuleSingleton {
public:
static MyModuleSingleton& getInstance() {
std::call_once(initFlag, [](){ instance = new MyModuleSingleton(); });
return *instance;
}
private:
MyModuleSingleton() = default;
static std::once_flag initFlag;
static MyModuleSingleton* instance;
};
// 모듈 구현부(my_singleton.cpp)
module my_singleton;
std::once_flag MyModuleSingleton::initFlag;
MyModuleSingleton* MyModuleSingleton::instance = nullptr;
고찰:
- 모듈로 정의하면 싱글톤 구현부를 감추고, 외부에는 깔끔한 인터페이스만 공개.
- 여전히 전역 상태 문제는 남지만, 빌드 시스템과 의존성 관리 측면에서 개선.
5. 테스트성과 종속성 주입(DI)
싱글톤은 테스트하기 어렵다는 비판을 자주 받습니다. 모던 C++에서 싱글톤을 구현하더라도 전역 상태는 변함없습니다. 하지만 Concepts와 lambda로 인터페이스를 추상화한 뒤, 실제 싱글톤 인스턴스 대신 Mock 객체를 주입할 수도 있습니다. 예를 들어, Factory Method나 Abstract Factory 패턴과 조합해 인터페이스만 노출하고, 실제 런타임에서 싱글톤을 할당하지만 테스트 시에는 Mock을 주입하는 식으로 전역 상태 의존도를 줄일 수 있습니다.
고찰:
- 모던 C++ 기능 자체가 싱글톤 테스트를 마법처럼 쉽게 하지는 않음.
- 다만 std::function, Concepts, variant 등을 활용하면 싱글톤을 감싼 추상화 계층을 만들기 용이해짐.
- 전역 상태를 완화하기 위해 싱글톤 대신 '전역 함수 시 Mock 주입'이 가능한 구조를 취하거나, 의존성 주입 컨테이너 활용 가능.
6. 성능과 컴파일러 최적화 관점
constinit나 constexpr를 활용해 싱글톤 인스턴스를 컴파일 타임에 확정 지을 수 있다면, 런타임에 불필요한 초기화 비용을 줄일 수 있습니다. 또한 컴파일러가 상수 표현으로 최적화해, 접근 비용이 거의 없는 전역 상수 객체를 제공할 수도 있습니다.
고찰:
- 성능상 이점은 주로 '런타임 초기화 비용 감소', 'thread-safe 초기화 코드 제거' 형태로 나타남.
- 단, 싱글톤 자체가 성능 지표 개선에 직결되는 패턴은 아니므로, 주요 장점은 안전성과 명확성에서 오는 유지보수성 향상임.
비교 및 분석
- 전통적 구현(C++11 전후): 정적 지역 변수 또는 double-checked locking 복잡한 코드 필요. 테스트, 에러 처리, 초기화/종료 순서 문제가 여전.
- 모던 C++(C++20 이상):
- constinit, constexpr, std::call_once, std::unique_ptr 등 도구로 초기화/종료 안정성 향상.
- std::expected를 통한 에러 처리 명확화 가능.
- 모듈(Modules)을 통한 빌드/의존성 관리 개선.
- 여전히 전역 상태로 인한 테스트 어려움, 의존성 강한 구조 문제는 근본적으로 남음.
결론적으로, 모던 C++은 싱글톤 자체를 획기적으로 바꾸지는 못하지만, 초기화 안전성, 메모리 관리, 에러 처리 등의 보조 측면을 크게 개선합니다. 이를 통해 싱글톤 패턴 구현 시 발생하던 전통적 문제를 일부 완화할 수 있습니다.
마무리
모던 C++ 시대에서도 싱글톤은 전역 상태를 관리하는 하나의 방법입니다. 더 안전한 초기화, 명확한 에러 처리, 모듈 도입으로 빌드 타임 개선 등 개선점은 있지만, 싱글톤 패턴이 가진 전역 상태의 단점과 테스트성 한계는 여전합니다. 가능하다면 의존성 주입(DI)나 명시적 전달을 통해 전역 상태를 최소화하는 방향을 고민할 필요가 있습니다.
다음 글에서는 팩토리 메서드(Factory Method) 패턴을 다루며, 가상 함수 대신 Concepts를 활용해 인터페이스 요구사항을 명확히 표현하고, std::format이나 std::optional 등을 사용해 더 간결하고 안전한 객체 생성 로직을 구현하는 방법을 살펴보겠습니다.