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

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

이 글의 핵심

C++ 디자인 패턴에 대해 정리한 개발 블로그 글입니다. 데이터가 변경될 때 여러 UI 컴포넌트를 업데이트해야 했습니다. 하지만 강한 결합이 문제였습니다. 행동 패턴은 "누가 누구를 알고, 누가 누구에게 알리느냐"를 유연하게 만듭니다. Observer(옵저버—변경 시 구독자들에게… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: C++, 디…

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

“데이터가 변경되면 여러 곳에 알려야 해요”

데이터가 변경될 때 여러 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

문제의 코드에서는 DataModelChartView, TableView, StatusBar를 직접 알고 있어서, setValue에서 값이 바뀔 때마다 세 가지 뷰를 하나씩 호출합니다. 뷰를 하나 추가하거나 빼려면 DataModel을 수정해야 하고, 테스트할 때도 모든 뷰를 준비해야 합니다. 즉 “데이터가 변경되면 알려야 할 대상”이 DataModel 안에 하드코딩되어 있어 결합도가 높습니다. Observer 패턴은 “알릴 대상”을 Observer 인터페이스로 추상화하고 목록(observers)으로만 관리하게 해서, DataModelObserver만 알면 되고 구체적인 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 패턴으로 이벤트를 전파할 수 있습니다.
  • Strategy 패턴으로 알고리즘을 교체할 수 있습니다.
  • Command 패턴으로 작업을 캡슐화할 수 있습니다.
  • 실전에서 행동 패턴을 활용할 수 있습니다.

목차

  1. Observer 패턴
  2. Strategy 패턴
  3. Command 패턴
  4. State 패턴
  5. Template Method 패턴
  6. 실전 활용
  7. 자주 발생하는 문제와 해결법
  8. 모범 사례
  9. 프로덕션 패턴

1. Observer 패턴

문제 시나리오: 주문 시스템에서 알림 채널 추가

상황: 이커머스 주문 시스템에서 주문이 들어오면 이메일, SMS, 푸시 알림을 보내야 합니다. 처음에는 이메일만 있었는데, 나중에 SMS를 추가하려니 OrderService를 수정해야 했고, 푸시 알림을 추가할 때마다 OrderServiceif 분기가 늘어났습니다. 알림 채널이 10개로 늘어나면 OrderService는 10개의 알림 객체를 직접 알고 있어야 하고, 테스트 시 모든 채널을 mock 해야 하는 문제가 발생했습니다.

해결: Observer 패턴으로 “알릴 대상”을 Observer 인터페이스로 추상화하고, OrderService(Subject)는 구독자 목록만 관리합니다. 새 알림 채널을 추가할 때 OrderService를 수정할 필요가 없습니다.

기본 구현

Subject는 알릴 대상 목록(observers)만 갖고, attach로 구독자를 추가하고 detach로 제거합니다. notify(message)가 호출되면 등록된 모든 Observerupdate(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를 호출할 수 있습니다. notifyexpired()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(),
                 { return wp.expired(); }),
            observers.end()
        );

        // 알림
        for (auto& wp : observers) {
            if (auto sp = wp.lock()) {
                sp->update(value);
            }
        }
    }
};

2. Strategy 패턴

문제 시나리오: 데이터 크기에 따라 정렬 알고리즘 선택

상황: 대시보드에서 테이블 데이터를 정렬하는 기능이 필요했습니다. 데이터가 100개 미만일 때는 버블 정렬로 충분하고, 1000개 이상일 때는 퀵소트, 10000개 이상일 때는 머지소트를 쓰고 싶었습니다. if (size < 100) bubbleSort(); else if (size < 1000) quickSort(); else mergeSort();처럼 분기하면, 새 정렬 방식(예: 팀소트)을 추가할 때마다 조건문을 수정해야 하고, 단위 테스트에서 특정 알고리즘만 mock 하기 어렵습니다.

해결: 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::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 가 한 줄 출력됩니다.


3. Command 패턴

문제 시나리오: 에디터 Undo/Redo와 매크로 기능

상황: 텍스트 에디터에서 Undo/Redo를 구현해야 했습니다. “마지막 상태를 스냅샷으로 저장”하는 방식은 메모리 사용량이 크고(전체 문서 복사), “각 동작을 역연산”하는 방식은 복잡한 편집(예: 찾아바꾸기)에서 undo 로직이 어렵습니다. 또한 “여러 명령을 묶어서 매크로로 실행”하는 기능도 필요했습니다.

해결: Command 패턴으로 각 동작(삽입, 삭제, 찾아바꾸기 등)을 Command 객체로 캡슐화합니다. execute()로 실행하고 undo()로 취소할 수 있어, Undo 스택에 쌓고, 매크로는 Command 배열로 표현할 수 있습니다.

기본 구현

Command는 “실행할 작업”을 executeundo로 캡슐화한 인터페이스입니다. 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를 증가시킵니다. undocurrent 위치 명령의 undo()를 호출하고 current를 줄이고, redocurrent를 올린 뒤 해당 명령의 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;
}

4. 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(…)로 수행합니다.

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;
class PayingState;
class CompletedState;
class CancelledState;

class PendingState : public OrderState {
public:
    std::string name() const override { return "Pending"; }
    void processPayment(Order* order) override {
        std::cout << "결제 진행 중...\n";
        order->setState(std::make_unique<PayingState>());
    }
    void cancel(Order* order) override {
        std::cout << "주문 취소됨\n";
        order->setState(std::make_unique<CancelledState>());
    }
};

class PayingState : public OrderState {
public:
    std::string name() const override { return "Paying"; }
    void processPayment(Order* order) override {
        std::cout << "결제 완료\n";
        order->setState(std::make_unique<CompletedState>());
    }
    void cancel(Order* order) override {
        std::cout << "결제 중 취소\n";
        order->setState(std::make_unique<CancelledState>());
    }
};

class CompletedState : public OrderState {
public:
    std::string name() const override { return "Completed"; }
    void processPayment(Order* order) override {
        std::cout << "이미 결제 완료됨\n";
    }
    void cancel(Order* order) override {
        std::cout << "완료된 주문은 취소 불가\n";
    }
};

class CancelledState : public OrderState {
public:
    std::string name() const override { return "Cancelled"; }
    void processPayment(Order* order) override {
        std::cout << "취소된 주문은 결제 불가\n";
    }
    void cancel(Order* order) override {
        std::cout << "이미 취소됨\n";
    }
};

// Context (State 클래스들 정의 후)
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_; }
};

// 사용 예
int main() {
    Order order("ORD-001");
    order.processPayment();  // 결제 진행 중 → 결제 완료
    order.cancel();         // 완료된 주문은 취소 불가

    Order order2("ORD-002");
    order2.cancel();         // 주문 취소됨
    order2.processPayment(); // 취소된 주문은 결제 불가
    return 0;
}

5. Template Method 패턴

문제 시나리오: 데이터 처리 파이프라인의 공통 흐름

상황: 여러 데이터 소스(파일, DB, API)에서 데이터를 읽어 전처리 후 저장하는 파이프라인이 필요했습니다. 각 소스마다 “열기 → 읽기 → 파싱 → 저장 → 닫기” 흐름은 같지만, “읽기”와 “파싱” 방식만 다릅니다. 처음에는 각 클래스에 비슷한 코드를 복붙했고, “저장 전 검증” 단계를 추가하려면 모든 클래스를 수정해야 했습니다.

해결: Template Method 패턴으로 기본 알고리즘 골격을 DataPipeline::run()에 두고, open(), read(), parse() 등 변하는 부분만 가상 함수로 추출합니다. 파생 클래스는 이 훅(hook)들만 오버라이드하면 되고, 공통 로직 변경은 기본 클래스 한 곳에서만 하면 됩니다.

기본 구현

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

비유하면, 이런 가상 함수 훅들은 하나의 패널에 달린 다형성 스위치와 같습니다. run()이 “어떤 순서로 스위치를 누를지”를 고정해 두고, 파생 클래스는 각 스위치 뒤에 파일용·DB용처럼 실제 동작만 바꿉니다. 사용자 코드는 같은 run() 호출로 서로 다른 소스에 대응합니다.

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

6. 실전 활용

패턴 조합: Observer + Command

DocumentSubject를 상속해 내용이 바뀔 때 notify(“Content changed”)로 구독자(UI 등)에 알립니다. EditCommandexecute에서 이전 내용을 저장한 뒤 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);
    }
};

7. 자주 발생하는 문제와 해결법

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보다 먼저 소멸

원인: LightOnCommandLight*를 들고 있을 때, Light가 먼저 파괴되면 execute()/undo()에서 dangling pointer 접근이 발생합니다.

해결법: 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 시 상태가 정확히 복원되지 않음

원인: execute()에서 저장한 oldContent가 다른 Command에 의해 덮어쓰이거나, 여러 Command가 같은 Receiver를 공유할 때 순서가 꼬일 수 있습니다.

해결법: Command가 실행 시점의 상태만 저장하고, Composite Command로 여러 Command를 묶어서 원자적으로 undo/redo 합니다.

State 패턴

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

원인: State의 메서드에서 order->setState(...) 호출 시, order가 이미 소멸되었거나 잘못된 포인터일 수 있습니다.

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

해결법: nullptr 체크를 하거나, State가 Context의 수명을 shared_ptr로 공유해 관리합니다.

// ✅ 안전
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();
}

해결법: RAII나 try-finally 패턴으로 close()를 보장합니다.

// ✅ 안전: RAII로 close 보장
void run() {
    struct Guard {
        DataPipeline* p;
        ~Guard() { if (p) p->close(); }
    } guard{this};
    open();
    rawData_ = read();
    parsedData_ = parse(rawData_);
    if (!validate(parsedData_)) return;
    save(parsedData_);
    // Guard 소멸 시 close() 자동 호출
}

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

원인: 파생 클래스가 run()을 오버라이드하면서 close()를 빼먹거나 순서를 바꿔 버그 발생. 해결법: run()final로 두고, 파생 클래스는 훅만 오버라이드합니다.


8. 모범 사례

Observer 패턴

  1. 인터페이스 최소화: update() 시그니처를 단순하게 유지하고, 이벤트 타입이 많으면 Event 구조체나 variant로 전달합니다.
  2. 비동기 알림 고려: update()가 오래 걸리면 Subject의 notify()가 블로킹됩니다. 필요 시 std::async나 메시지 큐로 비동기 처리합니다.
  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. 템플릿 메서드는 final: run()final로 선언하면 파생 클래스가 알고리즘 골격을 깨뜨리는 실수를 방지할 수 있습니다.

9. 프로덕션 패턴

스레드 안전 Observer

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

#include <mutex>
#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);
        }
    }
};

전략 선택 팩토리

데이터 크기, 설정 등에 따라 적절한 전략을 선택하는 팩토리 패턴입니다.

std::unique_ptr<SortStrategy> createSortStrategy(size_t dataSize) {
    if (dataSize < 100) {
        return std::make_unique<BubbleSort>();
    } 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);

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 final로 두고, 가상 함수는 protected로 숨깁니다. 파생 클래스는 훅만 오버라이드하고, 알고리즘 골격을 변경할 수 없게 합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드
  • C++ 디자인 패턴 | Adapter·Decorator
  • C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]

이 글에서 다루는 키워드 (관련 검색어)

C++ 행동 패턴, Observer Strategy Command State, 디자인 패턴, Template Method, 행동 설계 등으로 검색하시면 이 글이 도움이 됩니다.

정리

패턴용도예제
Observer이벤트 전파UI 업데이트, 알림
Strategy알고리즘 교체정렬, 압축, 암호화
Command작업 캡슐화Undo/Redo, 매크로
State상태 전이 관리주문 상태, TCP 연결 상태
Template Method알고리즘 골격 재사용데이터 파이프라인, 테스트 픽스처

핵심 원칙:

  1. Observer: 느슨한 결합
  2. Strategy: 알고리즘 분리
  3. Command: 작업 객체화
  4. State: 상태별 동작 분리
  5. Template Method: 변하는 부분만 훅으로
  6. 인터페이스로 추상화
  7. 스마트 포인터로 메모리 관리

자주 묻는 질문 (FAQ)

Q. Observer와 Pub/Sub(발행-구독)의 차이는?

A: 개념적으로 비슷합니다. Observer는 주제(Subject)가 구독자 목록을 직접 관리하고 알립니다. Pub/Sub는 중간에 메시지 브로커가 있어서 발행자와 구독자가 서로를 모르는 구조인 경우가 많습니다. 작은 규모에서는 Observer로 충분하고, 시스템이 커지면 메시지 큐 기반 Pub/Sub를 고려할 수 있습니다.

Q. Strategy 패턴은 언제 쓰면 좋나요?

A: 같은 문제에 대해 서로 다른 알고리즘을 런타임에 바꿔야 할 때 유용합니다. 예: 정렬 방식(퀵소트/머지소트), 압축 방식, 결제 수단(카드/계좌이체) 등. if/else로 분기하는 대신 Strategy 객체를 주입하면 확장이 쉽습니다.

Q. Command 패턴 없이 Undo를 구현할 수 있나요?

A: 단순히 “마지막 한 단계만 취소”라면 이전 상태를 스냅샷으로 저장하는 방식도 가능합니다. Command 패턴은 각 동작을 객체로 저장해서 여러 단계 Undo/Redo, 매크로(연속 실행), 작업 큐 등을 일관되게 다룰 때 유리합니다.

Q. 이 내용을 실무에서 언제 쓰나요?

A. 객체 간 상호작용을 효과적으로 관리하는 Observer, Strategy, Command 패턴과 실전에서 활용하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: Observer·Strategy·Command·State·Template Method로 행동을 객체로 분리해 결합도를 낮출 수 있습니다. 다음으로 디자인 패턴 종합(#20-2)을 읽어보면 좋습니다.

이전 글: [C++ 실전 가이드 #19-3] PIMPL과 브릿지 패턴: 구현 숨기기와 추상화

다음 글: [C++ 실전 가이드 #20-2] 디자인 패턴 종합: Singleton·Factory·Observer·Strategy·PIMPL


관련 글

  • C++ 디자인 패턴 종합 가이드 | Singleton·Factory
  • C++ RAII 완벽 가이드 |
  • C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드
  • C++ 디자인 패턴 | Adapter·Decorator
  • C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]