Singleton 대안 패턴 — Service Locator·Static Init·Phantom
#한 줄 요약
“Singleton은 임베디드에서도 안티패턴입니다.” Static DI, Construct-On-First-Use, service locator가 세 가지 대안입니다.
#어떤 문제를 푸는가
Singleton에는 다음과 같은 문제가 있습니다(GoF조차 권장하지 않습니다).
- 전역 상태라서 테스트가 어렵습니다.
- 의존성이 시그니처에 드러나지 않아 암묵적입니다.
- 서로 다른 TU의 static 초기화 순서가 꼬이는 Static Initialization Order Fiasco가 생깁니다.
- Multi-thread 환경에서 atomic getInstance가 필요합니다.
- Mock이 어렵습니다.
임베디드도 예외가 아닙니다. 다만 제약된 환경이라 대안의 선택지가 조금 다릅니다.
이 글에서는 세 가지 Singleton 대안을 살펴봅니다.
#대안 1 — Construct-On-First-Use
C++11의 thread-safe magic static을 활용합니다.
class Logger {public: static Logger& instance() { static Logger inst; // C++11 — 최초 호출 시 1번 생성 return inst; }
void log(const char* msg) { /* */ }
private: Logger() = default;};
Logger::instance().log("hello");장점은 다음과 같습니다.
- 첫 호출 시 생성되므로 Static Init Order Fiasco가 해결됩니다.
- C++11 이상에서 thread-safe입니다.
- 코드가 간단합니다.
단점은 다음과 같습니다.
- 여전히 전역이라 테스트에서 mock하기 어렵습니다.
- thread-safe guard 비용이 들며,
-fno-threadsafe-statics로 끄면 코드가 더 작아집니다.
#대안 2 — Static DI (생성자 주입)
의존성을 명시적으로 받고 Singleton을 쓰지 않습니다.
class Logger {public: void log(const char* msg) { /* */ }};
class Sensor {public: Sensor(Logger& logger) : logger_(logger) {}
void read() { // ... logger_.log("read done"); }
private: Logger& logger_;};
// main에서 한 번 wiringLogger logger;Sensor sensor(logger);장점은 다음과 같습니다.
- 의존성이 시그니처에 그대로 드러납니다.
- fake logger를 주입해 테스트가 쉽습니다.
- 전역 상태가 없습니다.
- static initialization fiasco가 없습니다.
단점은 다음과 같습니다.
- 모든 객체가 의존성을 매개변수로 받으므로 매개변수 수가 늘어납니다.
- 큰 시스템에서는 wiring 코드가 길어집니다.
// main.cpp — composition rootLogger logger;Sensor sensor(logger);Display display(logger);DataCache cache(logger);EventBus bus(logger);System system(sensor, display, cache, bus, logger);dependency wiring이 한 곳에 모입니다. 명확하지만 길어집니다.
#대안 3 — Service Locator (조심)
전역 registry에 service를 등록하고 객체가 id로 검색하는 방식입니다.
class ServiceLocator { static inline Logger* logger_ = nullptr; static inline Database* db_ = nullptr;
public: static void register_logger(Logger* l) { logger_ = l; } static Logger* logger() { return logger_; }
static void register_db(Database* d) { db_ = d; } static Database* db() { return db_; }};
// mainLogger logger;ServiceLocator::register_logger(&logger);
// 다른 곳void some_function() { ServiceLocator::logger()->log("hello");}장점은 다음과 같습니다.
- 의존성 wiring이 가볍습니다.
- 테스트에서 fake를 등록할 수 있습니다.
단점은 다음과 같습니다.
- 여전히 전역이라 Singleton과 거의 같습니다.
- register를 누락하면 nullptr이 되는 순서 의존이 있습니다.
- anti-pattern으로 보는 시각도 있습니다.
대부분 Singleton보다 약간 나은 정도입니다. Static DI를 더 권장합니다.
#임베디드 — Static DI 실용
임베디드에서는 시스템 wiring을 main에서 한 번만 하므로 부담이 크지 않습니다.
int main() { // 1. peripheral 초기화 SystemClock_Init(); GPIO_Init(); UART_Init();
// 2. service 객체 생성 (stack 또는 static) static UartLogger logger; static FlashStore store; static EventBus bus;
// 3. application 객체 wiring Sensor sensor(logger, bus); Controller controller(sensor, store, logger);
// 4. 실행 controller.run();
return 0;}linear한 흐름으로 모든 의존성이 명시됩니다. Mock 테스트에서는 다음과 같이 씁니다.
TEST(SensorTest, ReadDoesNotCrash) { FakeLogger logger; FakeBus bus; Sensor sensor(logger, bus);
sensor.read(); EXPECT_EQ(logger.last_message(), "read done");}#대안 4 — Template Injection (compile-time)
runtime 비용이 0이며 의존성이 컴파일 타임에 결정됩니다.
template<typename Logger>class Sensor { Logger& logger_;public: Sensor(Logger& l) : logger_(l) {} void read() { logger_.log("read"); }};
// 사용UartLogger logger;Sensor<UartLogger> sensor(logger);
sensor.read(); // logger_.log 직접 호출 — inline 가능장점은 다음과 같습니다.
- zero runtime cost입니다.
- virtual 호출이 없습니다.
단점은 다음과 같습니다.
- template instantiation으로 type이 늘어납니다.
- Sensor 클래스가 logger의 type을 알고 있어야 합니다.
CRTP나 static polymorphism의 응용이며 Part 2-08에서 다룹니다.
#진짜 Singleton이 옳은 경우
드물지만 있습니다. 시스템에 물리적으로 하나뿐인 하드웨어 자원입니다.
- Serial port (UART0)
- Display controller
- 특정 ADC
class Uart0 {public: static Uart0& instance() { static Uart0 inst; return inst; }
void send(uint8_t b) { /* */ }
private: Uart0() { /* hw init */ }};이 경우에도 interface를 분리하는 편이 좋습니다.
class IUart {public: virtual ~IUart() = default; virtual void send(uint8_t) = 0;};
class Uart0 : public IUart { Uart0() = default;public: static IUart& instance() { static Uart0 inst; return inst; }
void send(uint8_t b) override { /* */ }};
// 테스트class FakeUart : public IUart {public: std::vector<uint8_t> sent; void send(uint8_t b) override { sent.push_back(b); }};interface를 통한 의존성을 둡니다. 운영 환경에서는 Uart0::instance()를 주입하고 테스트에서는 FakeUart를 주입합니다.
#자주 보는 함정과 안티패턴
#1. Singleton 남용
“전역 접근이 편하다”는 이유로 모든 service를 singleton으로 만들면 테스트가 지옥이 됩니다. DI를 우선합니다.
#2. Singleton의 destructor 의존
임베디드는 main이 끝나지 않으므로 destructor가 호출되지 않습니다. destructor에 중요한 로직을 두지 않습니다.
#3. thread-safe 비활성화
#pragma push_options// -fno-threadsafe-staticsstatic Logger inst;single-task RTOS면 괜찮습니다. multi-task에서는 thread-safe를 유지합니다.
#4. Construct-On-First-Use의 destructor 순서
여러 singleton의 destructor 호출 순서는 unspecified입니다. 의존성이 있으면 위험하지만, 임베디드는 main이 끝나지 않으므로 보통 무관합니다.
#5. Service Locator의 register 순서
register하지 않은 service를 사용하면 nullptr로 crash가 납니다. 순서를 명시합니다.
#6. Mock 못 하는 객체
모든 의존성이 concrete singleton이면 mock이 불가능합니다. interface를 통해 주입합니다.
#임베디드 — Hardware Singleton 패턴
UART0 같은 hardware singleton은 DI와 factory를 조합해서 다룹니다.
// 1. Interfaceclass IUart {public: virtual ~IUart() = default; virtual void send(uint8_t) = 0; virtual int receive() = 0;};
// 2. Concrete implclass Uart0 : public IUart { Uart0() { init_hw(); }public: static Uart0& get() { static Uart0 inst; return inst; } void send(uint8_t b) override { USART0->DR = b; } int receive() override { return USART0->DR; }};
// 3. 사용 — interface 받음class Logger {public: Logger(IUart& uart) : uart_(uart) {} void log(const char* msg) { while (*msg) uart_.send(*msg++); }private: IUart& uart_;};
// mainLogger logger(Uart0::get());
// 테스트class FakeUart : public IUart { /* */ };FakeUart fake;Logger test_logger(fake);하드웨어는 singleton으로 두고 application은 DI로 받는 균형입니다.
#DI Container — overkill?
대규모 desktop SW에서는 Spring이나 Guice 같은 DI framework를 씁니다. 임베디드에서는 manual wiring으로 충분합니다.
// 가벼운 DI container — 직접 작성class Container {public: template<typename T, typename... Args> T& create(Args&&... args) { static T instance(std::forward<Args>(args)...); return instance; }};
Container c;auto& logger = c.create<Logger>();auto& sensor = c.create<Sensor>(logger);대부분 불필요합니다. 임베디드에서는 명시적 wiring이 더 깨끗합니다.
#측정 — Singleton vs DI
5개 service 시스템의 코드 크기 비교입니다.
# Singleton everywhere.text: 4.2 KB.bss: 120 B (singleton instances)runtime: thread-safe guards
# Static DI (main에서 wiring).text: 3.8 KB.bss: 120 B (named instances)runtime: 0 (no guards)DI가 약간 작고 더 빠릅니다. 의존성이 명확해지는 가치도 함께 얻습니다.
#정리
- Singleton은 임베디드에서도 회피합니다. 전역 상태, 테스트 난이도, init order 문제가 따라옵니다.
- 대안은 세 가지입니다 — Construct-On-First-Use, Static DI, Service Locator.
- Static DI를 권장합니다. main에서 wiring하고 의존성을 명시합니다.
- Template injection은 zero-cost입니다.
- 진짜 hardware singleton도 interface를 거쳐 DI합니다.
- DI framework는 임베디드에 overkill이므로 manual wiring을 사용합니다.
#관련 항목
- Part 1-06: 스타트업 코드 — Static Init Order Fiasco
- Part 3-10: 소유권 모델 — DI와 ownership
- GoF 5: Singleton (avoid)
- TDD Pattern 43: Singleton (avoid)
#다음 글 (Part 5 시작)
Part 5-01: Register 추상화 — MMIO를 type-safe하게. Memory-mapped register의 C++ 표현.
Embedded C++ for Real Systems · 37 of 41
- 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
- 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
- 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
- 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
- 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
- 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
- 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
- 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
- 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
- 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
- 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
- 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
- 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
- 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
- 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
- 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
- 17CRTP 패턴 분석 — vtable 없는 정적 다형성
- 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
- 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
- 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
- 21Custom Allocator 기초 — std::allocator 인터페이스 분석
- 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
- 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
- 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
- 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
- 26std::expected 분석 — C++23 결과 타입과 에러 전파
- 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
- 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
- 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
- 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
- 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
- 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
- 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
- 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
- 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
- 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
- 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
- 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
- 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
- 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
- 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
관련 글
임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
범용 HAL 구조 — 벤더 종속성 격리, 다중 보드/MCU 지원, 시리즈 마무리.
Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
UART, SPI, I2C — peripheral을 type-safe class로. Blocking, interrupt, DMA 패턴.
GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
GPIO pin = type — 컴파일 타임에 핀 설정 검증, runtime 비용 0.