본문으로 건너뛰기
Embedded C++ for Real Systems · 36/41

Singleton 대안 패턴 — Service Locator·Static Init·Phantom

· Hawk · 6분 읽기

#한 줄 요약

“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에서 한 번 wiring
Logger logger;
Sensor sensor(logger);

장점은 다음과 같습니다.

  • 의존성이 시그니처에 그대로 드러납니다.
  • fake logger를 주입해 테스트가 쉽습니다.
  • 전역 상태가 없습니다.
  • static initialization fiasco가 없습니다.

단점은 다음과 같습니다.

  • 모든 객체가 의존성을 매개변수로 받으므로 매개변수 수가 늘어납니다.
  • 큰 시스템에서는 wiring 코드가 길어집니다.
// main.cpp — composition root
Logger 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_; }
};
// main
Logger 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에서 한 번만 하므로 부담이 크지 않습니다.

main.cpp
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-statics
static 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. Interface
class IUart {
public:
virtual ~IUart() = default;
virtual void send(uint8_t) = 0;
virtual int receive() = 0;
};
// 2. Concrete impl
class 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_;
};
// main
Logger 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 5 시작)

Part 5-01: Register 추상화 — MMIO를 type-safe하게. Memory-mapped register의 C++ 표현.

Embedded C++ for Real Systems · 37 of 41

  1. 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
  2. 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
  3. 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
  4. 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
  5. 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
  6. 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
  7. 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
  8. 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
  9. 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
  10. 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
  11. 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
  12. 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
  13. 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
  14. 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
  15. 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
  16. 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
  17. 17CRTP 패턴 분석 — vtable 없는 정적 다형성
  18. 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
  19. 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
  20. 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
  21. 21Custom Allocator 기초 — std::allocator 인터페이스 분석
  22. 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
  23. 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
  24. 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
  25. 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
  26. 26std::expected 분석 — C++23 결과 타입과 에러 전파
  27. 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
  28. 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
  29. 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
  30. 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
  31. 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
  32. 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
  33. 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
  34. 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
  35. 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
  36. 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
  37. 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
  38. 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
  39. 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
  40. 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
  41. 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교