C++ 생성 패턴 실무 활용: Singleton·Factory·Builder·Prototype
들어가며: 객체 생성의 복잡성
여러 모듈이 같은 설정 객체를 써야 하는데 인스턴스는 하나여야 하고, 전역 변수는 피하고 싶은 상황이 있습니다. 도형 타입에 따라 다른 클래스를 만들어야 하는 코드가 여러 파일에 흩어져 있거나, 생성자 인자가 너무 많아 호출부를 읽기 어려운 경우도 흔합니다. 생성 패턴은 이런 문제를 “객체를 누가, 언제, 어떤 조건으로 만들지”를 한곳에서 관리하는 방식으로 풉니다. Singleton(싱글톤)은 인스턴스가 하나뿐임을 보장하고, Factory(팩토리)는 생성 로직을 한곳에 모으며, Builder(빌더)는 복잡한 객체를 단계적으로 조립하고, Prototype(프로토타입)은 기존 객체를 복제해 새 객체를 만듭니다. 이 글은 각 패턴의 C++ 구현과 함께, 멀티스레드 초기화와 얕은 복사처럼 실제로 문제가 되는 지점을 다룹니다.
같은 패턴은 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, 메서드, 여러 헤더, 바디, 타임아웃 등 선택 항목이 많음 | 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");
// (static 멤버 instance의 정의 Config* Config::instance = nullptr; 는 .cpp에 필요)
전역 설정, 다형적 생성, 긴 생성자: 생성 패턴이 필요한 이유
전역 변수의 문제는 접근이 쉽다는 것보다 초기화 시점이 불분명하다는 데 있습니다. 서로 다른 번역 단위에 있는 전역 객체의 동적 초기화 순서는 표준이 정하지 않으므로(static initialization order fiasco), 한 전역 객체의 생성자가 아직 초기화되지 않은 다른 전역 객체를 쓸 수 있습니다. Singleton을 함수 안 static으로 만들면 최초 사용 시점에 초기화되어 이 문제를 피할 수 있습니다. 다만 테스트에서 다른 구현으로 바꾸기 어렵다는 단점은 그대로이므로, 테스트가 중요하다면 인터페이스와 의존성 주입을 함께 고려합니다.
다형적 객체 생성에서는 if (type == CIRCLE) new Circle(); else if (type == RECTANGLE) ... 같은 분기가 클라이언트 코드 곳곳에 흩어지는 것이 문제입니다. 새 도형 타입을 추가할 때마다 여러 파일을 찾아 고쳐야 하고, 한 곳을 빠뜨리기 쉽습니다. Factory는 이 분기를 한 함수나 등록 테이블로 모읍니다.
생성자 인자가 많을 때는 HttpRequest(url), HttpRequest(url, method), HttpRequest(url, method, headers)처럼 오버로드를 계속 늘리는 점층적 생성자(telescoping constructor)가 되기 쉽고, 호출부에서 HttpRequest(u, "POST", h, b, 30, 3, true)의 각 값이 무엇인지 읽을 수 없게 됩니다. Builder는 이름 있는 메서드로 필요한 옵션만 설정하고 build()에서 최종 객체를 만듭니다.
Singleton: static 지역 변수로 만드는 스레드 안전 인스턴스
기본 구현 (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() 호출은 초기화 여부를 확인하는 가벼운 검사만 하므로 성능 부담은 작습니다. 다만 한 스코프 안에서 여러 번 쓸 때는 참조를 한 번 받아 두는 편이 읽기 쉽습니다. 더 중요한 것은 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 <chrono>
#include <ctime>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <string>
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::ctime은 끝에 줄바꿈을 붙이므로 put_time으로 형식을 지정
// (localtime은 스레드 안전하지 않지만 여기서는 mutex 안에서만 호출)
std::cout << prefix_ << " " << std::put_time(std::localtime(&time), "%F %T")
<< " " << message << "\n";
}
};
int main() {
Logger::getInstance().log("Application started");
Logger::getInstance().setPrefix("[INFO]");
Logger::getInstance().log("Configuration loaded");
return 0;
}
Factory: Simple Factory, Factory Method, Abstract 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 구현체 추가 |
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; // 아래에서 정의: Builder가 HttpRequest 값을 멤버로 가지려면 HttpRequest가 먼저 완성되어야 함
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";
}
}
};
class HttpRequest::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;
}
};
// 사용
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;
}
};
};
Prototype: clone()으로 객체 복제하기
기본 Prototype: clone()
Prototype은 기존 객체를 복사해 새 객체를 만드는 패턴입니다. 초기화 과정이 복잡하거나 비싼 객체를 매번 처음부터 만드는 대신, 미리 만들어 둔 객체를 복제한 뒤 일부만 바꿉니다. 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()하는 흐름에 맞습니다. clone()이 가상 함수이므로 등록된 객체가 Monster의 파생 클래스여도 잘리지 않고 그 타입 그대로 복제됩니다.
#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 케이스 추가 |
Double-Checked Locking, build() 중복 호출, 얕은 복사 실수
Singleton: Double-Checked Locking 실수
“getInstance()마다 mutex를 잡으면 느리지 않을까?” 하는 생각에서 락 없이 if (!instance)를 먼저 확인하는 Double-Checked Locking(DCL)을 일반 포인터로 구현하는 경우가 있습니다.
// ❌ 잘못된 Double-Checked Locking (일반 포인터)
static Database* getInstance() {
if (!instance) { // 락 없이 읽기: 다른 스레드의 쓰기와 data race
std::lock_guard<std::mutex> lock(mutex);
if (!instance) {
instance = new Database(); // 포인터 쓰기가 생성 완료보다 먼저 보일 수 있음
}
}
return instance;
}
안쪽 검사가 락 안에 있으므로 객체가 두 번 만들어지지는 않습니다. 문제는 바깥 검사입니다. 락 없이 읽는 instance는 다른 스레드의 쓰기와 동기화되지 않아 data race(미정의 동작)이고, 실제로는 컴파일러나 CPU가 순서를 바꿔 포인터 값이 Database 생성자의 쓰기보다 먼저 보일 수 있습니다. 그러면 다른 스레드가 null이 아닌 포인터를 보고 초기화가 덜 끝난 객체를 사용하게 됩니다. 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 반환 미처리
앞의 ShapeFactory에는 TRIANGLE 분기가 없어 createShape(ShapeType::TRIANGLE)이 nullptr를 반환합니다. 호출 측에서 null 검사 없이 shape->draw()를 부르면 크래시가 납니다.
// ❌ 위험한 사용
auto shape = ShapeFactory::createShape(ShapeType::TRIANGLE);
shape->draw(); // nullptr면 크래시!
반환값을 항상 검사하거나, 알 수 없는 타입이면 예외를 던지도록 설계합니다. enum class에 값을 추가하고 switch에 default를 두지 않으면 GCC·Clang의 -Wswitch 경고로 빠진 분기를 컴파일 시점에 찾을 수도 있습니다.
// ✅ 방법 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()가 무엇을 하는지에 따라 결과가 달라집니다. 앞의 PizzaBuilder::build()처럼 내부 객체를 복사해 반환하면 두 번째 호출은 같은 피자를 하나 더 만들고, 그 사이에 addTopping을 부르면 앞에서 넣은 토핑이 그대로 남아 있습니다. 반대로 성능을 위해 내부 객체를 std::move로 꺼내 반환하면 두 번째 호출은 이동된 뒤의 빈 객체를 반환합니다.
// ❌ Builder 재사용 시 결과가 build() 구현에 의존
auto builder = PizzaBuilder().setDough("thin");
Pizza p1 = builder.build();
Pizza p2 = builder.build(); // 복사 반환이면 p1과 같은 피자, 이동 반환이면 빈 피자
Builder를 일회용으로 쓴다고 정해 문서에 남기거나, build()가 내부 상태를 명시적으로 초기화하게 만듭니다.
// ✅ build() 시 내부 pizza를 꺼내고 새로 초기화
Pizza build() {
Pizza result = std::move(pizza);
pizza = Pizza(); // 다음 build()를 위해 리셋
return result;
}
Singleton: 정적 초기화 순서 문제
Logger 인스턴스를 클래스의 static 멤버나 전역 변수로 두면, 다른 번역 단위의 전역 객체 생성자에서 Logger::getInstance()를 호출하는 시점에 아직 초기화되지 않았을 수 있습니다. 인스턴스를 getInstance() 안의 static 지역 변수로 두면 최초 호출 시 초기화되므로 이 순서 문제를 피할 수 있습니다. 반대 방향, 즉 프로그램 종료 시 소멸 순서는 여전히 문제가 될 수 있습니다. 다른 전역 객체의 소멸자가 이미 소멸된 Logger를 쓰지 않도록, 소멸자에서 다른 싱글톤을 호출하는 코드는 피하는 편이 안전합니다.
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의 체크박스를 섞어 쓰면 Abstract Factory를 쓰는 의미가 사라집니다.
// ❌ 위험: 서로 다른 Factory 제품 혼합
WindowsFactory winFactory;
MacFactory macFactory;
auto button = winFactory.createButton(); // Windows 스타일
auto checkbox = macFactory.createCheckbox(); // Mac 스타일
// 버튼은 Windows, 체크박스는 Mac → UI 일관성 깨짐
한 Factory 인스턴스에서 생성한 제품만 조합해서 사용합니다. renderUI(GUIFactory& factory)처럼 Factory를 하나만 받아 그 Factory로 모든 위젯을 만듭니다.
패턴 선택 기준과 Singleton 대안
패턴 선택 가이드
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{선택 옵션이 많음?}
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
인자가 몇 개 안 되고 모두 필수라면 생성자나 aggregate 초기화로 충분합니다. C++20의 지정 초기화(Config{.host = "db", .port = 5432})를 쓰면 인자가 조금 많아도 Builder 없이 이름을 붙여 쓸 수 있습니다. 선택 항목이 많거나, 여러 항목 사이의 관계를 검증해야 하거나, 완성된 뒤에는 바뀌지 않는 불변 객체를 만들어야 할 때 Builder가 의미가 있습니다.
Prototype 사용 시점
Prototype은 DB에서 읽어 온 설정처럼 처음 만드는 비용이 큰 객체, 여러 단계를 거쳐 초기화한 객체를 템플릿으로 복제할 때 쓸모가 있습니다. Factory와 달리 호출하는 쪽이 구체 타입을 알 필요 없이 clone()만 부르면 된다는 점도 차이입니다.
Singleton 대안
Singleton이 테스트·확장에 불리할 때는 다음을 고려합니다.
| 대안 | 용도 |
|---|---|
| 의존성 주입 | 테스트 시 mock 교체 가능 |
| 서비스 로케이터 | 전역 접근은 유지하되, 등록된 구현체 교체 가능 |
| 모듈 단위 Singleton | 앱 전체가 아닌 모듈 내에서만 단일 인스턴스 |
생성 패턴을 다른 패턴과 조합하기
패턴 조합: 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));
}
};
getConnection()이 원시 포인터를 돌려주므로 호출자가 releaseConnection()을 빠뜨리면 연결이 새어 나갑니다. 실제 코드에서는 소멸자에서 풀에 반환하는 핸들 객체나 커스텀 deleter를 단 unique_ptr를 돌려주는 편이 안전합니다.
Builder로 SQL 쿼리 조립하기
SQL처럼 테이블·컬럼·조건을 조합해 문자열을 만드는 경우에도 Builder가 잘 맞습니다. from(), select(), where()가 *this를 반환해 체인으로 호출하고, build()에서 지금까지 쌓인 정보를 합쳐 최종 쿼리 문자열을 만듭니다. 다만 where()에 사용자 입력을 이어 붙이면 SQL 인젝션이 되므로, 실제로는 값 대신 ? 자리표시자를 넣고 DB 드라이버의 바인딩으로 값을 전달해야 합니다.
#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();
패턴 조합: 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; // Linux용 구현은 생략
#endif
return instance;
}
};
// 사용: 어디서든 동일한 플랫폼 Factory
auto& factory = GUIFactoryProvider::getInstance();
auto button = factory.createButton();
같이 보면 좋은 글
- C++ 디자인 패턴 | Adapter·Decorator
- C++ 디자인 패턴 | Observer·Strategy
- C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]
- C++ 디자인 패턴 종합 가이드 | Singleton·Factory
- C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화
- 래퍼 함수에서 인자가 복사될 때: 유니버설 참조와 std::forward로 완벽한 전달 구현하기
자주 묻는 질문 (FAQ)
Double-Checked Locking으로 Singleton을 직접 구현하면 왜 위험한가요?
락 없이 포인터부터 확인하는 경로에서는 메모리 연산 순서가 재배치되어, 다른 스레드가 생성이 끝나지 않은 객체의 포인터를 먼저 볼 수 있습니다. 이를 막으려면 std::atomic과 올바른 메모리 순서를 직접 맞춰야 해서 실수하기 쉽습니다. C++11부터 함수 안의 static 지역 변수 초기화는 스레드 안전이 보장되므로, 대부분은 Meyers Singleton이나 std::call_once로 충분합니다.