C++ Command 패턴: 요청을 객체로 만들어 Undo/Redo·매크로·트랜잭션 구현하기
이 글의 핵심
Undo를 역연산으로 만들지 스냅샷으로 만들지, 얕은 복사로 상태를 저장했다가 겪는 실패가 핵심 함정입니다. 매크로 실행 중 예외가 나서 일부만 적용되는 문제, Receiver 생명주기, 히스토리 크기 제한, 다른 스레드가 소비하는 Command 큐의 스레드 안전성까지 실무에서 부딪히는 문제를 다룹니다.
요청을 객체로 만들면 생기는 이점
함수 호출만으로는 Undo를 만들기 어려운 이유
문제: 텍스트 에디터에서 Undo/Redo를 구현하려면, 모든 작업을 기록하고 역순으로 실행해야 합니다. 작업을 함수 호출로만 하면 기록이 어렵습니다.
// 나쁜 예: 함수 호출만
void insertText(std::string& doc, const std::string& text) {
doc += text;
// Undo를 어떻게?
}
해결: Command Pattern은 요청을 객체로 캡슐화합니다. 각 Command는 execute()와 undo()를 가지며, 히스토리 스택에 저장됩니다.
// 좋은 예: Command 객체
// 타입 정의
class InsertCommand : public Command {
public:
InsertCommand(std::string& doc, const std::string& text)
: doc_(doc), text_(text), position_(doc.size()) {}
void execute() override {
doc_ += text_;
}
void undo() override {
doc_.erase(position_, text_.size());
}
private:
std::string& doc_;
std::string text_;
size_t position_;
};
flowchart TD
invoker["Invoker (Editor)"]
cmd[Command]
insert[InsertCommand]
delete[DeleteCommand]
receiver["Receiver (Document)"]
invoker -->|execute| cmd
insert -.->|구현| cmd
delete -.->|구현| cmd
insert --> receiver
delete --> receiver
함수 포인터·std::function으로 충분한 경우, 객체가 필요한 경우
Command 패턴을 처음 접하면 “이거 그냥 std::function<void()> 하나로 되는 거 아닌가?”라는 의문이 자연스럽게 듭니다. 실제로 절반은 맞는 말입니다. 버튼을 누르면 콜백 하나가 실행되고 끝나는 구조, 예를 들어 “저장 버튼 → save() 호출”처럼 실행만 하고 되돌릴 필요가 없는 동작이라면 std::function이나 람다 캡처로 충분합니다. 오히려 이런 단순한 케이스에 execute()/undo()를 가진 클래스 계층을 통째로 만드는 것은 과설계(over-engineering)에 가깝습니다. 클래스 파일 수만 늘어나고 가상 함수 호출 오버헤드만 추가될 뿐, 얻는 이득이 없습니다.
경계선이 명확히 그어지는 지점은 “실행 전 상태” 또는 “역연산”을 저장해야 하는 순간입니다. 콜백은 호출되고 나면 그 호출이 무엇을 했는지에 대한 정보를 아무것도 남기지 않습니다. std::function<void()>를 실행 큐에 쌓아뒀다가 “방금 실행한 것 3개를 취소해줘”라는 요구가 들어오면 답이 없습니다 — 함수 안에서 무슨 일이 일어났는지 캡처된 클로저 바깥에서는 전혀 알 수 없기 때문입니다. 이 순간부터는 실행 정보(무엇을, 어떤 파라미터로, 실행 전 상태가 어땠는지)를 함께 들고 다닐 객체가 필요해집니다. 즉 Command 패턴을 쓸지 말지의 실무적 기준은 “GoF 패턴이니까 정석대로” 같은 게 아니라, “나중에 이 실행을 되돌리거나, 로그로 남기거나, 큐에 쌓아뒀다가 다른 시점에 재생해야 하는가” 하나로 압축됩니다. 이 셋 중 하나도 해당하지 않는다면 콜백으로 남겨두는 것이 더 나은 설계입니다.
execute()와 undo()를 가진 최소 Command
#include <iostream>
#include <memory>
class Command {
public:
virtual void execute() = 0;
virtual void undo() = 0;
virtual ~Command() = default;
};
class Light {
public:
void on() { std::cout << "Light ON\n"; }
void off() { std::cout << "Light OFF\n"; }
};
class LightOnCommand : public Command {
public:
LightOnCommand(Light& light) : light_(light) {}
void execute() override { light_.on(); }
void undo() override { light_.off(); }
private:
Light& light_;
};
class LightOffCommand : public Command {
public:
LightOffCommand(Light& light) : light_(light) {}
void execute() override { light_.off(); }
void undo() override { light_.on(); }
private:
Light& light_;
};
class RemoteControl {
public:
void setCommand(std::unique_ptr<Command> cmd) {
command_ = std::move(cmd);
}
void pressButton() {
if (command_) {
command_->execute();
}
}
void pressUndo() {
if (command_) {
command_->undo();
}
}
private:
std::unique_ptr<Command> command_;
};
int main() {
Light light;
RemoteControl remote;
remote.setCommand(std::make_unique<LightOnCommand>(light));
remote.pressButton(); // Light ON
remote.pressUndo(); // Light OFF
}
히스토리 스택으로 Undo/Redo 구현하기
undo·redo 두 스택 관리
#include <iostream>
#include <memory>
#include <stack>
#include <string>
class Command {
public:
virtual void execute() = 0;
virtual void undo() = 0;
virtual ~Command() = default;
};
class Document {
public:
void insert(const std::string& text) {
content_ += text;
std::cout << "Document: " << content_ << '\n';
}
void remove(size_t pos, size_t len) {
content_.erase(pos, len);
std::cout << "Document: " << content_ << '\n';
}
const std::string& getContent() const { return content_; }
private:
std::string content_;
};
class InsertCommand : public Command {
public:
InsertCommand(Document& doc, const std::string& text)
: doc_(doc), text_(text), position_(doc.getContent().size()) {}
void execute() override {
doc_.insert(text_);
}
void undo() override {
doc_.remove(position_, text_.size());
}
private:
Document& doc_;
std::string text_;
size_t position_;
};
class CommandManager {
public:
void executeCommand(std::unique_ptr<Command> cmd) {
cmd->execute();
undoStack_.push(std::move(cmd));
// Redo 스택 클리어
while (!redoStack_.empty()) {
redoStack_.pop();
}
}
void undo() {
if (!undoStack_.empty()) {
auto cmd = std::move(undoStack_.top());
undoStack_.pop();
cmd->undo();
redoStack_.push(std::move(cmd));
}
}
void redo() {
if (!redoStack_.empty()) {
auto cmd = std::move(redoStack_.top());
redoStack_.pop();
cmd->execute();
undoStack_.push(std::move(cmd));
}
}
private:
std::stack<std::unique_ptr<Command>> undoStack_;
std::stack<std::unique_ptr<Command>> redoStack_;
};
int main() {
Document doc;
CommandManager manager;
manager.executeCommand(std::make_unique<InsertCommand>(doc, "Hello "));
manager.executeCommand(std::make_unique<InsertCommand>(doc, "World"));
manager.undo(); // "Hello "
manager.undo(); // ""
manager.redo(); // "Hello "
manager.redo(); // "Hello World"
}
Undo 구현 전략: 역연산 vs 스냅샷, 그리고 얕은 복사로 겪은 실패
Undo를 구현하는 방법은 크게 두 가지로 나뉘고, 이 둘의 선택이 코드 전체의 메모리 사용량과 정확성을 좌우합니다.
1. 역연산(inverse operation) 방식 — 위 InsertCommand처럼 “삽입의 반대는 삭제”라는 것을 직접 코드로 구현하는 방법입니다. 메모리를 거의 쓰지 않는다는 장점이 있지만, 모든 연산에 대해 정확한 역연산이 존재해야 한다는 전제가 붙습니다. 문제는 실무에서 다루는 연산 중 상당수가 역연산을 정의하기 까다롭다는 점입니다. 예를 들어 “정렬(sort)“이나 “중복 제거(dedupe)“처럼 정보 손실이 일어나는 연산은 역연산만으로는 원래 상태를 복원할 수 없습니다. 이런 경우 역연산 방식은 아예 선택지에서 빠집니다.
2. 스냅샷(snapshot) 방식 — 실행 직전 상태를 통째로 복사해뒀다가, undo 시 그 상태로 되돌리는 방법입니다. 구현이 압도적으로 쉽고 정보 손실이 있는 연산에도 안전하게 적용할 수 있지만, 상태가 클수록 스냅샷 하나하나가 메모리를 잡아먹습니다. 문서 편집기에서 문서 전체를 매 Command마다 복사한다면 히스토리 100개만 쌓여도 메모리가 순식간에 불어납니다.
이 트레이드오프를 몸으로 겪은 적이 있습니다. 사내 도구에서 객체의 “실행 전 상태”를 스냅샷으로 저장하는 Command를 만들면서, 성능을 아낀다고 값 복사 대신 참조 필드가 포함된 구조체를 얕은 복사(shallow copy)로 저장한 적이 있습니다. 처음 몇 번의 테스트에서는 undo가 멀쩡히 동작했는데, 이후 같은 원본 객체를 연속으로 수정하는 시나리오에서 undo를 눌러도 아무 변화가 없는 버그가 발생했습니다. 원인은 단순했습니다 — 스냅샷이 원본 객체의 내부 버퍼를 포인터(혹은 참조)로만 가리키고 있었기 때문에, 원본이 나중에 바뀌면 “과거 상태”로 저장해둔 스냅샷도 똑같이 바뀌어 있었던 것입니다. 스냅샷은 찍는 순간 완전히 독립적인 깊은 복사(deep copy)여야 한다는 당연한 원칙을, 최적화한다고 건드렸다가 undo 자체를 무의미하게 만든 경험이었습니다. 이후로는 스냅샷 방식을 쓸 때 “이 복사가 원본과 조금이라도 메모리를 공유하는가”를 항상 먼저 확인하는 습관이 생겼습니다.
실무에서는 이 둘을 섞어 쓰는 경우가 많습니다. 삽입/삭제처럼 역연산이 명확한 연산은 역연산 방식으로, 정렬처럼 역연산이 애매한 연산만 예외적으로 스냅샷을 찍는 하이브리드 전략이 메모리와 정확성 사이의 합리적인 절충점입니다.
여러 Command를 묶는 매크로
MacroCommand: 복합 Command
#include <iostream>
#include <memory>
#include <vector>
class Command {
public:
virtual void execute() = 0;
virtual void undo() = 0;
virtual ~Command() = default;
};
class MacroCommand : public Command {
public:
void add(std::unique_ptr<Command> cmd) {
commands_.push_back(std::move(cmd));
}
void execute() override {
for (auto& cmd : commands_) {
cmd->execute();
}
}
void undo() override {
// 역순으로 undo
for (auto it = commands_.rbegin(); it != commands_.rend(); ++it) {
(*it)->undo();
}
}
private:
std::vector<std::unique_ptr<Command>> commands_;
};
class PrintCommand : public Command {
public:
PrintCommand(const std::string& msg) : message_(msg) {}
void execute() override {
std::cout << message_ << '\n';
}
void undo() override {
std::cout << "Undo: " << message_ << '\n';
}
private:
std::string message_;
};
int main() {
auto macro = std::make_unique<MacroCommand>();
macro->add(std::make_unique<PrintCommand>("Step 1"));
macro->add(std::make_unique<PrintCommand>("Step 2"));
macro->add(std::make_unique<PrintCommand>("Step 3"));
macro->execute();
// Step 1
// Step 2
// Step 3
macro->undo();
// Undo: Step 3
// Undo: Step 2
// Undo: Step 1
}
매크로 실행 중 예외가 터지면: 부분 실행이라는 함정
위 MacroCommand::execute()를 자세히 보면 for 루프 안에서 각 cmd->execute()를 그냥 호출만 하고 있습니다. 이 코드가 프로덕션에서 위험한 이유는, 세 개짜리 매크로 중 세 번째 명령에서 예외가 터지면 앞의 두 개는 이미 실행되어 부작용을 낸 상태로 남는다는 점입니다. execute()는 “전부 실행되거나 전부 실행되지 않거나” 둘 중 하나를 보장하는 함수가 아니라, 그냥 반복문일 뿐입니다.
실제로 여러 필드를 순서대로 갱신하는 매크로 명령을 만들면서 이 문제에 정확히 부딪힌 적이 있습니다. 사용자가 “이름 변경 + 이메일 변경 + 권한 변경”을 하나의 매크로로 묶어 실행했는데, 세 번째 권한 변경 단계에서 유효성 검사 예외가 발생했습니다. 문제는 이름과 이메일은 이미 실행되어 버렸다는 것이었습니다. 매크로 자체는 실패로 끝났지만 시스템 상태는 “일부만 적용된” 애매한 상태로 남았고, 사용자 입장에서는 “저장이 실패했다”는 메시지를 봤는데 실제로는 절반이 저장되어 있는 불일치가 발생했습니다. 처음에는 단순히 예외를 잡아서 로그만 남기고 넘어갔는데, 이 불일치를 재현하고서야 롤백 로직이 반드시 필요하다는 것을 깨달았습니다.
해결 방법은 MacroCommand::execute()에도 아래 트랜잭션 절의 Transaction과 동일한 원칙을 적용하는 것입니다 — 몇 번째 명령까지 실행됐는지를 기록해두고, 예외가 발생하면 그 지점까지 실행된 명령만 역순으로 undo해야 합니다.
void execute() override {
size_t executedCount = 0;
try {
for (auto& cmd : commands_) {
cmd->execute();
++executedCount;
}
} catch (...) {
// 예외 발생 지점 이전까지만 역순으로 롤백
for (size_t i = executedCount; i-- > 0; ) {
commands_[i]->undo();
}
throw; // 호출자에게도 실패를 알려야 함
}
}
여기서 중요한 것은 undo() 자체도 실패할 수 있다는 점입니다. 롤백 도중 또 예외가 나면 시스템이 이도 저도 아닌 상태로 남을 수 있으므로, 실무에서는 undo()가 예외를 던지지 않도록 설계하거나(자원 해제류의 연산으로 제한), 최소한 롤백 실패를 별도로 로깅해서 운영자가 수동으로 정합성을 맞출 수 있게 해야 합니다. “명령을 객체화했으니 자동으로 트랜잭션이 된다”는 착각이 이 패턴에서 가장 흔한 함정입니다.
All-or-Nothing 트랜잭션 Command
#include <iostream>
#include <memory>
#include <vector>
#include <stdexcept>
class Command {
public:
virtual void execute() = 0;
virtual void undo() = 0;
virtual ~Command() = default;
};
class Transaction {
public:
void add(std::unique_ptr<Command> cmd) {
commands_.push_back(std::move(cmd));
}
bool commit() {
size_t executed = 0;
try {
for (auto& cmd : commands_) {
cmd->execute();
++executed; // 성공한 명령 수만 센다
}
return true;
} catch (const std::exception& e) {
std::cerr << "Transaction failed: " << e.what() << '\n';
rollback(executed);
return false;
}
}
// 실제로 실행된 앞쪽 count개만 역순으로 되돌린다
void rollback(size_t count) {
for (size_t i = count; i-- > 0; ) {
try {
commands_[i]->undo();
} catch (const std::exception& e) {
// 롤백 실패는 삼키지 말고 최소한 기록한다
std::cerr << "Rollback step failed: " << e.what() << '\n';
}
}
}
private:
std::vector<std::unique_ptr<Command>> commands_;
};
class Account {
public:
Account(double balance) : balance_(balance) {}
void deposit(double amount) {
balance_ += amount;
std::cout << "Deposited $" << amount << ", Balance: $" << balance_ << '\n';
}
void withdraw(double amount) {
if (balance_ < amount) {
throw std::runtime_error("Insufficient funds");
}
balance_ -= amount;
std::cout << "Withdrew $" << amount << ", Balance: $" << balance_ << '\n';
}
private:
double balance_;
};
class DepositCommand : public Command {
public:
DepositCommand(Account& acc, double amount) : account_(acc), amount_(amount) {}
void execute() override { account_.deposit(amount_); }
void undo() override { account_.withdraw(amount_); }
private:
Account& account_;
double amount_;
};
class WithdrawCommand : public Command {
public:
WithdrawCommand(Account& acc, double amount) : account_(acc), amount_(amount) {}
void execute() override { account_.withdraw(amount_); }
void undo() override { account_.deposit(amount_); }
private:
Account& account_;
double amount_;
};
int main() {
Account acc(100.0);
Transaction txn;
txn.add(std::make_unique<WithdrawCommand>(acc, 50.0));
txn.add(std::make_unique<DepositCommand>(acc, 30.0));
txn.add(std::make_unique<WithdrawCommand>(acc, 100.0)); // 실패
if (!txn.commit()) {
std::cout << "Transaction rolled back\n"; // 잔액은 다시 $100
}
}
commit()이 “몇 번째까지 성공했는가”를 세는 이유는 예제의 흐름을 따라가 보면 분명해집니다. 잔액 100에서 50을 출금(50), 30을 입금(80)한 뒤 100 출금이 Insufficient funds로 실패합니다. 이때 세 번째 명령은 아무 일도 하지 않았으므로 되돌릴 것도 없습니다. 만약 모든 명령을 무조건 역순으로 undo하면, 실패한 출금의 역연산인 “100 입금”이 먼저 실행되어 잔액이 180이 되고, 나머지 두 개를 되돌린 최종 잔액은 원래의 100이 아니라 200이 됩니다. 롤백이 오히려 돈을 만들어 내는 셈입니다. 실행되지 않은 명령은 undo하지 않는다는 규칙은 위 매크로 절의 executedCount와 같은 원리입니다.
또 하나 짚어 둘 점은 DepositCommand::undo()가 withdraw()를 호출한다는 것입니다. 그 사이에 다른 코드가 잔액을 줄였다면 undo 자체가 Insufficient funds로 실패할 수 있습니다. 역연산 방식의 undo는 “실행 직후 상태에서 곧바로 되돌린다”는 가정 위에서만 안전하며, Receiver를 다른 경로로도 수정할 수 있는 시스템에서는 이 가정이 깨집니다. 실제 금융 코드라면 double 대신 정수 최소 단위(원, 센트)를 쓰고, 이 정도의 원자성은 애플리케이션 레벨 Command가 아니라 DB 트랜잭션에 맡기는 것이 맞습니다. 이 예제는 Command로 All-or-Nothing의 뼈대를 어떻게 잡는지를 보여 주는 용도로만 보시면 됩니다.
Receiver 수명과 되돌릴 수 없는 Command
Receiver가 먼저 소멸해 생기는 dangling reference
증상: Dangling reference. 원인: Command가 Receiver를 참조하는데, Receiver가 먼저 소멸.
// ❌ 잘못된 사용: 참조
class Command {
Receiver& receiver_; // Dangling 가능
};
// ✅ 수명을 공유해야 한다면: shared_ptr
class Command {
std::shared_ptr<Receiver> receiver_;
};
shared_ptr로 바꾸면 dangling은 사라지지만 반대 방향의 문제가 생깁니다. 히스토리 스택에 남아 있는 Command들이 Receiver를 계속 붙잡고 있어서, 사용자가 문서 탭을 닫아도 문서 객체가 해제되지 않습니다. 그래서 에디터류에서는 보통 히스토리를 Receiver 쪽에 둡니다. 문서마다 자기 CommandManager를 갖고, 문서가 닫히면 히스토리도 같이 사라지므로 참조(Receiver&)를 써도 수명 문제가 없습니다. Receiver가 먼저 사라질 수 있는 구조라면 weak_ptr로 들고 있다가 lock()이 실패하면 그 Command를 건너뛰는 방식이 shared_ptr보다 의도를 잘 드러냅니다.
복원 정보 없이 만든 Command
증상: Undo 시 복원 불가. 원인: 상태를 저장하지 않았습니다.
// ❌ 잘못된 사용: 상태 미저장
class DeleteCommand : public Command {
void undo() override {
// 삭제된 데이터를 어떻게 복원?
}
};
// ✅ 올바른 사용: 상태 저장
class DeleteCommand : public Command {
std::string deletedText_; // 저장
void execute() override {
deletedText_ = doc_.getText();
doc_.clear();
}
void undo() override {
doc_.setText(deletedText_);
}
};
히스토리 제한·비동기 실행·스레드 간 Command 큐
히스토리 개수 제한
#include <deque>
class CommandManager {
public:
CommandManager(size_t maxHistory = 100) : maxHistory_(maxHistory) {}
void executeCommand(std::unique_ptr<Command> cmd) {
cmd->execute();
undoStack_.push_back(std::move(cmd)); // 뒤쪽이 최신
// 히스토리 제한: 가장 오래된(앞쪽) 항목을 버린다
if (undoStack_.size() > maxHistory_) {
undoStack_.pop_front();
}
redoStack_.clear();
}
private:
size_t maxHistory_;
std::deque<std::unique_ptr<Command>> undoStack_;
std::deque<std::unique_ptr<Command>> redoStack_;
};
히스토리 제한은 std::stack으로는 구현할 수 없습니다. std::stack은 top()만 노출하므로 pop()은 방금 넣은 최신 Command를 버립니다. 한도를 넘는 순간부터 사용자가 방금 한 작업을 되돌릴 수 없게 되는, 한도에 도달해야만 드러나는 버그입니다. 양쪽 끝을 모두 다룰 수 있는 std::deque로 두고 뒤쪽을 스택 top처럼 쓰다가, 넘치면 pop_front()로 가장 오래된 항목을 버립니다. 개수 대신 Command가 쥔 스냅샷 크기의 합으로 한도를 거는 편이 실제 메모리 사용량과는 더 잘 맞습니다.
std::async로 비동기 Command 실행
#include <future>
class AsyncCommand : public Command {
public:
void execute() override {
future_ = std::async(std::launch::async, [this]() {
// 비동기 작업
});
}
void wait() {
if (future_.valid()) {
future_.wait();
}
}
private:
std::future<void> future_;
};
이 스케치는 undo()를 정의하지 않았으므로 그대로는 추상 클래스이고, 비동기 Command에서 undo가 얼마나 까다로운지를 보여 줍니다. 작업이 아직 진행 중일 때 undo가 들어오면 취소해야 할지, 완료를 기다렸다가 되돌려야 할지부터 정해야 합니다. 또 std::async가 돌려준 std::future는 소멸자에서 작업 완료를 기다리며 블록되기 때문에, 히스토리 한도로 Command를 버리는 순간 UI 스레드가 멈출 수 있습니다. 람다가 this를 캡처하므로 작업이 끝나기 전에 Command 객체가 사라지면 안 된다는 점도 같은 문제의 다른 얼굴입니다.
다른 스레드가 소비하는 Command 큐와 스레드 안전성
지금까지 본 CommandManager는 전부 단일 스레드를 전제로 합니다. 하지만 GUI 이벤트 루프가 Command를 만들어 큐에 넣고, 별도의 워커 스레드가 그 큐를 꺼내 실행하는 구조(작업 큐, 렌더링 파이프라인, 네트워크 요청 큐 등)로 확장하는 순간 스레드 안전성 문제가 새로 생깁니다.
가장 먼저 생각해야 할 것은 큐 자체의 동시 접근 보호입니다. std::stack이나 std::vector는 스레드 안전하지 않으므로, 생산자(producer) 스레드가 push하는 동안 소비자(consumer) 스레드가 pop하면 데이터 경쟁(data race)이 발생합니다. std::mutex와 std::condition_variable로 감싼 스레드 안전 큐가 최소 요건입니다.
#include <mutex>
#include <condition_variable>
#include <queue>
class CommandQueue {
public:
void push(std::unique_ptr<Command> cmd) {
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(cmd));
}
cv_.notify_one();
}
std::unique_ptr<Command> waitAndPop() {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return !queue_.empty(); });
auto cmd = std::move(queue_.front());
queue_.pop();
return cmd;
}
private:
std::mutex mutex_;
std::condition_variable cv_;
std::queue<std::unique_ptr<Command>> queue_;
};
두 번째로 신경 써야 하는 것은 Command가 캡처한 Receiver에 대한 접근도 동기화되어야 한다는 점입니다. 큐 자체를 뮤텍스로 보호했다고 해서 안심할 수 없습니다. Command가 execute() 안에서 건드리는 대상(Document, Account 등 Receiver)을 다른 스레드가 동시에 읽거나 쓰고 있다면, 큐는 안전해도 실제 연산은 여전히 데이터 경쟁입니다. 실무에서는 보통 “Receiver 하나당 Command는 항상 같은 워커 스레드에서만 실행”하도록 큐를 나누거나, Receiver 자체에 락을 두는 방식으로 이 문제를 피합니다.
세 번째는 undo/redo 히스토리와 비동기 실행의 순서 보장입니다. Command를 큐에 넣은 시점과 실제로 워커 스레드가 그것을 실행하는 시점 사이에는 시간차가 있습니다. 이 상태에서 사용자가 undo를 누르면 무엇을 되돌려야 할까요 — 아직 큐에서 대기 중인 Command일 수도 있고, 이미 실행이 끝난 Command일 수도 있습니다. 이 모호함을 없애려면 “실행 완료를 확인한 Command만 undo 스택에 넣는다”는 규칙을 명시적으로 지켜야 하며, 실행 완료 신호를 std::future나 콜백으로 명확히 받아서 undo 스택 push 시점을 실행 완료 이후로 미루는 것이 안전합니다. 이 순서를 지키지 않으면 “아직 실행되지도 않은 명령을 undo했다”는, 디버깅하기 매우 까다로운 버그로 이어집니다.
텍스트 에디터에 Undo/Redo 붙이기
앞선 예제들과 달리 여기서는 InsertCommand가 삽입 위치를 생성자 인자로 받고, DeleteCommand는 execute() 시점에 지울 텍스트를 읽어 deletedText_에 보관합니다. 첫 절의 InsertCommand처럼 생성 시점의 문서 길이를 위치로 삼으면, Command를 만들어 두고 나중에 실행하는(큐잉, 매크로 재생) 경우 그 사이 문서가 바뀌어 엉뚱한 위치를 지우게 됩니다. 복원에 필요한 정보는 실행 시점에 캡처해야 redo 뒤 다시 undo할 때도 정확합니다.
#include <iostream>
#include <memory>
#include <stack>
#include <string>
class Command {
public:
virtual void execute() = 0;
virtual void undo() = 0;
virtual std::string describe() const = 0;
virtual ~Command() = default;
};
class TextEditor {
public:
void insert(size_t pos, const std::string& text) {
content_.insert(pos, text);
}
void erase(size_t pos, size_t len) {
content_.erase(pos, len);
}
std::string getText(size_t pos, size_t len) const {
return content_.substr(pos, len);
}
const std::string& getContent() const { return content_; }
void print() const {
std::cout << "Content: \"" << content_ << "\"\n";
}
private:
std::string content_;
};
class InsertCommand : public Command {
public:
InsertCommand(TextEditor& editor, size_t pos, const std::string& text)
: editor_(editor), position_(pos), text_(text) {}
void execute() override {
editor_.insert(position_, text_);
}
void undo() override {
editor_.erase(position_, text_.size());
}
std::string describe() const override {
return "Insert \"" + text_ + "\" at " + std::to_string(position_);
}
private:
TextEditor& editor_;
size_t position_;
std::string text_;
};
class DeleteCommand : public Command {
public:
DeleteCommand(TextEditor& editor, size_t pos, size_t len)
: editor_(editor), position_(pos), length_(len) {}
void execute() override {
deletedText_ = editor_.getText(position_, length_);
editor_.erase(position_, length_);
}
void undo() override {
editor_.insert(position_, deletedText_);
}
std::string describe() const override {
return "Delete " + std::to_string(length_) + " chars at " + std::to_string(position_);
}
private:
TextEditor& editor_;
size_t position_;
size_t length_;
std::string deletedText_;
};
class EditorController {
public:
EditorController(TextEditor& editor) : editor_(editor) {}
void execute(std::unique_ptr<Command> cmd) {
std::cout << "Executing: " << cmd->describe() << '\n';
cmd->execute();
editor_.print();
undoStack_.push(std::move(cmd));
while (!redoStack_.empty()) {
redoStack_.pop();
}
}
void undo() {
if (undoStack_.empty()) {
std::cout << "Nothing to undo\n";
return;
}
auto cmd = std::move(undoStack_.top());
undoStack_.pop();
std::cout << "Undoing: " << cmd->describe() << '\n';
cmd->undo();
editor_.print();
redoStack_.push(std::move(cmd));
}
void redo() {
if (redoStack_.empty()) {
std::cout << "Nothing to redo\n";
return;
}
auto cmd = std::move(redoStack_.top());
redoStack_.pop();
std::cout << "Redoing: " << cmd->describe() << '\n';
cmd->execute();
editor_.print();
undoStack_.push(std::move(cmd));
}
private:
TextEditor& editor_;
std::stack<std::unique_ptr<Command>> undoStack_;
std::stack<std::unique_ptr<Command>> redoStack_;
};
int main() {
TextEditor editor;
EditorController controller(editor);
controller.execute(std::make_unique<InsertCommand>(editor, 0, "Hello"));
controller.execute(std::make_unique<InsertCommand>(editor, 5, " World"));
controller.execute(std::make_unique<DeleteCommand>(editor, 5, 6));
controller.undo();
controller.undo();
controller.redo();
}
실행하면 “Hello” → “Hello World” → “Hello”(삭제) → undo로 “Hello World” → 다시 undo로 “Hello” → redo로 “Hello World” 순서로 내용이 바뀝니다. describe()는 Undo 메뉴에 “실행 취소: Insert …”처럼 표시하거나 매크로를 로그로 남길 때 쓰는 용도입니다.
이 구조를 실제 에디터에 붙이면 곧바로 부딪히는 문제가 Undo 단위입니다. 키 입력마다 InsertCommand를 하나씩 만들면 “Hello”를 되돌리는 데 Ctrl+Z를 다섯 번 눌러야 합니다. 사용자 기대와 맞추려면 직전 Command와 종류가 같고 위치가 연속이면 새로 push하지 않고 직전 Command에 합치는 병합(coalescing) 로직이 필요합니다. Qt의 QUndoCommand::mergeWith()가 바로 이 용도의 훅입니다. 공백·줄바꿈이나 일정 시간 입력이 멈춘 시점을 병합의 경계로 삼는 것이 흔한 선택입니다.
Command 패턴 요약
| 개념 | 설명 |
|---|---|
| Command Pattern | 요청을 객체로 캡슐화 |
| 목적 | Undo/Redo, 매크로, 트랜잭션, 큐 |
| 구조 | Command, Invoker, Receiver |
| 장점 | 요청 기록, 취소 가능, 조합 가능 |
| 단점 | 클래스 증가, 메모리 사용 |
| 사용 사례 | 에디터, GUI, 트랜잭션, 작업 큐 |
Command Pattern은 요청을 객체화해 Undo/Redo와 매크로를 구현하는 강력한 패턴입니다.
FAQ
Q1: Command Pattern은 언제 쓰나요?
A: Undo/Redo, 매크로, 트랜잭션, 작업 큐가 필요할 때 사용합니다.
Q2: Memento Pattern과 차이는?
A: Command는 작업 기록, Memento는 상태 스냅샷에 집중합니다. 앞의 “역연산 vs 스냅샷”에서 스냅샷 방식을 고르면, Command가 실행 전 상태를 Memento로 받아 들고 있다가 undo에서 되돌려 주는 식으로 두 패턴을 함께 쓰게 됩니다.
Q3: 메모리 사용량은?
A: 히스토리 스택이 커지면 메모리가 증가합니다. 히스토리 제한을 두세요.
Q4: 비동기 Command는?
A: std::async나 std::thread로 비동기 실행 가능합니다.
Q5: Receiver 생명주기는?
A: shared_ptr로 관리하거나, Command가 Receiver보다 먼저 소멸되도록 보장하세요.
Q6: Command Pattern 학습 리소스는?
A:
- “Design Patterns” by Gang of Four
- “Head First Design Patterns” by Freeman & Freeman
- Refactoring Guru: Command Pattern
Command Pattern으로 요청을 객체화하고 Undo/Redo를 구현할 수 있습니다. 다음으로 State Pattern을 읽어보면 좋습니다.
관련 글
- C++ State 패턴
- C++ Strategy 패턴
- C++ Observer 패턴
- C++ Decorator 패턴
- C++ 디자인 패턴 | Singleton·Factory·Observer·Strategy·PIMPL