포스트

Command Pattern

요청 자체를 Command 객체로 캡슐화해 Invoker가 Receiver를 모른 채 실행하는 패턴. 큐잉, 로그, 취소, 지연 실행이 자연스러워진다.

Command Pattern

난이도 중급 · 선행 Strategy

한 줄 요약

“어떤 일을 한다”는 행위 자체를 객체로 만든다. 그러면 그 행위를 큐에 넣거나, 나중에 실행하거나, 거꾸로 되돌리는 일이 자연스럽게 풀린다.

어떤 문제를 푸는가

텍스트 에디터에 Undo 기능을 만든다. 단순하게 짜면 이런 식이 된다.

1
2
3
4
5
6
7
8
9
10
class Editor {
    std::string text;
public:
    void type(const std::string& s) { text += s; }
    void deleteLast(int n)          { text.erase(text.size() - n); }
    void undo() {
        // ??? 직전에 무슨 동작을 했는지 어떻게 알지?
        // 동작별로 enum + 보조 데이터를 따로 관리해야 한다
    }
};

문제:

  • 동작별로 “되돌리는 법”이 다른데, Editor에 그걸 다 알게 하면 Editor가 비대해진다.
  • 매크로(여러 동작 묶어 한 번에 실행)나 작업 큐를 만들려면 동작을 1급 시민으로 다룰 수단이 없다.
  • 단축키와 메뉴 항목에 같은 동작을 바인딩하려면 결국 함수 포인터/람다로 가는데, 그러면 상태가 있는 동작이 표현되지 않는다.

패턴 적용 후

요청(동작)을 객체로 만든다. 객체는 execute()undo()를 안다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Editor {
    std::string text;
public:
    void insert(const std::string& s) { text += s; }
    void erase(size_t n)              { text.erase(text.size() - n); }
    const std::string& content() const { return text; }
};

class Command {
public:
    virtual void execute() = 0;
    virtual void undo() = 0;
    virtual ~Command() = default;
};

class InsertCommand : public Command {
    Editor& editor;
    std::string payload;
public:
    InsertCommand(Editor& editor, std::string s)
        : editor(editor), payload(std::move(s)) {}
    void execute() override { editor.insert(payload); }
    void undo() override    { editor.erase(payload.size()); }
};

class DeleteCommand : public Command {
    Editor& editor;
    std::string removed; // undo를 위해 지운 내용을 저장
    size_t count;
public:
    DeleteCommand(Editor& editor, size_t n) : editor(editor), count(n) {}
    void execute() override {
        const auto& t = editor.content();
        removed = t.substr(t.size() - count);
        editor.erase(count);
    }
    void undo() override { editor.insert(removed); }
};

class History {
    std::vector<std::unique_ptr<Command>> stack;
public:
    void run(std::unique_ptr<Command> cmd) {
        cmd->execute();
        stack.push_back(std::move(cmd));
    }
    void undo() {
        if (stack.empty()) return;
        stack.back()->undo();
        stack.pop_back();
    }
};

int main() {
    Editor editor;
    History history;

    history.run(std::make_unique<InsertCommand>(editor, "Hello "));
    history.run(std::make_unique<InsertCommand>(editor, "World"));
    std::cout << editor.content() << std::endl; // Hello World

    history.undo();
    std::cout << editor.content() << std::endl; // Hello

    history.run(std::make_unique<DeleteCommand>(editor, 6));
    std::cout << editor.content() << std::endl; // (빈 문자열)

    history.undo();
    std::cout << editor.content() << std::endl; // Hello
}

달라진 점:

  • 동작이 객체라서 스택·큐·리스트에 자유롭게 담는다.
  • Undo는 마지막 객체의 undo() 호출 한 줄. Editor는 Undo를 몰라도 된다.
  • 새 동작(서식, 자르기, 붙여넣기)은 새 Command 클래스로 추가. Editor 무손상.

구조

  • Command: execute()/undo() 인터페이스
  • ConcreteCommand: 실제 동작과 Receiver 호출 (InsertCommand)
  • Invoker: Command를 실행하는 쪽 (History)
  • Receiver: 실제 일을 하는 객체 (Editor)
  • Client: Command를 만들어 Invoker에게 건네는 쪽
1
2
3
4
Client ──creates──▶ Command ──knows──▶ Receiver
                       ▲
                       │
                    Invoker (실행, 큐, undo 스택)

Invoker는 Receiver를 모른다. 이게 핵심.

매크로 커맨드

여러 Command를 묶어 하나의 Command처럼 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MacroCommand : public Command {
    std::vector<std::unique_ptr<Command>> commands;
public:
    void add(std::unique_ptr<Command> cmd) { commands.push_back(std::move(cmd)); }
    void execute() override {
        for (auto& c : commands) c->execute();
    }
    void undo() override {
        // 역순으로 되돌리기
        for (auto it = commands.rbegin(); it != commands.rend(); ++it) {
            (*it)->undo();
        }
    }
};

Undo 순서가 역순인 이유: 동작 간 의존성이 있을 수 있다. A → B 순으로 실행했으면 B → A 순으로 되돌려야 안전하다.

실전 사례

  • GUI 버튼·메뉴·단축키 바인딩: 같은 Command 객체를 여러 위젯에 묶는다.
  • 작업 큐 / 스레드풀: 큐에 Command를 넣고 워커가 꺼내서 execute(). RabbitMQ·Celery 같은 메시지 큐가 같은 발상.
  • Redux의 Action: 객체 형태로 표현된 “할 일”. 실행 로직(reducer)과 분리됨.
  • DB 트랜잭션 로그: 커밋된 Command들을 디스크에 적어두면, 크래시 후 재실행으로 복구 가능.

Strategy Pattern과의 차이

같은 “행위를 객체로” 발상이라 헷갈리기 쉽다. 의도가 다르다.

 CommandStrategy
의도요청을 객체로 — 큐잉·로그·undo알고리즘을 교체 가능하게
핵심 메서드execute() (보통 인자 없음)algorithm(input) (인자 받음)
Receiver객체 안에 들고 다님없음 — Context가 호출
시점생성 ≠ 실행 (지연·큐잉)생성 ≈ 곧 실행

Command는 “할 일 자체”를 캡슐화. Strategy는 “할 일의 방법”을 캡슐화.

안티패턴 / 주의

  • 단순 함수 한 줄이면 Command 클래스 만들지 마라. std::function<void()> 한 줄이 더 깔끔하다. Command 클래스는 (1) undo가 필요하거나 (2) 상태를 들고 다녀야 하거나 (3) 동작을 직렬화해야 할 때 가치가 있다.
  • Receiver를 값으로 들고 있지 마라. 위 예제처럼 참조(또는 포인터)로 들어야 동작이 같은 Editor에 적용된다.
  • Undo가 항상 가능한 건 아니다. “이메일 발송” 같은 부수효과는 되돌릴 수 없다. Undoable / Non-undoable을 구분해 둘 것.
  • 메모리: undo 스택이 무한정 자라지 않게 상한을 둔다. 깊은 매크로의 메모리 누적도 주의.

스스로 점검

1. 텍스트 에디터에서 “복사 + 자르기 + 붙여넣기”를 매크로로 묶어 실행하려면?

MacroCommand — 여러 Command를 vector에 담아두고 execute()에서 차례로 호출. undo는 반드시 역순으로(B → A) 되돌려야 의존성이 깨지지 않는다.

2. “이메일 발송” 동작을 Command로 감싸면 undo가 가능할까?

아니다. 이미 전송된 이메일은 되돌릴 수 없는 부수효과. Undoable / Non-undoable을 구분해야 한다. 모든 동작이 undo 가능하다고 가정하면 잘못된 UX가 나온다.

3. std::function<void()> 한 줄과 Command 클래스, 언제 어느 쪽을 쓰나?

단순 콜백이면 std::function이 가볍다. Command 클래스는 (1) undo가 필요하거나 (2) 상태·메타데이터를 들고 다녀야 하거나 (3) 직렬화·로그 기록이 필요할 때 가치가 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.