C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드
이 글의 핵심
C++ 디자인 패턴에 대한 실전 가이드입니다. Singleton·Factory·Builder·Prototype 생성 패턴 가이드 등을 예제와 함께 상세히 설명합니다.
들어가며: 객체 생성이 복잡하다
”설정 객체를 어디서든 접근하고 싶어요”
애플리케이션 설정 객체가 필요했습니다. 하지만:
- 여러 곳에서 접근 필요
- 하나의 인스턴스만 필요
- 전역 변수는 피하고 싶음
생성 패턴은 “객체를 누가, 언제, 어떤 조건으로 만들지”를 한곳에서 관리하게 해 줍니다. Singleton(싱글톤—전역에서 단 하나만 존재하는 객체를 보장하는 패턴)은 전역 접근이 꼭 필요할 때만 쓰고(테스트·멀티스레드에서 불리할 수 있음), Factory(팩토리—객체 생성 로직을 한곳에 모아 두는 패턴)·Builder(빌더—복잡한 객체를 단계적으로 만드는 패턴)는 생성 로직을 캡슐화해서 타입 추가·옵션 변경 시 한 곳만 수정하도록 할 때 실무에서 자주 씁니다.
같은 패턴은 JavaScript에서도 싱글톤·팩토리·옵저버로 다루고, Python 데코레이터 글에서는 싱글톤을 데코레이터로 감싸는 예가 나옵니다. 구조 패턴과의 연결은 C++ 구조 패턴 가이드를 이어서 읽으면 잡히기 쉽습니다.
생성 패턴 역할을 한눈에 보면 아래와 같습니다.
flowchart LR
subgraph S["Singleton"]
S1[단일 인스턴스]
S2[전역 접근]
end
subgraph F["Factory"]
F1[생성 캡슐화]
F2[타입별 분기]
end
subgraph B["Builder"]
B1[단계적 생성]
B2[옵션 조합]
end
subgraph P["Prototype"]
P1[복제]
P2[기존 객체 복사]
end
subgraph AF["Abstract Factory"]
AF1[제품군 생성]
AF2[플랫폼별 일관]
end
실무에서 겪는 문제 시나리오
| 시나리오 | 증상 | 적합한 패턴 |
|---|---|---|
| 전역 설정 | 로그, DB 연결, API 키 등 앱 전체에서 하나만 필요 | Singleton |
| 타입별 분기 | shapeType에 따라 Circle, Rectangle 등 생성 로직이 분산됨 | Factory |
| 복잡한 옵션 | HttpRequest에 URL, 메서드, 헤더 10개, 바디, 타임아웃 등 15개 이상 | Builder |
| 객체 복제 | 게임 몬스터, 문서 템플릿처럼 기존 객체를 복사해 변형하고 싶음 | Prototype |
| 제품군 일관 | Windows/Mac 버튼·체크박스를 플랫폼별로 묶어 생성 | Abstract Factory |
문제의 코드: 전역 변수로 설정을 두면 func1, func2 어디서든 직접 접근할 수 있지만, 테스트 시 목(mock)(실제 객체 대신 동작을 흉내 내는 가짜 객체)으로 바꾸기 어렵고, 초기화 순서나 멀티스레드에서 경쟁 조건이 생기기 쉽습니다.
// ❌ 전역 변수
Config globalConfig;
void func1() {
globalConfig.getValue("key"); // 전역 변수 직접 접근
}
void func2() {
globalConfig.setValue("key", "value");
}
Singleton으로 해결: 생성자를 private로 두어 외부에서 new Config()를 막고, getInstance()로만 단일 인스턴스에 접근하게 합니다. 아래 예제는 스레드 안전하지 않으므로, 실무에서는 다음에 나오는 static 지역 변수나 std::call_once 방식을 쓰는 것이 좋습니다.
class Config {
static Config* instance;
Config() {} // private 생성자
public:
static Config& getInstance() {
if (!instance) {
instance = new Config();
}
return *instance;
}
std::string getValue(const std::string& key);
void setValue(const std::string& key, const std::string& value);
};
// 사용
Config::getInstance().getValue("key");
이 글을 읽으면:
- Singleton 패턴으로 단일 인스턴스를 관리할 수 있습니다.
- Factory 패턴으로 객체 생성을 캡슐화할 수 있습니다.
- Builder 패턴으로 복잡한 객체를 단계적으로 생성할 수 있습니다.
- Prototype 패턴으로 기존 객체를 복제해 변형할 수 있습니다.
- Abstract Factory 패턴으로 제품군을 플랫폼별로 일관되게 생성할 수 있습니다.
- 실전에서 생성 패턴을 활용할 수 있습니다.
문제 시나리오: 생성 패턴이 필요한 상황
시나리오 1: 전역 설정 객체 접근 문제
상황: 로그 설정, DB 연결 정보, API 키 등 앱 전체에서 하나만 있어야 하는 객체가 있다. 여러 모듈에서 접근해야 하는데, 전역 변수로 두면 테스트 시 mock으로 교체하기 어렵고, 초기화 순서 의존성이 생긴다.
원인: 전역 변수는 “언제 초기화되는지”가 불명확하고, 정적 초기화 순서 fiasco(Static Initialization Order Fiasco)로 인해 다른 전역 객체가 아직 초기화되지 않은 상태에서 접근할 수 있다. 멀티스레드에서는 경쟁 조건(race condition)이 발생한다.
해결: Singleton으로 단일 인스턴스를 보장하고, lazy initialization으로 최초 사용 시점에만 생성한다. 테스트가 필요하면 인터페이스 추상화 + 의존성 주입을 고려한다.
시나리오 2: 다형적 객체 생성의 복잡성
상황: ShapeType에 따라 Circle, Rectangle, Triangle을 만들어야 한다. 클라이언트 코드 곳곳에 if (type == CIRCLE) new Circle(); else if (type == RECTANGLE)...가 흩어져 있다. 새 도형 타입을 추가할 때마다 여러 파일을 수정해야 한다.
원인: 생성 로직이 분산되어 있어 “어디서 어떤 객체를 만드는지” 파악하기 어렵다. 타입 추가 시 누락되기 쉽다.
해결: Factory 패턴으로 생성 로직을 한곳에 모은다. 새 타입 추가 시 Factory의 switch나 등록 테이블만 수정하면 된다.
시나리오 3: 생성자 파라미터가 너무 많다
상황: HttpRequest를 만들 때 URL, 메서드, 헤더 10개, 바디, 타임아웃, 재시도 횟수 등 15개 이상의 옵션이 있다. 생성자에 모두 넘기면 가독성이 떨어지고, 대부분 기본값인데 매번 지정해야 한다.
원인: 점층적 생성자(telescoping constructor) 패턴—HttpRequest(url), HttpRequest(url, method), HttpRequest(url, method, headers)… —은 조합이 폭발적으로 늘어난다.
해결: Builder 패턴으로 메서드 체인으로 필요한 옵션만 단계적으로 설정하고, build()로 최종 객체를 만든다.
목차
1. Singleton 패턴
기본 구현 (Thread-Safe)
C++11부터 함수 내부의 static 지역 변수 초기화는 스레드에 안전합니다. 따라서 getInstance() 안에서 static Logger instance;를 두면, 최초 호출 시 한 번만 생성되고 이후에는 같은 인스턴스가 반환됩니다. = delete로 복사·대입을 막아 인스턴스가 복제되지 않게 합니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o singleton_logger singleton_logger.cpp && ./singleton_logger
#include <iostream>
#include <string>
class Logger {
Logger() {}
public:
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
static Logger& getInstance() {
static Logger instance; // C++11: thread-safe
return instance;
}
void log(const std::string& message) {
std::cout << "[LOG] " << message << "\n";
}
};
int main() {
Logger::getInstance().log("Application started");
return 0;
}
실행 결과: [LOG] Application started 가 한 줄 출력됩니다.
Lazy Initialization
DB 연결처럼 생성 비용이 큰 경우에는, 앱 시작 시점이 아니라 최초 사용 시점에만 초기화하는 게 좋습니다. std::call_once와 std::once_flag를 쓰면 여러 스레드가 동시에 getInstance()를 호출해도 실제 생성은 한 번만 이루어지고, 나머지는 대기 후 이미 만들어진 인스턴스를 사용합니다.
#include <memory>
#include <mutex>
class Database {
static std::unique_ptr<Database> instance;
static std::once_flag initFlag;
Database() {
// 무거운 초기화...
}
public:
static Database& getInstance() {
std::call_once(initFlag, {
instance.reset(new Database());
});
return *instance;
}
};
std::unique_ptr<Database> Database::instance;
std::once_flag Database::initFlag;
주의사항
getInstance()를 호출할 때마다 함수 호출 비용이 들고, 코드만 봐서는 “같은 인스턴스”인지 한눈에 들어오지 않습니다. 한 스코프 안에서 여러 번 쓸 때는 한 번만 참조를 받아 두고 그 참조로 로그를 남기면 가독성과 성능 모두 유리합니다.
// ❌ 나쁜 예: 전역 변수처럼 남용
Logger::getInstance().log("msg1");
Logger::getInstance().log("msg2");
Logger::getInstance().log("msg3");
// ✅ 좋은 예: 참조 저장
auto& logger = Logger::getInstance();
logger.log("msg1");
logger.log("msg2");
logger.log("msg3");
완전한 Singleton 예제: 스레드 안전 Logger
// singleton_logger_complete.cpp
// g++ -std=c++17 -pthread -o singleton_logger singleton_logger_complete.cpp
#include <iostream>
#include <string>
#include <mutex>
#include <chrono>
class Logger {
std::mutex mutex_;
std::string prefix_ = "[LOG]";
Logger() = default;
public:
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
static Logger& getInstance() {
static Logger instance;
return instance;
}
void setPrefix(const std::string& prefix) {
std::lock_guard<std::mutex> lock(mutex_);
prefix_ = prefix;
}
void log(const std::string& message) {
std::lock_guard<std::mutex> lock(mutex_);
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
std::cout << prefix_ << " " << std::ctime(&time) << " " << message << "\n";
}
};
int main() {
Logger::getInstance().log("Application started");
Logger::getInstance().setPrefix("[INFO]");
Logger::getInstance().log("Configuration loaded");
return 0;
}
2. Factory 패턴
Simple Factory
클라이언트가 Circle, Rectangle 같은 구체 타입을 직접 new하지 않고, ShapeType만 넘기면 Factory가 적절한 객체를 만들어 반환합니다. 새 도형을 추가할 때는 createShape의 switch만 수정하면 되므로, 생성 로직이 한곳에 모입니다. 반환 타입은 공통 인터페이스(Shape)의 스마트 포인터로 두어 소유권과 메모리 해제를 명확히 합니다.
#include <memory>
#include <iostream>
enum class ShapeType { CIRCLE, RECTANGLE, TRIANGLE };
class Shape {
public:
virtual ~Shape() = default;
virtual void draw() = 0;
};
class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing Circle\n";
}
};
class Rectangle : public Shape {
public:
void draw() override {
std::cout << "Drawing Rectangle\n";
}
};
class ShapeFactory {
public:
static std::unique_ptr<Shape> createShape(ShapeType type) {
switch (type) {
case ShapeType::CIRCLE:
return std::make_unique<Circle>();
case ShapeType::RECTANGLE:
return std::make_unique<Rectangle>();
default:
return nullptr;
}
}
};
// 사용
int main() {
auto shape = ShapeFactory::createShape(ShapeType::CIRCLE);
if (shape) shape->draw();
}
Factory Method
어플리케이션 종류(PDF 앱, Word 앱)에 따라 생성되는 문서 타입이 달라질 때 사용합니다. Application은 “문서를 만든다”는 추상 메서드(createDocument)만 정의하고, PDFApplication·WordApplication이 각각 PDFDocument·WordDocument를 생성합니다. 이렇게 하면 새 문서 형식을 추가할 때 기존 newDocument() 흐름은 건드리지 않고, 파생 클래스만 추가하면 됩니다.
#include <memory>
#include <iostream>
class Document {
public:
virtual ~Document() = default;
virtual void open() = 0;
virtual void save() = 0;
};
class PDFDocument : public Document {
public:
void open() override { std::cout << "Opening PDF\n"; }
void save() override { std::cout << "Saving PDF\n"; }
};
class WordDocument : public Document {
public:
void open() override { std::cout << "Opening Word\n"; }
void save() override { std::cout << "Saving Word\n"; }
};
class Application {
public:
virtual ~Application() = default;
virtual std::unique_ptr<Document> createDocument() = 0;
void newDocument() {
auto doc = createDocument();
doc->open();
}
};
class PDFApplication : public Application {
public:
std::unique_ptr<Document> createDocument() override {
return std::make_unique<PDFDocument>();
}
};
class WordApplication : public Application {
public:
std::unique_ptr<Document> createDocument() override {
return std::make_unique<WordDocument>();
}
};
Abstract Factory
관련된 제품군(버튼, 체크박스 등)을 플랫폼별(Windows, Mac)로 묶어서 만들고 싶을 때 씁니다. GUIFactory가 “버튼”과 “체크박스” 생성 인터페이스를 정의하고, WindowsFactory는 Windows 스타일, MacFactory는 Mac 스타일 객체를 반환합니다. renderUI는 구체 플랫폼을 알 필요 없이 GUIFactory만 받아 쓰므로, 테마나 OS를 바꿀 때 Factory만 교체하면 됩니다.
#include <memory>
#include <iostream>
class Button {
public:
virtual ~Button() = default;
virtual void render() = 0;
};
class Checkbox {
public:
virtual ~Checkbox() = default;
virtual void render() = 0;
};
class WindowsButton : public Button {
public:
void render() override { std::cout << "Windows Button\n"; }
};
class WindowsCheckbox : public Checkbox {
public:
void render() override { std::cout << "Windows Checkbox\n"; }
};
class MacButton : public Button {
public:
void render() override { std::cout << "Mac Button\n"; }
};
class MacCheckbox : public Checkbox {
public:
void render() override { std::cout << "Mac Checkbox\n"; }
};
class GUIFactory {
public:
virtual ~GUIFactory() = default;
virtual std::unique_ptr<Button> createButton() = 0;
virtual std::unique_ptr<Checkbox> createCheckbox() = 0;
};
class WindowsFactory : public GUIFactory {
public:
std::unique_ptr<Button> createButton() override {
return std::make_unique<WindowsButton>();
}
std::unique_ptr<Checkbox> createCheckbox() override {
return std::make_unique<WindowsCheckbox>();
}
};
class MacFactory : public GUIFactory {
public:
std::unique_ptr<Button> createButton() override {
return std::make_unique<MacButton>();
}
std::unique_ptr<Checkbox> createCheckbox() override {
return std::make_unique<MacCheckbox>();
}
};
void renderUI(GUIFactory& factory) {
auto button = factory.createButton();
auto checkbox = factory.createCheckbox();
button->render();
checkbox->render();
}
Factory 패턴 비교
flowchart TB
subgraph SF["Simple Factory"]
SF1[createShape type]
SF1 --> SF2[switch 분기]
SF2 --> SF3[구체 타입 반환]
end
subgraph FM["Factory Method"]
FM1["Application createDocument"]
FM1 --> FM2[PDFApp → PDFDoc]
FM1 --> FM3[WordApp → WordDoc]
end
subgraph AF["Abstract Factory"]
AF1[GUIFactory]
AF1 --> AF2[createButton]
AF1 --> AF3[createCheckbox]
AF2 --> AF4[Windows/Mac]
end
| 패턴 | 용도 | 확장 시 |
|---|---|---|
| Simple Factory | 타입별 객체 생성 | switch 수정 |
| Factory Method | 제품 생성 위임 | 파생 클래스 추가 |
| Abstract Factory | 제품군 생성 | Factory 구현체 추가 |
3. Builder 패턴
기본 Builder
피자처럼 옵션이 많은 객체(도우, 소스, 토핑)를 생성할 때, 생성자에 인자를 너무 많이 넘기지 않고 메서드 체인으로 단계적으로 세팅합니다. PizzaBuilder의 각 setter는 return *this를 해서 .setDough(...).setSauce(...).addTopping(...)처럼 이어 쓸 수 있고, 마지막에 build()로 최종 Pizza 객체를 만듭니다. 필수/선택 항목을 나중에 바꾸기도 쉽습니다.
#include <iostream>
#include <string>
#include <vector>
class Pizza {
std::string dough;
std::string sauce;
std::vector<std::string> toppings;
public:
void setDough(const std::string& d) { dough = d; }
void setSauce(const std::string& s) { sauce = s; }
void addTopping(const std::string& t) { toppings.push_back(t); }
void show() {
std::cout << "Pizza with " << dough << " dough, " << sauce << " sauce\n";
std::cout << "Toppings: ";
for (const auto& t : toppings) {
std::cout << t << " ";
}
std::cout << "\n";
}
};
class PizzaBuilder {
Pizza pizza;
public:
PizzaBuilder& setDough(const std::string& dough) {
pizza.setDough(dough);
return *this;
}
PizzaBuilder& setSauce(const std::string& sauce) {
pizza.setSauce(sauce);
return *this;
}
PizzaBuilder& addTopping(const std::string& topping) {
pizza.addTopping(topping);
return *this;
}
Pizza build() {
return pizza;
}
};
// 사용
int main() {
Pizza pizza = PizzaBuilder()
.setDough("thin")
.setSauce("tomato")
.addTopping("cheese")
.addTopping("pepperoni")
.build();
pizza.show();
}
Fluent Interface: HttpRequest Builder
HTTP 요청도 URL, 메서드, 헤더, 바디 등 설정할 항목이 많아서 Builder로 묶기 좋습니다. HttpRequest::Builder는 내부에 HttpRequest를 갖고, method(), header(), body()가 각각 *this를 반환해 체인으로 호출할 수 있게 합니다. build() 시점에 설정이 모두 반영된 요청 객체가 만들어지므로, 생성 로직과 검증을 한 곳에 모을 수 있습니다.
#include <iostream>
#include <string>
#include <map>
class HttpRequest {
std::string url;
std::string method = "GET";
std::map<std::string, std::string> headers;
std::string body;
public:
class Builder {
HttpRequest request;
public:
Builder(const std::string& url) {
request.url = url;
}
Builder& method(const std::string& m) {
request.method = m;
return *this;
}
Builder& header(const std::string& key, const std::string& value) {
request.headers[key] = value;
return *this;
}
Builder& body(const std::string& b) {
request.body = b;
return *this;
}
HttpRequest build() {
return request;
}
};
void send() {
std::cout << method << " " << url << "\n";
for (const auto& [key, value] : headers) {
std::cout << key << ": " << value << "\n";
}
if (!body.empty()) {
std::cout << "Body: " << body << "\n";
}
}
};
// 사용
int main() {
auto request = HttpRequest::Builder("https://api.example.com/users")
.method("POST")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer token123")
.body(R"({"name": "Alice"})")
.build();
request.send();
}
완전한 Builder 예제: 검증 포함
#include <stdexcept>
#include <string>
class DatabaseConfig {
std::string host_;
int port_ = 5432;
std::string user_;
std::string password_;
int poolSize_ = 10;
public:
class Builder {
DatabaseConfig config;
public:
Builder& host(const std::string& h) {
config.host_ = h;
return *this;
}
Builder& port(int p) {
config.port_ = p;
return *this;
}
Builder& credentials(const std::string& u, const std::string& p) {
config.user_ = u;
config.password_ = p;
return *this;
}
Builder& poolSize(int s) {
config.poolSize_ = s;
return *this;
}
DatabaseConfig build() {
if (config.host_.empty())
throw std::invalid_argument("host is required");
if (config.user_.empty())
throw std::invalid_argument("user is required");
if (config.poolSize_ <= 0)
throw std::invalid_argument("poolSize must be positive");
return config;
}
};
};
4. Prototype 패턴
기본 Prototype: clone()
기존 객체를 복사해 새 객체를 만드는 패턴입니다. 생성자로 초기화하는 것보다 “기존 객체와 거의 같은데 일부만 다름”인 경우 효율적입니다. Prototype 인터페이스에 clone() 메서드를 두고, 각 구체 클래스가 자신을 복제해 반환합니다.
#include <iostream>
#include <memory>
#include <string>
class Prototype {
public:
virtual ~Prototype() = default;
virtual std::unique_ptr<Prototype> clone() const = 0;
virtual void describe() const = 0;
};
class Monster : public Prototype {
std::string name_;
int hp_;
int attack_;
public:
Monster(const std::string& name, int hp, int attack)
: name_(name), hp_(hp), attack_(attack) {}
std::unique_ptr<Prototype> clone() const override {
return std::make_unique<Monster>(*this); // 복사 생성자 활용
}
void describe() const override {
std::cout << "Monster " << name_ << " HP:" << hp_ << " ATK:" << attack_ << "\n";
}
void setHp(int hp) { hp_ = hp; }
};
// 사용
int main() {
auto base = std::make_unique<Monster>("Goblin", 50, 10);
auto strong = base->clone();
static_cast<Monster*>(strong.get())->setHp(150);
base->describe(); // Monster Goblin HP:50 ATK:10
strong->describe(); // Monster Goblin HP:150 ATK:10
}
Deep Copy vs Shallow Copy
포인터나 참조를 멤버로 갖는 경우, 얕은 복사(Shallow Copy)는 원본과 복제본이 같은 객체를 가리켜 수정 시 부작용이 생깁니다. 깊은 복사(Deep Copy)로 내부 포인터가 가리키는 객체까지 새로 복제해야 합니다.
// ❌ 얕은 복사: 위험
class Document {
std::vector<std::string>* paragraphs_; // 포인터
public:
Document() : paragraphs_(new std::vector<std::string>()) {}
Document(const Document& other) : paragraphs_(other.paragraphs_) {} // 같은 객체 공유!
std::unique_ptr<Document> clone() const {
return std::make_unique<Document>(*this); // 위험
}
};
// ✅ 깊은 복사: 안전
class Document {
std::vector<std::string> paragraphs_; // 값 타입 또는
// std::unique_ptr<std::vector<std::string>> paragraphs_;
public:
std::unique_ptr<Document> clone() const {
auto copy = std::make_unique<Document>();
copy->paragraphs_ = paragraphs_; // 값 복사
return copy;
}
};
Prototype Registry: 이름으로 복제
프로토타입을 등록해 두고, 이름(문자열)으로 복제할 수 있게 합니다. 게임에서 “몬스터 타입” → “프로토타입 등록” → “스폰 시 clone()” 흐름에 적합합니다.
#include <memory>
#include <unordered_map>
#include <string>
class PrototypeRegistry {
std::unordered_map<std::string, std::unique_ptr<Prototype>> prototypes_;
public:
void registerPrototype(const std::string& name, std::unique_ptr<Prototype> proto) {
prototypes_[name] = std::move(proto);
}
std::unique_ptr<Prototype> create(const std::string& name) const {
auto it = prototypes_.find(name);
if (it == prototypes_.end()) return nullptr;
return it->second->clone();
}
};
// 사용
PrototypeRegistry registry;
registry.registerPrototype("goblin", std::make_unique<Monster>("Goblin", 50, 10));
registry.registerPrototype("dragon", std::make_unique<Monster>("Dragon", 500, 50));
auto m1 = registry.create("goblin");
auto m2 = registry.create("dragon");
Prototype vs Factory
| 구분 | Prototype | Factory |
|---|---|---|
| 생성 방식 | 기존 객체 복제 | 새 객체 생성 |
| 적합 상황 | ”거의 같은 객체” 여러 개 | 타입별 분기 |
| 확장 | 복제 가능한 클래스 추가 | Factory 케이스 추가 |
5. 자주 발생하는 에러
Singleton: Double-Checked Locking 실수
문제: “getInstance()에서 매번 mutex 잡으면 느리지 않을까?” 하고 if (!instance) 체크를 먼저 하는 패턴을 쓰다가, 멀티스레드에서 두 스레드가 동시에 인스턴스를 생성하는 버그가 발생한다.
// ❌ 잘못된 Double-Checked Locking (C++03 스타일)
static Database* getInstance() {
if (!instance) { // 스레드 A, B 동시 진입
std::lock_guard<std::mutex> lock(mutex);
if (!instance) {
instance = new Database(); // 여전히 재진입 가능
}
}
return instance;
}
원인: instance의 쓰기와 읽기가 동기화되지 않아, 한 스레드가 아직 생성 중인 객체를 다른 스레드가 읽을 수 있다(메모리 순서 문제).
해결: C++11에서는 static 지역 변수나 std::call_once를 사용한다. 수동으로 DCL을 구현하지 않는다.
// ✅ 올바른 방법 1: static 지역 변수 (Meyers Singleton)
static Database& getInstance() {
static Database instance;
return instance;
}
// ✅ 올바른 방법 2: std::call_once
static Database& getInstance() {
std::call_once(initFlag, {
instance.reset(new Database());
});
return *instance;
}
Factory: nullptr 반환 미처리
문제: createShape(ShapeType::TRIANGLE)을 호출했는데 Factory에 Triangle 케이스가 없어 nullptr를 반환한다. 호출 측에서 null 체크 없이 shape->draw()를 호출해 크래시가 난다.
// ❌ 위험한 사용
auto shape = ShapeFactory::createShape(ShapeType::TRIANGLE);
shape->draw(); // nullptr면 크래시!
해결: 반환값을 항상 검사하거나, 예외를 던지도록 설계한다.
// ✅ 방법 1: null 체크
auto shape = ShapeFactory::createShape(ShapeType::TRIANGLE);
if (shape) {
shape->draw();
}
// ✅ 방법 2: Factory에서 예외
static std::unique_ptr<Shape> createShape(ShapeType type) {
switch (type) {
case ShapeType::CIRCLE: return std::make_unique<Circle>();
case ShapeType::RECTANGLE: return std::make_unique<Rectangle>();
default:
throw std::invalid_argument("Unknown shape type");
}
}
Builder: build() 중복 호출
문제: Builder를 재사용하려고 build() 호출 후 다시 build()를 호출하면, 이미 이동된 객체나 빈 상태를 반환할 수 있다.
// ❌ Builder 재사용 시 주의
auto builder = PizzaBuilder().setDough("thin");
Pizza p1 = builder.build();
Pizza p2 = builder.build(); // p1과 같은 데이터? 빈 객체?
해결: Builder는 일회용으로 설계하거나, build() 후 내부 상태를 리셋한다. 문서화로 “한 Builder로 한 번만 build”를 명시한다.
// ✅ build() 후 재사용 불가 문서화
// 또는 build() 시 내부 pizza를 새로 초기화
Pizza build() {
Pizza result = std::move(pizza);
pizza = Pizza(); // 다음 build()를 위해 리셋
return result;
}
Singleton: 정적 초기화 순서 문제
문제: 다른 전역 객체의 생성자에서 Logger::getInstance()를 호출하는데, 그 시점에 Logger의 static이 아직 초기화되지 않았을 수 있다.
해결: 가능하면 전역 객체를 피하고, Singleton은 getInstance() 내부 static 지역 변수로 두어 “최초 호출 시” 초기화되게 한다. 이렇게 하면 호출 순서에 따라 안전하게 초기화된다.
Prototype: 얕은 복사로 인한 공유 참조
문제: clone()에서 복사 생성자를 그대로 쓰는데, 멤버에 포인터가 있으면 원본과 복제본이 같은 메모리를 가리킨다. 한쪽에서 수정하면 다른 쪽에도 영향을 준다.
// ❌ 위험: 포인터 멤버 얕은 복사
class GameEntity {
int* resourcePtr_; // 동적 할당된 리소스
public:
GameEntity(const GameEntity& other) : resourcePtr_(other.resourcePtr_) {}
// 원본과 복제본이 같은 resourcePtr_ 공유 → double-free 위험
};
해결: 포인터 멤버는 clone() 내부에서 새로 할당하고 복사하거나, std::shared_ptr로 공유 의도를 명시한다. 가능하면 값 타입(std::vector, std::string)을 사용해 복사 시 자동으로 깊은 복사되게 한다.
// ✅ 안전: 값 타입 또는 깊은 복사
class GameEntity {
std::vector<int> resources_; // 값 타입
public:
std::unique_ptr<GameEntity> clone() const {
auto copy = std::make_unique<GameEntity>();
copy->resources_ = resources_;
return copy;
}
};
Abstract Factory: 제품군 혼합
문제: WindowsFactory의 버튼과 MacFactory의 체크박스를 섞어 쓰면, 시각적으로 일관성이 깨진다.
// ❌ 위험: 서로 다른 Factory 제품 혼합
WindowsFactory winFactory;
MacFactory macFactory;
auto button = winFactory.createButton(); // Windows 스타일
auto checkbox = macFactory.createCheckbox(); // Mac 스타일
// 버튼은 Windows, 체크박스는 Mac → UI 일관성 깨짐
해결: 한 Factory 인스턴스에서 생성한 제품만 조합해서 사용한다. renderUI(GUIFactory& factory)처럼 Factory를 하나만 받아 그 Factory로 모든 위젯을 만든다.
6. 모범 사례
패턴 선택 가이드
flowchart TD
A[객체 생성 필요] --> B{전역에서 하나만?}
B -->|Yes| C[Singleton 고려]
B -->|No| D{기존 객체 복제?}
D -->|Yes| E1[Prototype 고려]
D -->|No| E{타입에 따라 분기?}
E -->|Yes| F{제품군 일관?}
F -->|Yes| F1[Abstract Factory 고려]
F -->|No| F2[Factory 고려]
E -->|No| G{옵션이 5개 이상?}
G -->|Yes| H[Builder 고려]
G -->|No| I[일반 생성자]
| 상황 | 권장 패턴 | 이유 |
|---|---|---|
| 설정, 로거, 연결 풀 | Singleton | 앱 전체에서 하나만 필요 |
| 플러그인, 문서 타입 | Factory | 타입별 생성 로직 캡슐화 |
| HTTP 요청, 쿼리, 설정 | Builder | 가독성, 선택적 옵션 |
| 게임 몬스터, 문서 템플릿 | Prototype | 기존 객체 복제 후 변형 |
| 플랫폼별 UI 제품군 | Abstract Factory | Windows/Mac 등 일관된 스타일 |
| 단순 DTO | 생성자 | 과한 추상화 방지 |
테스트 가능성
Singleton은 테스트 시 mock으로 교체하기 어렵다. 인터페이스 + 의존성 주입을 함께 고려한다.
// Singleton 대신 인터페이스 주입
class ILogger {
public:
virtual void log(const std::string&) = 0;
virtual ~ILogger() = default;
};
class Service {
ILogger* logger_;
public:
Service(ILogger* logger) : logger_(logger) {}
void doWork() {
logger_->log("working");
}
};
// 테스트 시 MockLogger 주입 가능
생성자 vs Builder
- 4개 이하 파라미터: 생성자 또는 aggregate 초기화
- 5개 이상 또는 많은 선택적 옵션: Builder
- 불변 객체 필요: Builder에서
build()시점에만 완성
Prototype 사용 시점
- 객체 생성 비용이 클 때: DB에서 로드한 설정 객체를 복제해 변형
- 초기화가 복잡할 때: 여러 단계를 거쳐 만든 객체를 템플릿으로 복제
- 타입을 모를 때: Factory와 달리 구체 타입을 알 필요 없이
clone()만 호출
Singleton 대안
Singleton이 테스트·확장에 불리할 때 다음을 고려한다:
| 대안 | 용도 |
|---|---|
| 의존성 주입 | 테스트 시 mock 교체 가능 |
| 서비스 로케이터 | 전역 접근은 유지하되, 등록된 구현체 교체 가능 |
| 모듈 단위 Singleton | 앱 전체가 아닌 모듈 내에서만 단일 인스턴스 |
7. 프로덕션 패턴
패턴 조합: Factory + Singleton
DB 연결 풀은 앱 전체에서 하나만 있어야 하고, 연결 생성/반환은 풀이 담당하는 게 자연스럽습니다. Singleton으로 풀 인스턴스를 하나만 두고, getConnection()으로 연결을 빌려 주고 releaseConnection()으로 돌려받는 식으로 쓰면, 연결 수를 제한하고 재사용할 수 있습니다.
#include <memory>
#include <vector>
#include <mutex>
class Connection { /* ... */ };
class ConnectionPool {
std::vector<std::unique_ptr<Connection>> connections;
std::mutex mutex_;
static constexpr size_t MAX_POOL = 10;
ConnectionPool() {
for (size_t i = 0; i < MAX_POOL; ++i) {
connections.push_back(std::make_unique<Connection>());
}
}
public:
ConnectionPool(const ConnectionPool&) = delete;
ConnectionPool& operator=(const ConnectionPool&) = delete;
static ConnectionPool& getInstance() {
static ConnectionPool instance;
return instance;
}
Connection* getConnection() {
std::lock_guard<std::mutex> lock(mutex_);
if (connections.empty()) return nullptr;
auto conn = connections.back().release();
connections.pop_back();
return conn;
}
void releaseConnection(Connection* conn) {
std::lock_guard<std::mutex> lock(mutex_);
connections.push_back(std::unique_ptr<Connection>(conn));
}
};
패턴 조합: Builder + Factory
SQL처럼 테이블·컬럼·조건을 조합해 문자열을 만드는 경우에도 Builder가 잘 맞습니다. from(), select(), where()가 *this를 반환해 체인으로 호출하고, build()에서 지금까지 쌓인 정보를 합쳐 최종 쿼리 문자열을 만듭니다.
#include <string>
#include <vector>
class QueryBuilder {
std::string table;
std::vector<std::string> columns;
std::string whereClause;
public:
QueryBuilder& from(const std::string& t) {
table = t;
return *this;
}
QueryBuilder& select(const std::vector<std::string>& cols) {
columns = cols;
return *this;
}
QueryBuilder& where(const std::string& condition) {
whereClause = condition;
return *this;
}
std::string build() {
std::string query = "SELECT ";
for (size_t i = 0; i < columns.size(); ++i) {
query += columns[i];
if (i < columns.size() - 1) query += ", ";
}
query += " FROM " + table;
if (!whereClause.empty()) {
query += " WHERE " + whereClause;
}
return query;
}
};
// 사용
std::string query = QueryBuilder()
.from("users")
.select({"id", "name", "email"})
.where("age > 18")
.build();
패턴 조합: Prototype + Factory
게임 엔진에서 몬스터 스폰 시, 타입 이름으로 프로토타입을 찾아 복제하는 패턴입니다.
#include <memory>
#include <unordered_map>
#include <string>
class MonsterSpawner {
std::unordered_map<std::string, std::unique_ptr<Monster>> prototypes_;
public:
void registerMonster(const std::string& type, std::unique_ptr<Monster> proto) {
prototypes_[type] = std::move(proto);
}
std::unique_ptr<Monster> spawn(const std::string& type) const {
auto it = prototypes_.find(type);
if (it == prototypes_.end()) return nullptr;
return std::make_unique<Monster>(*it->second); // clone
}
};
패턴 조합: Abstract Factory + Singleton
플랫폼별 GUI Factory를 앱 전체에서 하나만 쓰고 싶을 때, Factory 자체를 Singleton으로 둡니다.
class GUIFactoryProvider {
public:
static GUIFactory& getInstance() {
#ifdef _WIN32
static WindowsFactory instance;
#elif __APPLE__
static MacFactory instance;
#else
static LinuxFactory instance;
#endif
return instance;
}
};
// 사용: 어디서든 동일한 플랫폼 Factory
auto& factory = GUIFactoryProvider::getInstance();
auto button = factory.createButton();
프로덕션 체크리스트
- Singleton:
static지역 변수 또는std::call_once사용 (스레드 안전) - Singleton: 복사/대입
= delete - Factory:
nullptr반환 시 호출 측 처리 또는 예외 - Builder: 필수 필드 검증을
build()에서 수행 - Prototype: 포인터 멤버는 깊은 복사 또는
shared_ptr명시 - Abstract Factory: 한 Factory에서 생성한 제품만 조합
- 테스트: Singleton 대신 인터페이스 주입 가능한지 검토
- 패턴 남용 금지: 단순 객체는 일반 생성자 사용
8. 실전 활용
패턴 조합 요약
| 조합 | 예시 | 용도 |
|---|---|---|
| Factory + Singleton | ConnectionPool | 풀 하나, 연결 생성 캡슐화 |
| Builder + Factory | QueryBuilder | 쿼리 조합 + 타입별 분기 |
| Builder만 | HttpRequest, Config | 복잡한 옵션 조합 |
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 디자인 패턴 | Adapter·Decorator
- C++ 디자인 패턴 | Observer·Strategy
- C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]
이 글에서 다루는 키워드 (관련 검색어)
C++ 디자인 패턴, 생성 패턴, Singleton Factory, 객체 생성 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 패턴 | 용도 | 예제 |
|---|---|---|
| Singleton | 단일 인스턴스 | Logger, Config, ConnectionPool |
| Factory | 객체 생성 캡슐화 | Shape, Document, GUIFactory |
| Builder | 복잡한 객체 생성 | HttpRequest, Query, Pizza |
| Prototype | 기존 객체 복제 | Monster, DocumentTemplate |
| Abstract Factory | 제품군 일관 생성 | WindowsFactory, MacFactory |
핵심 원칙:
- Singleton: 전역 상태 최소화, 스레드 안전 확보
- Factory: 생성 로직 분리, nullptr/예외 처리
- Builder: 가독성 높은 생성, build() 검증
- Prototype: 깊은 복사 확보, 포인터 멤버 주의
- Abstract Factory: 한 Factory 제품만 조합
- 패턴 남용 금지
- 상황에 맞는 패턴 선택
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 객체 생성을 효과적으로 관리하는 Singleton, Factory, Builder 패턴과 실전에서 활용하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: Singleton·Factory·Builder로 객체 생성 방식을 캡슐화할 수 있습니다. 다음으로 구조 패턴(#19-2)를 읽어보면 좋습니다.
다음 글: [C++ 실전 가이드 #19-2] 구조 패턴: Adapter, Decorator, Proxy
이전 글: [C++ 실전 가이드 #18-2] Google Mock으로 의존성 모킹하기
관련 글
- C++ 디자인 패턴 | Adapter·Decorator
- C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]
- C++ 디자인 패턴 종합 가이드 | Singleton·Factory
- C++ 이동 의미론 완벽 가이드 | rvalue 참조·std::move
- C++ Perfect Forwarding 완벽 가이드 | 유니버설 참조·std::forward