C++ 디자인 패턴 | Observer·Strategy

들어가며: 객체 간 결합도가 너무 높다

데이터가 변경될 때 여러 UI 컴포넌트를 업데이트해야 했습니다. 하지만 강한 결합이 문제였습니다.
행동 패턴은 “누가 누구를 알고, 누가 누구에게 알리느냐”를 유연하게 만듭니다. Observer(옵저버—변경 시 구독자들에게 알리는 패턴)는 주제가 구독자 목록만 알면 되어 결합도(클래스 간 의존 정도. 낮을수록 한쪽 수정이 다른 쪽에 덜 영향)가 낮으며, Strategy(전략—알고리즘을 바꿔 끼울 수 있게 하는 패턴)는 알고리즘을 런타임에 바꿀 수 있으며, Command(커맨드—작업을 객체로 감싸 실행·취소·큐를 통일하는 패턴)는 작업을 객체로 감싸서 실행·취소·큐에 넣기를 통일할 때 실무에서 유용합니다. 옵저버·이벤트는 JavaScript 디자인 패턴에서도 같은 결합도 문제를 다룹니다. 생성·구조 맥락은 생성 패턴, 구조 패턴 글과 맞춰 읽으면 좋습니다. 행동 패턴 역할을 한눈에 보면 아래와 같습니다.

flowchart TB
  subgraph Observer[Observer 패턴]
    O1[Subject] --> O2[notify]
    O2 --> O3[Observer 1]
    O2 --> O4[Observer 2]
    O2 --> O5[Observer N]
  end
  subgraph Strategy[Strategy 패턴]
    S1[Context] --> S2[Strategy 인터페이스]
    S2 --> S3[ConcreteStrategy A]
    S2 --> S4[ConcreteStrategy B]
  end
  subgraph Command[Command 패턴]
    C1[Invoker] --> C2[Command]
    C2 --> C3[execute / undo]
    C3 --> C4[Receiver]
  end
  subgraph State[State 패턴]
    ST1[Context] --> ST2[State 인터페이스]
    ST2 --> ST3[ConcreteState A]
    ST2 --> ST4[ConcreteState B]
  end
  subgraph Template[Template Method 패턴]
    T1[AbstractClass] --> T2[templateMethod]
    T2 --> T3[hook1 / hook2]
  end

문제의 코드에서는 DataModel이 ChartView, TableView, StatusBar를 직접 알고 있어서, setValue에서 값이 바뀔 때마다 세 가지 뷰를 하나씩 호출합니다. 뷰를 하나 추가하거나 빼려면 DataModel을 수정해야 하며, 테스트할 때도 모든 뷰를 준비해야 합니다. 즉 “데이터가 변경되면 알려야 할 대상”이 DataModel 안에 하드코딩되어 있어 결합도가 높습니다. Observer 패턴은 “알릴 대상”을 Observer 인터페이스로 추상화하고 목록(observers)으로만 관리하게 해서, DataModel은 Observer만 알면 되고 구체적인 ChartView·TableView는 알 필요가 없어집니다.

// ❌ 강한 결합: DataModel이 모든 뷰를 직접 알고 있음
// 타입 정의
class DataModel {
    int value;
    ChartView* chart;      // ❌ 강한 결합
    TableView* table;      // ❌ 강한 결합
    StatusBar* statusBar;  // ❌ 강한 결합
public:
    void setValue(int v) {
        value = v;
        chart->update(value);      // 직접 호출
        table->update(value);      // 직접 호출
        statusBar->update(value);  // 직접 호출
    }
};

Observer 패턴으로 해결:

// 타입 정의
class Observer {
public:
    virtual ~Observer() = default;
    virtual void update(int value) = 0;
};
class DataModel {
    int value;
    std::vector<Observer*> observers;
public:
    void attach(Observer* obs) {
        observers.push_back(obs);
    }
    void setValue(int v) {
        value = v;
        notify();  // 모든 옵저버에게 알림
    }
private:
    void notify() {
        for (auto obs : observers) {
            obs->update(value);
        }
    }
};
// ✅ 느슨한 결합: DataModel은 Observer 인터페이스만 알면 됨

Observer: 상태 변화를 여러 구독자에게 알리기

주문 시스템에 알림 채널을 추가할 때

이커머스 주문 시스템에서 주문이 들어오면 이메일, SMS, 푸시 알림을 보내야 합니다. 처음에는 이메일만 있었는데, 나중에 SMS를 추가하려니 OrderService를 수정해야 했으며, 푸시 알림을 추가할 때마다 OrderService에 if 분기가 늘어났습니다. 알림 채널이 10개로 늘어나면 OrderService는 10개의 알림 객체를 직접 알고 있어야 하며, 테스트 시 모든 채널을 mock 해야 하는 문제가 발생했습니다. Observer 패턴으로 “알릴 대상”을 Observer 인터페이스로 추상화하며, OrderService(Subject)는 구독자 목록만 관리합니다. 새 알림 채널을 추가할 때 OrderService를 수정할 필요가 없습니다.

기본 구현

Subject는 알릴 대상 목록(observers)만 갖고, attach로 구독자를 추가하고 detach로 제거합니다. notify(message)가 호출되면 등록된 모든 Observer의 update(message)를 호출합니다. Observer는 인터페이스만 정의하며, EmailNotifier, SMSNotifier처럼 구체적인 알림 방식을 파생 클래스에서 구현합니다. 이렇게 하면 Subject는 “Observer 인터페이스”만 알면 되어, 새 알림 채널을 추가해도 Subject 코드를 수정할 필요가 없습니다.

sequenceDiagram
    participant Client
    participant Subject
    participant Observer1
    participant Observer2
    Client->>Subject: attach(Observer1)
    Client->>Subject: attach(Observer2)
    Client->>Subject: notify("New order")
    Subject->>Observer1: update("New order")
    Subject->>Observer2: update("New order")
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
class Observer {
public:
    virtual ~Observer() = default;
    virtual void update(const std::string& message) = 0;
};
class Subject {
    std::vector<Observer*> observers;
public:
    void attach(Observer* observer) {
        observers.push_back(observer);
    }
    void detach(Observer* observer) {
        observers.erase(
            std::remove(observers.begin(), observers.end(), observer),
            observers.end()
        );
    }
    void notify(const std::string& message) {
        for (auto observer : observers) {
            observer->update(message);
        }
    }
};
// Concrete Observers
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";
    }
};
// 사용 예
int main() {
    Subject subject;
    EmailNotifier email;
    SMSNotifier sms;
    subject.attach(&email);
    subject.attach(&sms);
    subject.notify("New order received");
    // Email: New order received
    // SMS: New order received
    subject.detach(&sms);
    subject.notify("Order shipped");
    // Email: Order shipped
    return 0;
}

스마트 포인터 사용

Observer를 raw pointer로 들고 있으면, Observer가 먼저 소멸될 때 dangling pointer가 될 수 있습니다. std::weak_ptr<Observer>를 저장하면 Observer의 수명에 영향을 주지 않으면서, lock()으로 유효할 때만 shared_ptr를 얻어 update를 호출할 수 있습니다. notify 시 expired()인 weak_ptr는 제거해 두면, 이미 파괴된 Observer를 참조하는 일을 막을 수 있습니다.

#include <memory>
#include <vector>
#include <algorithm>
class Observer {
public:
    virtual ~Observer() = default;
    virtual void update(int value) = 0;
};
class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
public:
    void attach(std::shared_ptr<Observer> observer) {
        observers.push_back(observer);
    }
    void notify(int value) {
        // 만료된 weak_ptr 제거
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                 [](const std::weak_ptr<Observer>& wp) { return wp.expired(); }),
            observers.end()
        );
        // 알림
        for (auto& wp : observers) {
            if (auto sp = wp.lock()) {
                sp->update(value);
            }
        }
    }
};

Strategy: 알고리즘을 런타임에 교체하기

데이터 크기에 따라 정렬 알고리즘을 고를 때

대시보드에서 테이블 데이터를 정렬하는데, 어떤 열은 같은 값끼리 기존 순서를 지켜야(안정 정렬) 하고 어떤 열은 순서 보존이 필요 없다고 해 봅시다. 정렬 함수 안에서 if (stable) mergeSort(); else quickSort();처럼 분기하면 새 정렬 방식을 추가할 때마다 조건문을 고쳐야 하고, 단위 테스트에서 특정 알고리즘만 바꿔 끼우기도 어렵습니다. 실제 코드라면 std::sort와 std::stable_sort를 쓰겠지만, 여기서는 패턴 구조를 보이기 위해 알고리즘을 직접 구현합니다.

Strategy 패턴으로 정렬 알고리즘을 SortStrategy 인터페이스로 추상화하며, Sorter(Context)는 전략 객체를 주입받아 사용합니다. 런타임에 전략을 바꿀 수 있으며, 테스트 시 mock 전략을 주입하기 쉽습니다.

기본 구현

SortStrategy는 “정렬 방법”을 나타내는 인터페이스이며, BubbleSort, QuickSort, MergeSort가 구체 전략입니다. Sorter(Context)는 setStrategy로 전략을 주입받으며, sort 호출 시 그 전략의 sort를 실행합니다. 이렇게 하면 정렬 알고리즘을 런타임에 바꿀 수 있으며, 새 정렬 방식을 추가할 때도 기존 코드를 건드리지 않고 새 클래스만 추가하면 됩니다.

flowchart LR
    subgraph Context["Context (Sorter)"]
        C1[sort]
    end
    subgraph Strategy[Strategy]
        S1[SortStrategy]
    end
    C1 --> S1
    S1 --> A[BubbleSort]
    S1 --> B[QuickSort]
    S1 --> C[MergeSort]
#include <iostream>
#include <vector>
#include <memory>
#include <algorithm>
// Strategy 인터페이스
class SortStrategy {
public:
    virtual ~SortStrategy() = default;
    virtual void sort(std::vector<int>& data) = 0;
};
// Concrete Strategies
class BubbleSort : public SortStrategy {
public:
    void sort(std::vector<int>& data) override {
        std::cout << "Bubble sort\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 quickSort(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;
            }
        }
        quickSort(data, left, j);
        quickSort(data, i, right);
    }
public:
    void sort(std::vector<int>& data) override {
        std::cout << "Quick sort\n";
        if (!data.empty()) {
            quickSort(data, 0, static_cast<int>(data.size()) - 1);
        }
    }
};
class MergeSort : public SortStrategy {
    void merge(std::vector<int>& data, int left, int mid, int right) {
        std::vector<int> temp(data.begin() + left, data.begin() + right + 1);
        int i = 0, j = mid - left + 1, k = left;
        while (i <= mid - left && j <= right - left) {
            data[k++] = temp[i] <= temp[j] ? temp[i++] : temp[j++];
        }
        while (i <= mid - left) data[k++] = temp[i++];
        while (j <= right - left) data[k++] = temp[j++];
    }
    void mergeSort(std::vector<int>& data, int left, int right) {
        if (left >= right) return;
        int mid = (left + right) / 2;
        mergeSort(data, left, mid);
        mergeSort(data, mid + 1, right);
        merge(data, left, mid, right);
    }
public:
    void sort(std::vector<int>& data) override {
        std::cout << "Merge sort\n";
        if (!data.empty()) {
            mergeSort(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);  // Bubble sort
    data = {5, 2, 8, 1, 9};
    sorter.setStrategy(std::make_unique<QuickSort>());
    sorter.sort(data);  // Quick sort
    return 0;
}

함수 포인터/람다 사용

전략이 한 가지 함수만 필요할 때는 std::function으로 받으면 클래스 계층 없이 람다나 일반 함수를 그대로 전략으로 쓸 수 있습니다. C++에서 Strategy 패턴을 가볍게 쓸 때 자주 쓰는 방식입니다.

#include <functional>
#include <vector>
#include <algorithm>
#include <iostream>
class Sorter {
    using SortFunc = std::function<void(std::vector<int>&)>;
    SortFunc sortFunc;
public:
    void setStrategy(SortFunc func) { sortFunc = std::move(func); }
    void sort(std::vector<int>& data) {
        if (sortFunc) sortFunc(data);
    }
};
int main() {
    Sorter sorter;
    sorter.setStrategy([](std::vector<int>& data) {
        std::sort(data.begin(), data.end());
    });
    std::vector<int> data = {3, 1, 4, 1, 5};
    sorter.sort(data);
    for (int x : data) std::cout << x << " ";
    std::cout << "\n";
    return 0;
}

실행 결과: 1 1 3 4 5 가 한 줄 출력됩니다.


Command: 요청을 객체로 만들어 Undo/Redo 지원

에디터의 Undo/Redo와 매크로

텍스트 에디터에서 Undo/Redo를 구현해야 했습니다. “마지막 상태를 스냅샷으로 저장”하는 방식은 메모리 사용량이 크고(전체 문서 복사), “각 동작을 역연산”하는 방식은 복잡한 편집(예: 찾아바꾸기)에서 undo 로직이 어렵습니다. 또한 “여러 명령을 묶어서 매크로로 실행”하는 기능도 필요했습니다. Command 패턴으로 각 동작(삽입, 삭제, 찾아바꾸기 등)을 Command 객체로 캡슐화합니다. execute()로 실행하고 undo()로 취소할 수 있어, Undo 스택에 쌓으며, 매크로는 Command 배열로 표현할 수 있습니다.

기본 구현

Command는 “실행할 작업”을 execute와 undo로 캡슐화한 인터페이스입니다. Light(Receiver)는 실제 동작(전등 on/off)을 수행하며, LightOnCommand·LightOffCommand는 그 동작을 감싸서 execute에서 호출하고 undo에서 반대 동작을 호출합니다. RemoteControl(Invoker)은 Command를 하나 들고 있으며, pressButton으로 실행, pressUndo로 취소합니다.

sequenceDiagram
    participant Client
    participant Invoker
    participant Command
    participant Receiver
    Client->>Invoker: setCommand(Command)
    Client->>Invoker: pressButton()
    Invoker->>Command: execute()
    Command->>Receiver: on() / off()
    Client->>Invoker: pressUndo()
    Invoker->>Command: undo()
    Command->>Receiver: off() / on()
#include <iostream>
#include <memory>
// Command 인터페이스
class Command {
public:
    virtual ~Command() = default;
    virtual void execute() = 0;
    virtual void undo() = 0;
};
// Receiver
class Light {
public:
    void on() {
        std::cout << "Light is ON\n";
    }
    void off() {
        std::cout << "Light is OFF\n";
    }
};
// Concrete Commands
class LightOnCommand : public Command {
    Light* light;
public:
    explicit LightOnCommand(Light* l) : light(l) {}
    void execute() override {
        light->on();
    }
    void undo() override {
        light->off();
    }
};
class LightOffCommand : public Command {
    Light* light;
public:
    explicit LightOffCommand(Light* l) : light(l) {}
    void execute() override {
        light->off();
    }
    void undo() override {
        light->on();
    }
};
// Invoker
class RemoteControl {
    std::unique_ptr<Command> command;
public:
    void setCommand(std::unique_ptr<Command> cmd) {
        command = std::move(cmd);
    }
    void pressButton() {
        if (command) {
            command->execute();
        }
    }
    void pressUndo() {
        if (command) {
            command->undo();
        }
    }
};
// 사용 예
int main() {
    Light light;
    RemoteControl remote;
    remote.setCommand(std::make_unique<LightOnCommand>(&light));
    remote.pressButton();  // Light is ON
    remote.pressUndo();    // Light is OFF
    return 0;
}

Undo/Redo 구현

CommandHistory는 실행한 Command들을 history 벡터에 쌓으며, current로 “지금 어디까지 실행했는지”를 가리킵니다. execute 시 현재 위치 이후 명령은 버리고(새 분기), 새 명령을 실행한 뒤 history에 넣고 current를 증가시킵니다. undo는 current 위치 명령의 undo()를 호출하고 current를 줄이며, redo는 current를 올린 뒤 해당 명령의 execute()를 다시 호출합니다.

#include <vector>
#include <memory>
class CommandHistory {
    std::vector<std::unique_ptr<Command>> history;
    int current = -1;
public:
    void execute(std::unique_ptr<Command> cmd) {
        // 현재 위치 이후 명령 삭제 (새 분기)
        history.erase(history.begin() + current + 1, history.end());
        cmd->execute();
        history.push_back(std::move(cmd));
        ++current;
    }
    void undo() {
        if (current >= 0) {
            history[current]->undo();
            --current;
        }
    }
    void redo() {
        if (current < static_cast<int>(history.size()) - 1) {
            ++current;
            history[current]->execute();
        }
    }
};
// 사용 예
int main() {
    CommandHistory history;
    Light light;
    history.execute(std::make_unique<LightOnCommand>(&light));
    history.execute(std::make_unique<LightOffCommand>(&light));
    history.undo();  // Light is ON
    history.undo();  // Light is OFF
    history.redo();  // Light is ON
    return 0;
}

State: 상태별 동작을 클래스로 분리하기

결제 시스템의 주문 상태 관리

이커머스 주문 시스템에서 주문은 “대기 → 결제중 → 완료” 또는 “대기 → 취소” 등 여러 상태를 거칩니다. 처음에는 Order 클래스에 if (state == PENDING) { ... } else if (state == PAYING) { ... }처럼 상태별 분기가 가득했습니다. 새 상태(예: “부분환불”)를 추가할 때마다 모든 메서드를 수정해야 했으며, 상태 전이 규칙이 흩어져 있어 버그가 자주 발생했습니다. State 패턴으로 각 상태를 OrderState 인터페이스를 구현한 클래스로 분리합니다. Order(Context)는 현재 상태 객체만 들고 있으며, 상태 전이는 각 State 클래스 내부에서 처리합니다. 새 상태 추가 시 새 클래스만 만들면 되고, 상태별 로직이 한 곳에 모여 유지보수가 쉬워집니다.

기본 구현

OrderState는 “상태별 동작”을 나타내는 인터페이스이며, PendingState, PayingState, CompletedState가 구체 상태입니다. Order(Context)는 setState로 상태를 바꾸고, processPayment·cancel 호출 시 현재 상태의 메서드를 위임합니다. 상태 전이는 State 내부에서 order->setState(…)로 수행합니다. 상태 클래스의 메서드가 Order의 멤버를 호출하므로, 메서드 본문은 Order가 완전히 정의된 뒤에 둬야 컴파일됩니다. 또 setState가 현재 상태 객체를 파괴하므로, 상태 메서드는 setState 호출 뒤에 자기 멤버에 접근하지 말고 바로 반환해야 합니다.

stateDiagram-v2
    [*] --> Pending
    Pending --> Paying: 결제시도
    Pending --> Cancelled: 취소
    Paying --> Completed: 결제완료
    Paying --> Pending: 결제실패
    Completed --> [*]
    Cancelled --> [*]
#include <iostream>
#include <memory>
#include <string>
// State 인터페이스
class Order;
class OrderState {
public:
    virtual ~OrderState() = default;
    virtual void processPayment(Order* order) = 0;
    virtual void cancel(Order* order) = 0;
    virtual std::string name() const = 0;
};
// Concrete States: 선언만 먼저 (본문은 Order 정의 뒤)
class PendingState : public OrderState {
public:
    std::string name() const override { return "Pending"; }
    void processPayment(Order* order) override;
    void cancel(Order* order) override;
};
class PayingState : public OrderState {
public:
    std::string name() const override { return "Paying"; }
    void processPayment(Order* order) override;
    void cancel(Order* order) override;
};
class CompletedState : public OrderState {
public:
    std::string name() const override { return "Completed"; }
    void processPayment(Order*) override { std::cout << "이미 결제 완료됨\n"; }
    void cancel(Order*) override { std::cout << "완료된 주문은 취소 불가\n"; }
};
class CancelledState : public OrderState {
public:
    std::string name() const override { return "Cancelled"; }
    void processPayment(Order*) override { std::cout << "취소된 주문은 결제 불가\n"; }
    void cancel(Order*) override { std::cout << "이미 취소됨\n"; }
};
// Context
class Order {
    std::unique_ptr<OrderState> state_;
    std::string orderId_;
public:
    explicit Order(const std::string& id)
        : state_(std::make_unique<PendingState>())
        , orderId_(id) {}
    void setState(std::unique_ptr<OrderState> state) {
        state_ = std::move(state);  // 기존 상태 객체가 여기서 파괴됨
    }
    void processPayment() {
        state_->processPayment(this);
    }
    void cancel() {
        state_->cancel(this);
    }
    const std::string& orderId() const { return orderId_; }
};
// 상태 전이 메서드 본문: Order가 완전한 타입이 된 뒤 정의
void PendingState::processPayment(Order* order) {
    std::cout << "결제 진행 중...\n";
    order->setState(std::make_unique<PayingState>());  // 이후 this 접근 금지
}
void PendingState::cancel(Order* order) {
    std::cout << "주문 취소됨\n";
    order->setState(std::make_unique<CancelledState>());
}
void PayingState::processPayment(Order* order) {
    std::cout << "결제 완료\n";
    order->setState(std::make_unique<CompletedState>());
}
void PayingState::cancel(Order* order) {
    std::cout << "결제 중 취소\n";
    order->setState(std::make_unique<CancelledState>());
}
// 사용 예
int main() {
    Order order("ORD-001");
    order.processPayment();  // 결제 진행 중... (Pending → Paying)
    order.processPayment();  // 결제 완료 (Paying → Completed)
    order.cancel();          // 완료된 주문은 취소 불가
    Order order2("ORD-002");
    order2.cancel();         // 주문 취소됨
    order2.processPayment(); // 취소된 주문은 결제 불가
    return 0;
}

Template Method: 공통 흐름은 고정하고 단계만 바꾸기

데이터 처리 파이프라인의 공통 흐름

여러 데이터 소스(파일, DB, API)에서 데이터를 읽어 전처리 후 저장하는 파이프라인이 필요했습니다. 각 소스마다 “열기 → 읽기 → 파싱 → 저장 → 닫기” 흐름은 같지만, “읽기”와 “파싱” 방식만 다릅니다. 처음에는 각 클래스에 비슷한 코드를 복붙했으며, “저장 전 검증” 단계를 추가하려면 모든 클래스를 수정해야 했습니다. Template Method 패턴으로 기본 알고리즘 골격을 DataPipeline::run()에 두며, open(), read(), parse() 등 변하는 부분만 가상 함수로 추출합니다. 파생 클래스는 이 훅(hook)들만 오버라이드하면 되고, 공통 로직 변경은 기본 클래스 한 곳에서만 하면 됩니다.

기본 구현

DataPipeline은 run() 템플릿 메서드에서 “열기 → 읽기 → 파싱 → 검증 → 저장 → 닫기” 순서를 정의합니다. open(), read(), parse()는 순수 가상 함수로 파생 클래스에서 구현하며, validate()는 기본 구현을 제공해 선택적으로 오버라이드할 수 있습니다. FilePipeline, DBPipeline은 각각 파일/DB에 맞게 훅을 구현합니다.

flowchart TD
    subgraph Template["Template Method (run)"]
        A[open] --> B[read]
        B --> C[parse]
        C --> D[validate]
        D --> E[save]
        E --> F[close]
    end
    subgraph Hooks["훅 (오버라이드)"]
        H1[FilePipeline: read, parse]
        H2[DBPipeline: read, parse]
    end
#include <iostream>
#include <string>
#include <vector>
#include <memory>
// Abstract Class - 템플릿 메서드 정의
class DataPipeline {
protected:
    std::string rawData_;
    std::vector<std::string> parsedData_;
    // 훅 메서드들 (파생 클래스에서 구현)
    virtual void open() = 0;
    virtual std::string read() = 0;
    virtual std::vector<std::string> parse(const std::string& raw) = 0;
    virtual void close() = 0;
    // 선택적 훅 (기본 구현 제공)
    virtual bool validate(const std::vector<std::string>& data) {
        return !data.empty();
    }
    void save(const std::vector<std::string>& data) {
        std::cout << "저장: " << data.size() << "개 레코드\n";
    }
public:
    // 템플릿 메서드 - 알고리즘 골격
    void run() {
        open();
        rawData_ = read();
        parsedData_ = parse(rawData_);
        if (!validate(parsedData_)) {
            std::cout << "검증 실패\n";
            close();
            return;
        }
        save(parsedData_);
        close();
    }
    virtual ~DataPipeline() = default;
};
// Concrete Class - 파일 파이프라인
class FilePipeline : public DataPipeline {
    std::string filePath_;
public:
    explicit FilePipeline(const std::string& path) : filePath_(path) {}
protected:
    void open() override {
        std::cout << "파일 열기: " << filePath_ << "\n";
    }
    std::string read() override {
        return "line1,line2,line3";  // 실제로는 파일 읽기
    }
    std::vector<std::string> parse(const std::string& raw) override {
        std::vector<std::string> result;
        size_t pos = 0;
        std::string s = raw;
        while ((pos = s.find(',')) != std::string::npos) {
            result.push_back(s.substr(0, pos));
            s.erase(0, pos + 1);
        }
        result.push_back(s);
        return result;
    }
    void close() override {
        std::cout << "파일 닫기\n";
    }
};
// Concrete Class - DB 파이프라인 (구분자 '|', open/close만 다름)
class DBPipeline : public DataPipeline {
    std::string query_;
public:
    explicit DBPipeline(const std::string& q) : query_(q) {}
protected:
    void open() override { std::cout << "DB 연결: " << query_ << "\n"; }
    std::string read() override { return "row1|row2|row3"; }
    std::vector<std::string> parse(const std::string& raw) override {
        std::vector<std::string> r; size_t p = 0; std::string s = raw;
        while ((p = s.find('|')) != std::string::npos) {
            r.push_back(s.substr(0, p)); s.erase(0, p + 1);
        }
        r.push_back(s); return r;
    }
    void close() override { std::cout << "DB 연결 해제\n"; }
};
// 사용 예
int main() {
    std::unique_ptr<DataPipeline> pipeline;
    pipeline = std::make_unique<FilePipeline>("/data/input.csv");
    pipeline->run();
    // 파일 열기 → 읽기 → 파싱 → 검증 → 저장 → 파일 닫기
    pipeline = std::make_unique<DBPipeline>("SELECT * FROM users");
    pipeline->run();
    // DB 연결 → 읽기 → 파싱 → 검증 → 저장 → 연결 해제
    return 0;
}

Observer와 Command를 조합한 이벤트 처리

패턴 조합: Observer + Command

Document는 Subject를 상속해 내용이 바뀔 때 notify(“Content changed”)로 구독자(UI 등)에 알립니다. EditCommand는 execute에서 이전 내용을 저장한 뒤 doc->setContent(newContent)로 바꾸고, undo에서 oldContent로 되돌립니다. 문서 편집기·드로잉 툴에서 자주 쓰는 조합입니다.

#include <string>
#include <vector>
class Document : public Subject {
    std::string content;
public:
    void setContent(const std::string& c) {
        content = c;
        notify("Content changed");
    }
    const std::string& getContent() const {
        return content;
    }
};
class EditCommand : public Command {
    Document* doc;
    std::string newContent;
    std::string oldContent;
public:
    EditCommand(Document* d, const std::string& content)
        : doc(d), newContent(content) {}
    void execute() override {
        oldContent = doc->getContent();
        doc->setContent(newContent);
    }
    void undo() override {
        doc->setContent(oldContent);
    }
};

notify 중 detach, 상태 전이 누락 같은 패턴별 문제

Observer 패턴

문제 1: notify 중 Observer가 자기 자신을 detach하면 크래시

notify() 루프에서 observer->update()가 호출될 때, 그 안에서 subject.detach(this)를 호출하면 반복 중인 observers 벡터가 수정되어 iterator invalidation이 발생합니다.

// ❌ 위험: update() 안에서 detach(this) 호출 시
void notify(const std::string& msg) {
    for (auto obs : observers) {
        obs->update(msg);  // 여기서 detach 호출 가능 → 크래시
    }
}

notify 시점에 observers 복사본을 만들어 순회합니다. 루프 중 벡터가 수정되어도 복사본을 순회하므로 iterator invalidation이 발생하지 않습니다.

// ✅ 안전: 복사본으로 순회 (raw pointer 사용 시)
void notify(const std::string& msg) {
    auto copy = observers;
    for (auto obs : copy) {
        obs->update(msg);
    }
}
// ✅ weak_ptr 사용 시: expired 체크 후 lock
void notify(const std::string& msg) {
    auto copy = observers;
    for (auto& wp : copy) {
        if (auto sp = wp.lock()) {
            sp->update(msg);
        }
    }
}

문제 2: Observer가 Subject보다 먼저 소멸 (dangling pointer)

raw pointer로 Observer를 저장할 때 Observer가 먼저 파괴되면 Subject가 이미 삭제된 메모리를 참조합니다. std::weak_ptr를 사용하거나, Observer 소멸 시 Subject::detach(this)를 호출하도록 합니다.

// ✅ weak_ptr 사용
class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
    // ...
};

Strategy 패턴

문제 3: 전략이 nullptr일 때 접근

setStrategy를 호출하지 않고 sort()를 호출하면 strategy가 nullptr일 수 있습니다.

// ❌ 위험
void sort(std::vector<int>& data) {
    strategy->sort(data);  // strategy가 nullptr면 크래시
}

nullptr 체크를 하거나, 생성자에서 기본 전략을 설정합니다.

// ✅ 안전
void sort(std::vector<int>& data) {
    if (strategy) {
        strategy->sort(data);
    }
    // 또는: strategy가 없으면 기본 정렬 사용
}

문제 4: 전략 변경 시 기존 상태와 불일치

Context가 내부 상태를 갖고 있을 때 전략을 바꾸면 이전 전략에 맞춰진 상태와 새 전략이 맞지 않을 수 있습니다. 전략 변경 시 Context 상태를 초기화하거나, 전략이 stateless이도록 설계합니다.

Command 패턴

문제 5: Receiver가 Command보다 먼저 소멸

LightOnCommand가 Light*를 들고 있을 때 Light가 먼저 파괴되면 execute()/undo()에서 dangling pointer 접근이 발생합니다. Undo 스택에 남은 Command는 오래 살아남기 쉬우므로, shared_ptr로 Receiver를 공유하거나, Command의 수명을 Receiver보다 짧게 관리합니다.

// ✅ shared_ptr 사용
class LightOnCommand : public Command {
    std::shared_ptr<Light> light;
public:
    explicit LightOnCommand(std::shared_ptr<Light> l) : light(std::move(l)) {}
    // ...
};

문제 6: undo 시 상태가 정확히 복원되지 않음

Undo는 실행의 역순으로만 정확합니다. 여러 Command가 같은 Receiver를 바꾸는데 history를 거치지 않고 일부만 취소하면 oldContent가 현재 상태와 맞지 않게 됩니다. 모든 변경을 history를 통해 실행하고, Command는 실행 시점의 상태만 저장하며, Composite Command로 여러 Command를 묶어서 원자적으로 undo/redo 합니다.

State 패턴

문제 7: 상태 전이 시 Context가 nullptr로 전달됨

State의 메서드에서 order->setState(...)를 호출할 때 order가 nullptr이거나 이미 소멸된 포인터일 수 있습니다.

// ❌ 위험: order가 유효한지 검증 없이 사용
void processPayment(Order* order) override {
    order->setState(std::make_unique<PayingState>());  // order가 nullptr면 크래시
}

nullptr 체크는 간단한 방어책이고, 근본적으로는 Context가 State를 소유하고 State는 Context를 호출 인자로만 받아 따로 저장하지 않게 설계합니다.

// ✅ 안전
void processPayment(Order* order) override {
    if (!order) return;
    order->setState(std::make_unique<PayingState>());
}

문제 8: 순환 참조로 인한 메모리 누수

Context가 State를 unique_ptr로 들고, State가 Context를 shared_ptr로 들면 순환 참조가 발생할 수 있습니다. State는 보통 Context*만 받으므로 해당되지 않지만, State가 Context를 캐시할 때 주의합니다. State는 Context를 raw pointer나 weak_ptr로만 참조하며, 소유하지 않습니다.

Template Method 패턴

문제 9: 훅 메서드에서 예외 발생 시 리소스 누수

run()에서 open() 후 read()에서 예외가 나면 close()가 호출되지 않아 리소스(파일 핸들, DB 연결)가 누수됩니다.

// ❌ 위험: read() 예외 시 close() 미호출
void run() {
    open();
    rawData_ = read();   // 예외 발생 가능
    parsedData_ = parse(rawData_);
    save(parsedData_);
    close();
}

C++에는 finally가 없으므로 RAII 가드로 close()를 보장합니다.

// ✅ 안전: RAII로 close 보장
void run() {
    open();  // open이 실패(예외)하면 close를 부르지 않도록 가드는 open 뒤에 생성
    struct Guard {
        DataPipeline* p;
        ~Guard() { p->close(); }
    } guard{this};
    rawData_ = read();
    parsedData_ = parse(rawData_);
    if (!validate(parsedData_)) return;
    save(parsedData_);
    // Guard 소멸 시 close() 자동 호출
}

문제 10: 파생 클래스에서 템플릿 메서드 오버라이드로 흐름 깨짐

run()을 virtual로 선언하면 파생 클래스가 오버라이드하면서 close()를 빼먹거나 순서를 바꿀 수 있습니다. run()은 위 예제처럼 non-virtual로 두는 것이 기본입니다. non-virtual 함수는 파생 클래스에서 같은 이름으로 가릴 수는 있어도 기본 클래스 포인터·참조를 통한 호출은 항상 기본 클래스의 run()을 부릅니다. 이미 virtual인 함수를 계층 중간에서 더 이상 바꾸지 못하게 하려면 그때 final을 붙입니다(final은 virtual 함수에만 쓸 수 있습니다).


패턴별 사용 원칙

Observer 패턴

  1. 인터페이스 최소화: update() 시그니처를 단순하게 유지하며, 이벤트 타입이 많으면 Event 구조체나 variant로 전달합니다.
  2. 비동기 알림 고려: update()가 오래 걸리면 Subject의 notify()가 블로킹됩니다. 필요하면 작업 큐나 스레드 풀로 넘깁니다. 반환된 future를 버리는 std::async는 그 future의 소멸자가 작업 완료를 기다리므로 비동기가 되지 않는다는 점에 주의합니다.
  3. 스레드 안전성: 멀티스레드에서 attach/detach/notify가 동시에 호출되면 락이 필요합니다. std::mutex로 보호하거나, 알림을 큐에 넣고 단일 스레드에서 처리하는 방식이 있습니다.

Strategy 패턴

  1. 전략 선택 로직 분리: “어떤 전략을 쓸지” 결정하는 코드는 Context 밖(Factory, 설정 등)에 두면 Context가 단순해집니다.
  2. 무상태(stateless) 전략 선호: 전략이 내부 상태를 갖지 않으면, 여러 Context가 같은 전략 인스턴스를 공유할 수 있어 메모리 효율이 좋습니다.
  3. std::function 활용: 단순한 전략은 람다로 주입하면 클래스 수가 줄어듭니다.

Command 패턴

  1. Command는 가볍게: Command 객체가 많아지므로(Undo 스택), 무거운 데이터는 shared_ptr로 공유합니다.
  2. 매크로/일괄 실행: CompositeCommand로 여러 Command를 묶어서 execute()/undo()를 한 번에 호출할 수 있습니다.
  3. 실행 전 검증: canExecute() 같은 메서드로 실행 가능 여부를 미리 확인하면, 비활성화된 버튼 처리 등에 유용합니다.

State 패턴

  1. 상태 전이 테이블 문서화: 어떤 상태에서 어떤 이벤트 시 다음 상태로 가는지 테이블로 정리하면, 구현과 리뷰가 쉬워집니다.
  2. 상태 객체 공유 가능 여부 검토: State가 무상태(stateless)이면 싱글톤으로 공유해 메모리를 절약할 수 있습니다. 상태별 데이터가 있으면 인스턴스별로 생성합니다.
  3. 입장/퇴장 훅: onEnter(), onExit() 훅을 두면 상태 전이 시 초기화/정리 로직을 한 곳에 모을 수 있습니다.

Template Method 패턴

  1. 훅은 최소한으로: 변하는 부분만 훅으로 추출하며, 나머지는 템플릿 메서드에 두면 파생 클래스 부담이 줄어듭니다.
  2. 훅에 기본 구현 제공: validate()처럼 선택적 오버라이드가 필요한 훅은 기본 구현을 두어, 파생 클래스가 필요한 것만 오버라이드하게 합니다.
  3. 템플릿 메서드는 non-virtual: run()을 virtual로 만들지 않으면 파생 클래스가 알고리즘 골격을 바꿀 수 없습니다.

스레드 안전 Observer, 전략 팩토리, Composite Command

스레드 안전 Observer

멀티스레드 환경에서 Subject가 여러 스레드에서 notify를 받으며, Observer도 여러 스레드에서 등록/해제될 때를 위한 패턴입니다.

#include <algorithm>
#include <mutex>
#include <string>
#include <vector>
#include <memory>
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 detach(std::shared_ptr<Observer> observer) {
        std::lock_guard<std::mutex> lock(mtx);
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                [&observer](const std::weak_ptr<Observer>& wp) {
                    return wp.expired() || wp.lock() == observer;
                }),
            observers.end()
        );
    }
    void notify(const std::string& message) {
        std::vector<std::shared_ptr<Observer>> copy;
        {
            std::lock_guard<std::mutex> lock(mtx);
            copy.reserve(observers.size());
            for (auto& wp : observers) {
                if (auto sp = wp.lock()) {
                    copy.push_back(sp);
                }
            }
        }
        for (auto& obs : copy) {
            obs->update(message);
        }
    }
};

전략 선택 팩토리

요구 조건이나 설정에 따라 전략을 고르는 코드를 Context 밖의 팩토리 함수로 모읍니다.

std::unique_ptr<SortStrategy> createSortStrategy(bool needStable) {
    if (needStable) {
        return std::make_unique<MergeSort>();  // 위 구현은 안정 정렬
    }
    return std::make_unique<QuickSort>();      // 순서 보존 불필요
}
// 사용
Sorter sorter;
sorter.setStrategy(createSortStrategy(column.requiresStableOrder()));
sorter.sort(data);

Composite Command (매크로)

여러 Command를 묶어서 한 번에 실행/취소하는 패턴입니다.

class CompositeCommand : public Command {
    std::vector<std::unique_ptr<Command>> commands;
public:
    void addCommand(std::unique_ptr<Command> cmd) {
        commands.push_back(std::move(cmd));
    }
    void execute() override {
        for (auto& cmd : commands) {
            cmd->execute();
        }
    }
    void undo() override {
        for (auto it = commands.rbegin(); it != commands.rend(); ++it) {
            (*it)->undo();
        }
    }
};

상태 전이 팩토리 (State Factory)

상태 객체를 팩토리로 생성해 Context와 분리합니다. OrderStateFactory::createPaying()처럼 정적 메서드로 상태를 생성하면, 상태 전이 로직을 한 곳에 모을 수 있습니다.

NVI (Non-Virtual Interface) 패턴

템플릿 메서드(run())를 public non-virtual로 두고, 가상 함수는 protected나 private으로 숨깁니다. 파생 클래스는 훅만 오버라이드하며, 알고리즘 골격을 변경할 수 없게 합니다.


같이 보면 좋은 글