C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드

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 패턴
  2. Factory 패턴
  3. Builder 패턴
  4. Prototype 패턴
  5. 자주 발생하는 에러
  6. 모범 사례
  7. 프로덕션 패턴
  8. 실전 활용

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_oncestd::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가 적절한 객체를 만들어 반환합니다. 새 도형을 추가할 때는 createShapeswitch만 수정하면 되므로, 생성 로직이 한곳에 모입니다. 반환 타입은 공통 인터페이스(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

구분PrototypeFactory
생성 방식기존 객체 복제새 객체 생성
적합 상황”거의 같은 객체” 여러 개타입별 분기
확장복제 가능한 클래스 추가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 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앱 전체가 아닌 모듈 내에서만 단일 인스턴스

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 + SingletonConnectionPool풀 하나, 연결 생성 캡슐화
Builder + FactoryQueryBuilder쿼리 조합 + 타입별 분기
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

핵심 원칙:

  1. Singleton: 전역 상태 최소화, 스레드 안전 확보
  2. Factory: 생성 로직 분리, nullptr/예외 처리
  3. Builder: 가독성 높은 생성, build() 검증
  4. Prototype: 깊은 복사 확보, 포인터 멤버 주의
  5. Abstract Factory: 한 Factory 제품만 조합
  6. 패턴 남용 금지
  7. 상황에 맞는 패턴 선택

자주 묻는 질문 (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