C++ 디자인 패턴 실전 | Singleton·Factory·Observer·Strategy·PIMPL 예제와 자주 하는 실수

디자인 패턴은 반복되는 설계 문제에 대해 이름이 붙은 해법입니다. 패턴을 모르고 코딩해도 프로그램은 동작하지만, 생성 로직이 여기저기 흩어지거나 알림 대상이 늘 때마다 같은 클래스를 고쳐야 하는 식으로 확장과 테스트가 점점 어려워집니다. 이 글은 C++ 실무에서 자주 쓰는 다섯 가지 패턴을 각각 하나의 구체적인 문제에 대응시켜 설명합니다.

문제패턴
설정·로거처럼 앱 전체에서 하나만 있어야 하는 객체, 정적 초기화 순서 문제Singleton
if (type == CIRCLE) ... else if ... 생성 코드가 여러 곳에 흩어짐Factory
주문이 들어오면 이메일·SMS·푸시로 알려야 하고 채널이 계속 늘어남Observer
상황에 따라 알고리즘을 런타임에 교체Strategy
private 멤버 하나 바꿨는데 헤더를 include한 모든 파일이 재컴파일PIMPL

같은 패턴을 JavaScript·Python (데코레이터·싱글톤 예시)에서도 접할 수 있습니다. 시리즈로는 생성 패턴·구조 패턴·행동 패턴과 연결해 읽을 수 있습니다. ABI·레거시 관점의 실무 적용은 PIMPL·인터페이스·레거시 현대화 글을 참고하세요.


Singleton: 스레드 안전 Logger

목적

전역에서 단 하나의 인스턴스만 존재하도록 보장하며, 어디서든 그 인스턴스에 접근할 수 있게 합니다. 로거, 설정, DB 연결 풀 등에 사용됩니다.

예제: 스레드 안전 Logger

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o singleton singleton.cpp && ./singleton
#include <iostream>
#include <string>
#include <mutex>
class Logger {
    Logger() = default;  // private 생성자
public:
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;
    // C++11: static 지역 변수 초기화는 스레드 안전
    static Logger& getInstance() {
        static Logger instance;
        return instance;
    }
    void log(const std::string& message) {
        std::lock_guard<std::mutex> lock(mtx_);
        std::cout << "[LOG] " << message << "\n";
    }
private:
    std::mutex mtx_;  // 멀티스레드 환경에서 log() 보호
};
int main() {
    Logger::getInstance().log("Application started");
    Logger::getInstance().log("Config loaded");
    return 0;
}

실행 결과:

[LOG] Application started
[LOG] Config loaded

Lazy Initialization (std::call_once)

생성 비용이 큰 객체(DB 연결 등)는 최초 사용 시점에만 초기화하는 것이 좋습니다.

#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_;
    }
    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;
};
std::unique_ptr<Database> Database::instance_;
std::once_flag Database::initFlag_;

Singleton 다이어그램

sequenceDiagram
  participant Client1
  participant Client2
  participant Logger
  Client1->>Logger: getInstance()
  Logger->>Logger: 최초 호출 시 생성
  Logger-->>Client1: instance 참조
  Client2->>Logger: getInstance()
  Logger-->>Client2: 동일 instance 참조

Factory: 타입별 객체 생성을 한곳에 모으기

목적

객체 생성 로직을 한곳에 캡슐화하여, 클라이언트가 구체 타입을 알 필요 없이 객체를 얻을 수 있게 합니다. 새 타입 추가 시 Factory만 수정하면 됩니다.

예제: Shape Factory

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o factory factory.cpp && ./factory
#include <iostream>
#include <memory>
#include <string>
// 제품 인터페이스
class Shape {
public:
    virtual ~Shape() = default;
    virtual void draw() const = 0;
};
class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing Circle\n";
    }
};
class Rectangle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing Rectangle\n";
    }
};
class Triangle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing Triangle\n";
    }
};
// Factory
enum class ShapeType { Circle, Rectangle, Triangle };
class ShapeFactory {
public:
    static std::unique_ptr<Shape> create(ShapeType type) {
        switch (type) {
            case ShapeType::Circle:
                return std::make_unique<Circle>();
            case ShapeType::Rectangle:
                return std::make_unique<Rectangle>();
            case ShapeType::Triangle:
                return std::make_unique<Triangle>();
            default:
                return nullptr;
        }
    }
};
int main() {
    auto circle = ShapeFactory::create(ShapeType::Circle);
    auto rect = ShapeFactory::create(ShapeType::Rectangle);
    circle->draw();   // Drawing Circle
    rect->draw();     // Drawing Rectangle
    return 0;
}

실행 결과:

Drawing Circle
Drawing Rectangle

Factory Method (가상 생성)

파생 클래스가 자신이 생성할 구체 타입을 결정하는 패턴입니다.

#include <memory>
class Document {
public:
    virtual ~Document() = default;
    virtual void open() = 0;
};
class PdfDocument : public Document {
public:
    void open() override { /* PDF 열기 */ }
};
class Application {
public:
    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>();
    }
};

Factory 다이어그램

flowchart LR
  subgraph Client[클라이언트]
    C[create]
  end
  subgraph Factory[ShapeFactory]
    F[create]
  end
  subgraph Products[제품]
    P1[Circle]
    P2[Rectangle]
    P3[Triangle]
  end
  C --> F
  F --> P1
  F --> P2
  F --> P3

Observer: 주문 알림 시스템

목적

Subject(주제)가 상태 변경 시 등록된 Observer(관찰자)들에게 자동으로 알림을 보냅니다. Subject는 Observer 구체 타입을 알 필요 없이 인터페이스만 알면 되어 결합도가 낮습니다.

예제: 주문 알림 시스템

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o observer observer.cpp && ./observer
#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <algorithm>
class Observer {
public:
    virtual ~Observer() = default;
    virtual void update(const std::string& message) = 0;
};
class Subject {
    std::vector<std::weak_ptr<Observer>> observers_;
public:
    void attach(std::shared_ptr<Observer> observer) {
        observers_.push_back(observer);
    }
    void detach(std::shared_ptr<Observer> observer) {
        observers_.erase(
            std::remove_if(observers_.begin(), observers_.end(),
                [&observer](const std::weak_ptr<Observer>& wp) {
                    auto sp = wp.lock();
                    return !sp || sp == observer;
                }),
            observers_.end()
        );
    }
    void notify(const std::string& message) {
        // notify 중 detach 방지: 복사본으로 순회
        auto copy = observers_;
        for (auto& wp : copy) {
            if (auto sp = wp.lock()) {
                sp->update(message);
            }
        }
    }
};
// 구체 Observer
class EmailNotifier : public Observer {
public:
    void update(const std::string& message) override {
        std::cout << "[Email] " << message << "\n";
    }
};
class SMSNotifier : public Observer {
public:
    void update(const std::string& message) override {
        std::cout << "[SMS] " << message << "\n";
    }
};
// Subject 확장: OrderService
class OrderService : public Subject {
public:
    void placeOrder(const std::string& orderId) {
        notify("New order: " + orderId);
    }
};
int main() {
    OrderService orders;
    auto email = std::make_shared<EmailNotifier>();
    auto sms = std::make_shared<SMSNotifier>();
    orders.attach(email);
    orders.attach(sms);
    orders.placeOrder("ORD-001");
    // [Email] New order: ORD-001
    // [SMS] New order: ORD-001
    orders.detach(sms);
    orders.placeOrder("ORD-002");
    // [Email] New order: ORD-002
    return 0;
}

실행 결과:

[Email] New order: ORD-001
[SMS] New order: ORD-001
[Email] New order: ORD-002

Observer 다이어그램

sequenceDiagram
  participant Client
  participant Subject
  participant O1 as EmailNotifier
  participant O2 as SMSNotifier
  Client->>Subject: attach(O1), attach(O2)
  Client->>Subject: placeOrder("ORD-001")
  Subject->>Subject: notify()
  Subject->>O1: update("New order: ORD-001")
  Subject->>O2: update("New order: ORD-001")

Strategy: 정렬 전략과 std::function

목적

알고리즘을 런타임에 교체할 수 있게 합니다. if/else 분기 대신 Strategy 객체를 주입하면 확장이 쉽으며, 테스트 시 mock 전략을 주입하기 쉽습니다.

예제: 정렬 전략

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o strategy strategy.cpp && ./strategy
#include <iostream>
#include <vector>
#include <memory>
#include <algorithm>
class SortStrategy {
public:
    virtual ~SortStrategy() = default;
    virtual void sort(std::vector<int>& data) = 0;
};
class BubbleSort : public SortStrategy {
public:
    void sort(std::vector<int>& data) override {
        std::cout << "BubbleSort\n";
        for (size_t i = 0; i < data.size(); ++i) {
            for (size_t j = 0; j < data.size() - 1 - i; ++j) {
                if (data[j] > data[j + 1]) {
                    std::swap(data[j], data[j + 1]);
                }
            }
        }
    }
};
class QuickSort : public SortStrategy {
    void qs(std::vector<int>& data, int left, int right) {
        if (left >= right) return;
        int pivot = data[(left + right) / 2];
        int i = left, j = right;
        while (i <= j) {
            while (data[i] < pivot) ++i;
            while (data[j] > pivot) --j;
            if (i <= j) {
                std::swap(data[i], data[j]);
                ++i; --j;
            }
        }
        qs(data, left, j);
        qs(data, i, right);
    }
public:
    void sort(std::vector<int>& data) override {
        std::cout << "QuickSort\n";
        if (!data.empty()) {
            qs(data, 0, static_cast<int>(data.size()) - 1);
        }
    }
};
// Context
class Sorter {
    std::unique_ptr<SortStrategy> strategy_;
public:
    void setStrategy(std::unique_ptr<SortStrategy> s) {
        strategy_ = std::move(s);
    }
    void sort(std::vector<int>& data) {
        if (strategy_) {
            strategy_->sort(data);
        }
    }
};
int main() {
    Sorter sorter;
    std::vector<int> data = {5, 2, 8, 1, 9};
    sorter.setStrategy(std::make_unique<BubbleSort>());
    sorter.sort(data);
    data = {5, 2, 8, 1, 9};
    sorter.setStrategy(std::make_unique<QuickSort>());
    sorter.sort(data);
    for (int x : data) std::cout << x << " ";
    std::cout << "\n";
    return 0;
}

실행 결과:

BubbleSort
QuickSort
1 2 5 8 9

std::function으로 가벼운 Strategy

단순한 전략은 람다로 주입할 수 있습니다.

#include <functional>
#include <vector>
#include <algorithm>
class Sorter {
    std::function<void(std::vector<int>&)> strategy_;
public:
    void setStrategy(std::function<void(std::vector<int>&)> f) {
        strategy_ = std::move(f);
    }
    void sort(std::vector<int>& data) {
        if (strategy_) {
            strategy_(data);
        }
    }
};
// 사용
Sorter sorter;
sorter.setStrategy([](std::vector<int>& data) {
    std::sort(data.begin(), data.end());
});

Strategy 다이어그램

flowchart TB
  subgraph Context["Context (Sorter)"]
    C[sort]
  end
  subgraph Strategy[Strategy]
    S[SortStrategy]
  end
  S --> A[BubbleSort]
  S --> B[QuickSort]
  S --> C2[MergeSort]
  C --> S

PIMPL: 구현 숨기기와 빌드 시간 단축

목적

구현을 헤더에서 분리하여 컴파일 의존성을 줄이며, ABI 안정성을 확보합니다. “Pointer to Implementation”의 약자로, 컴파일 방화벽(compilation firewall)이라고도 합니다.

예제: Widget with PIMPL

// widget.h
#include <memory>
class Widget {
public:
    Widget();
    ~Widget();  // unique_ptr<불완전타입> 위해 반드시 선언
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
    void draw();
private:
    struct Impl;  // 전방 선언만
    std::unique_ptr<Impl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include <vector>
#include <string>
#include <iostream>
// 무거운 타입, 외부 라이브러리는 .cpp에만 포함
struct Widget::Impl {
    std::vector<int> data_;
    std::string name_;
    void doHeavyWork() {
        // Boost.Asio, OpenSSL 등 여기서만 사용
    }
};
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // cpp에 정의 필수
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::draw() {
    pImpl_->data_.push_back(1);
    std::cout << "Widget drawn, data size: " << pImpl_->data_.size() << "\n";
}

PIMPL 적용 전후 비교

flowchart TB
  subgraph before[❌ PIMPL 없음]
    B1[widget.h] --> B2[vector, string, SomeBigLib 전체]
    B2 --> B3[include하는 모든 .cpp 재컴파일]
  end
  subgraph after[✅ PIMPL]
    A1[widget.h] --> A2[pImpl 포인터만]
    A2 --> A3[클라이언트 재컴파일 불필요]
    A4[widget.cpp] --> A5[struct Impl: 실제 멤버]
  end

PIMPL 주의사항

unique_ptr<불완전타입>을 멤버로 쓰면 소멸자는 반드시 .cpp에서 정의해야 하고, 헤더에서 = default로 정의하면 컴파일 에러가 납니다(아래 에러 7). unique_ptr는 복사할 수 없으므로 복사가 필요하면 복사 생성자와 복사 대입에서 Impl을 깊은 복사하도록 직접 구현해야 하고, 이동 연산은 .cpp에서 = default로 정의하면 됩니다.


패턴별로 자주 만나는 에러

Singleton 관련

에러 1: 정적 초기화 순서 fiasco

증상: 다른 전역 객체의 생성자에서 Singleton을 사용할 때, Singleton이 아직 초기화되지 않아 크래시합니다.

원인: 서로 다른 번역 단위(.cpp)에 있는 전역 객체들의 동적 초기화 순서는 표준이 정하지 않습니다.

// ❌ 위험: 네임스페이스 범위의 전역 인스턴스 (config.cpp)
Config g_config;  // 생성자가 실행되는 시점이 다른 .cpp의 전역보다 늦을 수 있음
// other.cpp
Logger g_logger(g_config.logLevel());  // 아직 생성되지 않은 g_config를 읽을 수 있음

해결법: 함수 내부 static 지역 변수를 사용합니다. C++11부터 스레드 안전합니다.

// ✅ 안전
static Config& getInstance() {
    static Config instance;
    return instance;
}

에러 2: Singleton 복사/대입

증상: Config c = Config::getInstance();로 복사하면 인스턴스가 둘로 늘어날 수 있습니다.

해결법: 복사·이동 생성자와 대입 연산자를 = delete로 막습니다.

Config(const Config&) = delete;
Config& operator=(const Config&) = delete;

Factory 관련

에러 3: 새 enum 값을 Factory에 추가하지 않음

증상: 새 타입을 enum에 추가했는데 Factory의 switch에 case를 넣지 않아 nullptr가 반환되고, 나중에 다른 곳에서 크래시합니다.

해결법: switch에 default를 두지 말고 모든 enum 값을 case로 나열한 뒤, switch 뒤에서 예외를 던집니다. default가 없어야 GCC·Clang의 -Wswitch(-Wall에 포함)가 처리하지 않은 enum 값을 컴파일 시점에 경고해 줍니다. default가 있으면 이 경고가 꺼집니다.

static std::unique_ptr<Shape> create(ShapeType type) {
    switch (type) {
        case ShapeType::Circle:    return std::make_unique<Circle>();
        case ShapeType::Rectangle: return std::make_unique<Rectangle>();
        case ShapeType::Triangle:  return std::make_unique<Triangle>();
    }
    throw std::invalid_argument("Unknown shape type");  // 잘못된 정수를 캐스팅해 넘긴 경우
}

Observer 관련

에러 4: notify 중 Observer가 detach하면 크래시

증상: observer->update() 안에서 subject.detach(this)를 호출하면, 순회 중인 observers 벡터가 수정되어 iterator invalidation으로 크래시합니다.

해결법: notify 시 observers의 복사본을 만들어 순회합니다.

void notify(const std::string& msg) {
    auto copy = observers_;
    for (auto& wp : copy) {
        if (auto sp = wp.lock()) {
            sp->update(msg);
        }
    }
}

에러 5: Observer가 Subject보다 먼저 소멸 (dangling pointer)

증상: raw pointer로 Observer를 저장할 때, Observer가 먼저 파괴되면 Subject가 이미 삭제된 메모리를 참조합니다.

해결법: std::weak_ptr<Observer>를 저장하며, lock()으로 유효할 때만 사용합니다.

Strategy 관련

에러 6: 전략이 nullptr일 때 접근

증상: setStrategy를 호출하지 않고 sort()를 호출하면 strategy->sort(data)에서 크래시합니다.

해결법: nullptr 체크를 하거나, 기본 전략을 설정합니다.

void sort(std::vector<int>& data) {
    if (strategy_) {
        strategy_->sort(data);
    } else {
        std::sort(data.begin(), data.end());  // 기본 동작
    }
}

PIMPL 관련

에러 7: 소멸자를 헤더에서 정의하면 컴파일 에러

증상: 헤더에 ~Widget() = default;라고 쓰거나 소멸자 선언을 아예 빼면, Widget을 사용하는 .cpp를 컴파일할 때 “invalid application of ‘sizeof’ to an incomplete type” 같은 에러가 납니다.

원인: 소멸자가 그 자리에서 정의되면 std::unique_ptr<Impl>의 소멸자도 같이 인스턴스화되는데, std::default_delete는 Impl이 완전한 타입인지 static_assert로 검사합니다. 헤더 시점의 Impl은 전방 선언뿐이라 이 검사에 걸립니다. 날 포인터로 불완전 타입을 delete하면 정의되지 않은 동작이 되는 것을 unique_ptr가 컴파일 에러로 막아 주는 셈입니다.

해결법: 헤더에는 선언만 두고, Impl이 완전히 정의된 .cpp에서 소멸자를 정의합니다. 이동 생성자·이동 대입도 같은 이유로 .cpp에서 = default합니다.

// widget.h
~Widget();  // 선언만
// widget.cpp
Widget::~Widget() = default;  // 정의

에러 8: PIMPL 클래스의 복사

증상: Widget w2 = w1; 시 unique_ptr는 복사 불가이므로 컴파일 에러가 납니다.

해결법: 복사가 필요하면 복사 생성자에서 Impl을 deep copy합니다.

Widget::Widget(const Widget& other)
    : pImpl_(std::make_unique<Impl>(*other.pImpl_)) {}

패턴별 사용 원칙

Singleton은 꼭 필요할 때만 씁니다. 전역 상태는 테스트에서 가짜 객체로 바꾸기 어렵고 병렬 테스트도 막으므로, 생성자나 함수 인자로 의존성을 넘길 수 있다면 그쪽이 낫습니다. 쓴다면 함수 안 static 지역 변수(C++11부터 스레드 안전 초기화 보장)나 std::call_once로 만들고, 다른 전역 객체의 생성자·소멸자가 Singleton에 의존하지 않도록 설계합니다.

Factory는 생성 로직을 한곳에 모으고 std::unique_ptr로 반환해 소유권을 분명히 하는 것이 기본입니다. 타입이 계속 늘어난다면 문자열이나 ID를 생성 함수에 매핑하는 레지스트리를 두어 switch 없이 확장할 수 있습니다.

Observer는 weak_ptr로 관찰자를 저장해 수명 문제를 피하고, notify 시 목록의 복사본을 순회해 콜백 안에서 detach가 일어나도 안전하게 만듭니다. update()가 오래 걸리는 작업이라면 Subject 스레드를 붙잡지 않도록 메시지 큐 등으로 넘겨 비동기로 처리합니다.

Strategy는 내부 상태가 없으면 여러 Context가 같은 인스턴스를 공유할 수 있고, 단순한 전략은 클래스 대신 람다와 std::function으로 충분합니다. 어떤 전략을 쓸지 고르는 코드는 Context 밖(Factory, 설정)에 두어야 Context가 다시 if/else로 오염되지 않습니다.

PIMPL은 소멸자·복사·이동을 모두 명시적으로 처리해야 하고(Rule of Five), Impl은 헤더에서 전방 선언만 합니다. 라이브러리로 배포할 때는 Impl의 멤버를 바꿔도 공개 클래스의 크기와 레이아웃이 그대로라 바이너리 호환성을 유지하기 쉽습니다.


Factory + Strategy, 스레드 안전 Observer, 외부 라이브러리 격리

Factory + Strategy 조합

입력 크기에 따라 정렬 전략을 고르는 Factory입니다. InsertionSort와 MergeSort는 위 SortStrategy를 상속해 따로 구현했다고 가정합니다. 작은 입력에 삽입 정렬을 쓰는 것은 상수 비용이 작아서인데, 실제로 std::sort의 구현(introsort)도 작은 구간에서는 삽입 정렬로 전환하므로 직접 정렬을 짤 일은 드뭅니다. 여기서는 패턴 조합을 보여 주는 예로만 보면 됩니다.

std::unique_ptr<SortStrategy> createSortStrategy(size_t dataSize) {
    if (dataSize < 16) {
        return std::make_unique<InsertionSort>();
    } else if (dataSize < 10000) {
        return std::make_unique<QuickSort>();
    } else {
        return std::make_unique<MergeSort>();
    }
}
// 사용
Sorter sorter;
sorter.setStrategy(createSortStrategy(data.size()));
sorter.sort(data);

스레드 안전 Observer

목록을 잠근 상태에서 살아 있는 Observer만 shared_ptr로 복사해 두고, 실제 update() 호출은 잠금을 푼 뒤에 합니다. 잠근 채로 콜백을 부르면 콜백 안에서 attach를 호출할 때 같은 뮤텍스를 다시 잠가 교착 상태가 됩니다.

#include <mutex>
class ThreadSafeSubject {
    std::vector<std::weak_ptr<Observer>> observers_;
    mutable std::mutex mtx_;
public:
    void attach(std::shared_ptr<Observer> observer) {
        std::lock_guard<std::mutex> lock(mtx_);
        observers_.push_back(observer);
    }
    void notify(const std::string& message) {
        std::vector<std::shared_ptr<Observer>> copy;
        {
            std::lock_guard<std::mutex> lock(mtx_);
            for (auto& wp : observers_) {
                if (auto sp = wp.lock()) {
                    copy.push_back(sp);
                }
            }
        }
        for (auto& obs : copy) {
            obs->update(message);
        }
    }
};

PIMPL + 외부 라이브러리 격리

Boost.Asio, OpenSSL 등 무거운 헤더를 .cpp에만 포함시킵니다.

// network_manager.h
#include <memory>
#include <string>
class NetworkManager {
public:
    NetworkManager();
    ~NetworkManager();
    void connect(const std::string& host, int port);
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl_;
};
// network_manager.cpp
#include "network_manager.h"
#include <boost/asio.hpp>  // 여기서만 include
struct NetworkManager::Impl {
    boost::asio::io_context io;
    boost::asio::ip::tcp::socket socket{io};
    // ...
};

패턴의 비용과 대안: 가상 호출, CRTP, 타입 소거, 정책 기반 설계

위 패턴들은 대부분 virtual 함수로 “런타임에 구현을 고른다”는 공통점이 있습니다. 편리하지만 비용이 있어서, C++에서는 같은 목적을 컴파일 타임에 푸는 대안이 함께 쓰입니다.

가상 호출의 비용은 호출 한 번이 느리다기보다 인라인이 막히는 것이 큽니다. Base*를 통한 호출은 vtable에서 함수 주소를 읽어 간접 호출하므로, 컴파일러가 본문을 호출 지점에 펼칠 수 없고 루프 벡터화도 어려워집니다. 핫 루프에서 수백만 번 호출되는 작은 가상 함수가 병목이 되는 게 이 때문이고, 반대로 모듈 경계나 요청당 몇 번 부르는 인터페이스라면 신경 쓸 필요가 거의 없습니다. 계층이 확정됐다면 final을 붙여 두면 컴파일러가 가상 호출을 직접 호출로 바꿀(devirtualize) 여지가 생깁니다.

구분virtual (런타임 다형성)템플릿/CRTP (컴파일 타임 다형성)
구현 선택 시점실행 중빌드 시
한 컨테이너에 여러 타입가능 (vector<unique_ptr<Base>>)불가 (타입마다 다른 인스턴스)
인라인대부분 불가가능
플러그인·동적 로딩적합부적합

타입 소거는 둘의 절충입니다. std::function처럼 서로 다른 구체 타입을 하나의 핸들에 담되, 사용하는 쪽에는 공통 연산만 보이게 합니다. 내부적으로는 가상 호출이나 함수 포인터 테이블을 쓰므로 비용은 virtual과 비슷하고, 대신 사용자가 상속 계층을 만들 필요가 없습니다. 위 Strategy의 std::function 버전이 이 방식입니다.

정책 기반 설계는 Strategy를 템플릿 매개변수로 옮긴 형태입니다. 동작이 빌드 시점에 정해져 있다면 가상 호출 없이 조합할 수 있습니다.

struct VerboseLog { static void on_event(const char* m) { std::cerr << m << '\n'; } };
struct SilentLog  { static void on_event(const char*) {} };

template <typename LogPolicy>
class Service {
public:
    void do_work() { LogPolicy::on_event("do_work"); /* ... */ }
};
using MonitoredService = Service<VerboseLog>;  // 로깅 호출이 인라인되거나 통째로 사라짐
using QuietService     = Service<SilentLog>;

선택 기준은 단순합니다. 구현을 실행 중에 바꾸거나 목록으로 섞어 담아야 하면 virtual(또는 타입 소거), 빌드 시점에 하나로 정해지고 성능이 중요하면 템플릿·CRTP·정책 기반 설계입니다. 기능을 겹겹이 덧씌우는 구조가 필요하다면 Decorator 패턴도 같은 인터페이스 위에서 동작합니다.


패턴 선택 가이드

문제추천 패턴대안
전역에서 단일 인스턴스 접근Singleton의존성 주입
타입에 따른 객체 생성FactoryBuilder
상태 변경 시 알림ObserverPub/Sub, 시그널
알고리즘 런타임 교체Strategystd::function
빌드 시간·ABI 안정성PIMPL브릿지

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Singleton과 전역 변수의 차이는?

Singleton은 lazy initialization이 가능하며, 생성 시점을 제어할 수 있습니다. 전역 변수는 정적 초기화 순서가 불명확하며, 테스트 시 교체가 어렵습니다. 하지만 Singleton도 전역 상태이므로, 꼭 필요할 때만 사용하는 것이 좋습니다.

Factory와 Builder의 차이는?

Factory는 “어떤 타입의 객체를 만들지”에 초점을 맞춥니다. Builder는 “복잡한 객체를 단계적으로 어떻게 만들지”에 초점을 맞춥니다. 생성자 파라미터가 많고 선택 옵션이 여럿이면 Builder가 낫습니다.

Observer와 Pub/Sub의 차이는?

Observer는 Subject가 구독자 목록을 직접 관리합니다. Pub/Sub는 메시지 브로커가 있어 발행자와 구독자가 서로를 모릅니다. 작은 규모에서는 Observer, 대규모에서는 Pub/Sub를 고려합니다.

가상 함수가 느리다고 하는데 항상 피해야 하나요?

아닙니다. 모듈 경계·플러그인·공개 API처럼 런타임 다형성이 필요한 곳에서는 가상 호출이 맞는 도구입니다. 문제가 되는 건 프로파일링 없이 핫 루프에 작은 가상 함수를 남발하는 경우로, 측정해서 병목이 확인되면 그 구간만 템플릿이나 CRTP로 바꾸면 됩니다.

std::function과 직접 만든 타입 소거는 언제 쓰나요?

대부분은 std::function으로 충분합니다. 주요 구현은 작은 호출 객체를 힙 할당 없이 내부 버퍼에 저장하지만(크기 기준은 구현마다 다름) 표준이 보장하지는 않습니다. 할당을 절대 하면 안 되는 경로, 이동 전용 호출 객체(C++23 std::move_only_function), 특정 연산만 노출해야 하는 경우에 직접 구현을 검토합니다. 호출할 함수를 인자로 받기만 하고 저장하지 않는다면 템플릿 매개변수(std::invocable 제약)가 가장 빠릅니다.

PIMPL을 쓸 때 성능 오버헤드는?

객체 생성마다 힙 할당이 한 번 늘고, 멤버 접근마다 포인터 간접 참조가 한 번 들어갑니다. 멤버 함수 본문이 .cpp에 있으므로 링크 타임 최적화(LTO) 없이는 호출 지점에서 인라인되지 않는다는 점도 비용입니다. 요청당 몇 번 호출되는 서비스 객체라면 무시할 만하지만, 작은 값 타입을 대량으로 만들거나 핫 루프에서 접근자를 부르는 클래스에는 맞지 않습니다.