C++ Command 패턴: 요청을 객체로 만들어 Undo/Redo·매크로·트랜잭션 구현하기
이 글의 핵심
Undo를 역연산으로 만들지 스냅샷으로 만들지, 얕은 복사로 상태를 저장했다가 겪는 실패가 핵심 함정입니다. 매크로 실행 중 예외가 나서 일부만 적용되는 문제, Receiver 생명주기, 히스토리 크기 제한, 다른 스레드가 소비하는 Command 큐의 스레드 안전성까지 실무에서 부딪히는 문제를 다룹니다.
요청을 객체로 만들면 생기는 이점
함수 호출만으로는 Undo를 만들기 어려운 이유
텍스트 에디터에서 Undo/Redo를 구현하려면 모든 작업을 기록해 두었다가 역순으로 되돌려야 합니다. 작업을 함수 호출로만 처리하면 호출이 끝난 뒤 무엇을 했는지 남지 않아 기록할 방법이 없습니다.
// 나쁜 예: 함수 호출만
void insertText(std::string& doc, const std::string& text) {
doc += text;
// Undo를 어떻게?
}
Command 패턴은 요청을 객체로 캡슐화합니다. 각 Command는 execute()와 undo()를 가지고, 실행된 Command는 히스토리 스택에 쌓입니다. 아래 Command는 실행에 필요한 정보(텍스트)와 되돌리는 데 필요한 정보(삽입 위치)를 함께 들고 있습니다.
// Command 객체 (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()를 가진 클래스 계층을 통째로 만들면 타입만 늘어나고 얻는 것이 없습니다.
경계선이 그어지는 지점은 실행 전 상태나 역연산 정보를 저장해야 하는 순간입니다. 콜백은 호출되고 나면 그 호출이 무엇을 했는지에 대한 정보를 아무것도 남기지 않습니다. std::function<void()>를 실행 큐에 쌓아뒀다가 “방금 실행한 것 3개를 취소해줘”라는 요구가 들어오면 답이 없습니다 — 함수 안에서 무슨 일이 일어났는지 캡처된 클로저 바깥에서는 전혀 알 수 없기 때문입니다. 이 순간부터는 실행 정보(무엇을, 어떤 파라미터로, 실행 전 상태가 어땠는지)를 함께 들고 다닐 객체가 필요해집니다. 즉 Command 패턴을 쓸지 말지의 실무적 기준은 “나중에 이 실행을 되돌리거나, 로그로 남기거나, 큐에 쌓아뒀다가 다른 시점에 재생해야 하는가” 하나로 압축됩니다. 이 셋 중 하나도 해당하지 않는다면 콜백으로 남겨두는 것이 더 나은 설계입니다.
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를 구현하는 방법은 크게 두 가지로 나뉘고, 이 둘의 선택이 메모리 사용량과 정확성을 좌우합니다.
첫째는 역연산(inverse operation) 방식입니다. 위 InsertCommand처럼 “삽입의 반대는 삭제”를 직접 코드로 구현합니다. 메모리를 거의 쓰지 않지만, 모든 연산에 정확한 역연산이 존재해야 한다는 전제가 붙습니다. 문제는 다루는 연산 중 상당수가 역연산을 정의하기 까다롭다는 점입니다. 예를 들어 “정렬(sort)“이나 “중복 제거(dedupe)“처럼 정보 손실이 일어나는 연산은 역연산만으로는 원래 상태를 복원할 수 없습니다. 이런 경우 역연산 방식은 아예 선택지에서 빠집니다.
둘째는 스냅샷(snapshot) 방식입니다. 실행 직전 상태를 통째로 복사해 두었다가 undo 시 그 상태로 되돌립니다. 구현이 쉽고 정보 손실이 있는 연산에도 적용할 수 있지만, 상태가 클수록 스냅샷 하나하나가 메모리를 차지합니다. 문서 편집기에서 매 Command마다 문서 전체를 복사하면 히스토리 크기에 문서 크기를 곱한 만큼 메모리가 필요합니다. 스냅샷을 Command와 분리된 객체로 두면 GoF의 Memento 패턴이 됩니다. Command는 작업을 기록하고, Memento는 상태를 보관하며, Command가 실행 전에 받아 둔 Memento를 undo에서 되돌려 주는 식으로 함께 쓰입니다.
스냅샷 방식에서 흔한 함정은 복사 비용을 아끼려고 얕은 복사(shallow copy)를 하는 것입니다. 포인터나 참조 멤버를 가진 구조체를 그대로 복사하면, 스냅샷이 원본의 내부 버퍼를 함께 가리키게 됩니다. 처음 몇 번의 테스트에서는 undo가 정상처럼 보이지만, 같은 원본을 연속으로 수정하면 “과거 상태”로 저장해 둔 스냅샷도 함께 바뀌어 있어서 undo를 눌러도 아무 변화가 없습니다. 스냅샷은 찍는 순간 원본과 메모리를 공유하지 않는 깊은 복사(deep copy)여야 합니다. 복사 비용이 문제라면 불변(immutable) 데이터 구조나 copy-on-write로 공유 부분을 안전하게 만드는 것이 올바른 최적화입니다.
실무에서는 이 둘을 섞어 쓰는 경우가 많습니다. 삽입/삭제처럼 역연산이 명확한 연산은 역연산 방식으로, 정렬처럼 역연산이 애매한 연산만 예외적으로 스냅샷을 찍는 하이브리드 전략이 메모리와 정확성 사이의 합리적인 절충점입니다.
여러 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
Command가 Receiver를 참조로 들고 있는데 Receiver가 먼저 소멸하면, 히스토리에 남은 Command의 undo나 redo가 이미 해제된 객체에 접근합니다.
// 잘못된 사용: 참조
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
삭제처럼 정보를 없애는 연산은 실행할 때 없어지는 데이터를 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()가 바로 이 용도의 훅입니다. 공백·줄바꿈이나 일정 시간 입력이 멈춘 시점을 병합의 경계로 삼는 것이 흔한 선택입니다.
참고 자료
- Erich Gamma 외, Design Patterns: Elements of Reusable Object-Oriented Software (Command, Memento 장)
- Refactoring Guru: Command Pattern
- Qt 문서: QUndoCommand