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()로 최종 객체를 만듭니다.


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()를 호출할 때마다 함수 호출 비용이 들고, 코드만 봐서는 “같은 인스턴스”인지 한눈에 들어오지 않습니다. 한 스코프 안에서 여러 번 쓸 때는 한 번만 참조를 받아 두고 그 참조로 로그를 남기면 가독성과 성능 모두 유리합니다.

// ❌ 나쁜 예: 전역 변수처럼 남용
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;
}

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 {
        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;
        }
    };
};

Prototype: clone()으로 객체 복제하기

기본 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

구분PrototypeFactory
생성 방식기존 객체 복제새 객체 생성
적합 상황”거의 같은 객체” 여러 개타입별 분기
확장복제 가능한 클래스 추가Factory 케이스 추가

Double-Checked Locking, build() 중복 호출, 얕은 복사 실수

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로 모든 위젯을 만듭니다.


패턴 선택 기준과 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{옵션이 5개 이상?}
  G -->|Yes| H[Builder 고려]
  G -->|No| I[일반 생성자]
상황권장 패턴이유
설정, 로거, 연결 풀Singleton앱 전체에서 하나만 필요
플러그인, 문서 타입Factory타입별 생성 로직 캡슐화
HTTP 요청, 쿼리, 설정Builder가독성, 선택적 옵션
게임 몬스터, 문서 템플릿Prototype기존 객체 복제 후 변형
플랫폼별 UI 제품군Abstract FactoryWindows/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앱 전체가 아닌 모듈 내에서만 단일 인스턴스

Factory, Builder, Prototype을 조합하는 방법

패턴 조합: 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 대신 인터페이스 주입 가능한지 검토
  • 패턴 남용 금지: 단순 객체는 일반 생성자 사용

패턴 조합 요약

조합예시용도
Factory + SingletonConnectionPool풀 하나, 연결 생성 캡슐화
Builder + FactoryQueryBuilder쿼리 조합 + 타입별 분기
Builder만HttpRequest, Config복잡한 옵션 조합

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Double-Checked Locking으로 Singleton을 직접 구현하면 왜 위험한가요?

A. 락 없이 포인터부터 확인하는 경로에서는 메모리 연산 순서가 재배치되어, 다른 스레드가 생성이 끝나지 않은 객체의 포인터를 먼저 볼 수 있습니다. 이를 막으려면 std::atomic과 올바른 메모리 순서를 직접 맞춰야 해서 실수하기 쉽습니다. C++11부터 함수 안의 static 지역 변수 초기화는 스레드 안전이 보장되므로, 대부분은 Meyers Singleton이나 std::call_once로 충분합니다.