편집기의 “되돌리기”, 작업을 백그라운드 스레드에 맡기기, 실행한 작업을 기록했다가 다시 재생하기는 겉보기에 다른 기능이지만 공통 전제가 있다. “무엇을 할지”가 함수 호출로 흘러가 버리지 않고 값으로 남아 있어야 한다는 것이다. 커맨드(Command)는 요청을 객체로 캡슐화해서, 요청을 매개변수화하고, 큐에 넣고, 기록하고, 취소할 수 있게 한다. 이 글에서는 undo/redo 스택과 연속 입력 병합, 실패 시 롤백하는 매크로 커맨드, 커맨드 로그 재생, 작업 큐, 그리고 C의 지연 실행 큐(커널 work_struct와 같은 구조)를 다룬다.
메뉴 이벤트를 리스너 → std::function → 커맨드로 발전시키며 기본 undo를 넣는 과정은 설계 기법 (9) 메뉴 이벤트 처리에서 다뤘다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (12) — 프록시(Proxy)
- C/C++ 디자인 패턴 (13) — 책임 연쇄(Chain of Responsibility)
- C/C++ 디자인 패턴 (15) — 반복자(Iterator)
- C/C++ 디자인 패턴 (16) — 중재자(Mediator)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Command | 실행(·취소) 연산 선언 | Command (execute, undo) |
| ConcreteCommand | Receiver와 인자를 묶어 동작 구현 | Insert, Erase, Macro |
| Receiver | 실제 작업 대상 | Doc |
| Invoker | 커맨드를 보관하고 실행 시점을 결정 | History, JobQueue |
| Client | 커맨드를 만들어 Invoker에 넘김 | main() |
// editor.hpp — Receiver(문서)와 Command 인터페이스
#pragma once
#include <memory>
#include <stdexcept>
#include <string>
struct Doc { std::string text; }; // Receiver
struct Command {
virtual ~Command() = default;
virtual void execute(Doc &d) = 0;
virtual void undo(Doc &d) = 0;
virtual std::string name() const = 0;
// 연속 입력을 하나로 합칠 수 있으면 true (기본은 불가)
virtual bool mergeWith(const Command &) { return false; }
};
struct Insert : Command {
std::size_t pos; std::string s;
Insert(std::size_t p, std::string t) : pos(p), s(std::move(t)) {}
void execute(Doc &d) override
{
if (pos > d.text.size())
throw std::out_of_range("insert at " + std::to_string(pos));
d.text.insert(pos, s);
}
void undo(Doc &d) override { d.text.erase(pos, s.size()); }
std::string name() const override { return "insert '" + s + "'@" + std::to_string(pos); }
bool mergeWith(const Command &o) override
{
auto *n = dynamic_cast<const Insert *>(&o);
// 바로 뒤에 이어 쓴 글자만, 공백을 경계로 단어 단위로 끊는다
if (!n || n->pos != pos + s.size() || n->s == " " || s.back() == ' ')
return false;
s += n->s;
return true;
}
};
struct Erase : Command {
std::size_t pos, len; std::string removed;
Erase(std::size_t p, std::size_t l) : pos(p), len(l) {}
void execute(Doc &d) override
{
if (pos + len > d.text.size())
throw std::out_of_range("erase past end");
removed = d.text.substr(pos, len);
d.text.erase(pos, len);
}
void undo(Doc &d) override { d.text.insert(pos, removed); }
std::string name() const override { return "erase " + std::to_string(len) + "@" + std::to_string(pos); }
};undo/redo 스택과 입력 병합
한 글자마다 커맨드를 쌓으면 되돌리기를 글자 수만큼 눌러야 한다. 새 커맨드를 직전 커맨드에 합칠 수 있으면(mergeWith) 흡수해서 단어 단위로 되돌린다.
// history.cpp — Invoker: undo/redo 스택과 연속 입력 병합
#include <iostream>
#include <vector>
#include "editor.hpp"
class History {
public:
explicit History(Doc &d) : d_(d) {}
void run(std::unique_ptr<Command> c)
{
c->execute(d_);
if (!done_.empty() && done_.back()->mergeWith(*c))
; // 이전 커맨드에 흡수
else
done_.push_back(std::move(c));
undone_.clear(); // 새 작업이 생기면 redo 기록 폐기
}
bool undo() { return move(done_, undone_, true); }
bool redo() { return move(undone_, done_, false); }
std::size_t size() const { return done_.size(); }
private:
bool move(std::vector<std::unique_ptr<Command>> &from, std::vector<std::unique_ptr<Command>> &to, bool isUndo)
{
if (from.empty())
return false;
auto c = std::move(from.back());
from.pop_back();
isUndo ? c->undo(d_) : c->execute(d_);
std::cout << (isUndo ? " undo " : " redo ") << c->name() << '\n';
to.push_back(std::move(c));
return true;
}
Doc &d_;
std::vector<std::unique_ptr<Command>> done_, undone_;
};
int main()
{
Doc d;
History h(d);
for (char ch : std::string("hello")) // 한 글자씩 입력
h.run(std::make_unique<Insert>(d.text.size(), std::string(1, ch)));
h.run(std::make_unique<Insert>(d.text.size(), " "));
for (char ch : std::string("world"))
h.run(std::make_unique<Insert>(d.text.size(), std::string(1, ch)));
std::cout << "text=\"" << d.text << "\" history=" << h.size() << " (11 keystrokes)\n";
h.undo();
std::cout << "text=\"" << d.text << "\"\n";
h.redo();
h.undo(); h.undo();
std::cout << "text=\"" << d.text << "\"\n";
h.run(std::make_unique<Insert>(5, "!")); // 새 작업 → redo 기록 사라짐
std::cout << "text=\"" << d.text << "\" redo possible? " << std::boolalpha << h.redo() << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o history history.cpp && ./history
text="hello world" history=3 (11 keystrokes)
undo insert 'world'@6
text="hello "
redo insert 'world'@6
undo insert 'world'@6
undo insert ' '@5
text="hello"
text="hello!" redo possible? false
키 입력 11번이 커맨드 3개로 합쳐졌다. 되돌린 뒤 새 작업을 하면 redo 기록은 의미가 없어지므로 비운다.
매크로 커맨드와 롤백
여러 커맨드를 하나로 묶은 매크로는 그 자체가 커맨드라 같은 방식으로 실행·취소된다. 중간에 실패하면 이미 실행한 부분만 역순으로 되돌려 문서를 원래대로 둔다.
// macro.cpp — 매크로 커맨드: 여러 커맨드를 하나로, 중간에 실패하면 이미 한 것을 되돌린다
#include <iostream>
#include <vector>
#include "editor.hpp"
class Macro : public Command {
public:
Macro &add(std::unique_ptr<Command> c) { cmds_.push_back(std::move(c)); return *this; }
void execute(Doc &d) override
{
std::size_t done = 0;
try {
for (; done < cmds_.size(); ++done)
cmds_[done]->execute(d);
} catch (...) {
while (done-- > 0) // 역순으로 롤백
cmds_[done]->undo(d);
throw;
}
}
void undo(Doc &d) override
{
for (auto it = cmds_.rbegin(); it != cmds_.rend(); ++it)
(*it)->undo(d);
}
std::string name() const override { return "macro(" + std::to_string(cmds_.size()) + ")"; }
private:
std::vector<std::unique_ptr<Command>> cmds_;
};
int main()
{
Doc d{"int x = 1;"};
Macro wrap; // "if (x) { ... }"로 감싸기
wrap.add(std::make_unique<Insert>(0, "if (x) { "))
.add(std::make_unique<Insert>(19, " }"));
wrap.execute(d);
std::cout << "after macro : " << d.text << '\n';
wrap.undo(d);
std::cout << "after undo : " << d.text << '\n';
Macro broken;
broken.add(std::make_unique<Insert>(0, "// "))
.add(std::make_unique<Erase>(3, 3))
.add(std::make_unique<Insert>(999, "?")); // 세 번째에서 실패
try {
broken.execute(d);
} catch (const std::exception &e) {
std::cout << "failed: " << e.what() << "\nrolled back : " << d.text << '\n';
}
}$ g++ -std=c++20 -Wall -Wextra -O2 -o macro macro.cpp && ./macro
after macro : if (x) { int x = 1; }
after undo : int x = 1;
failed: insert at 999
rolled back : int x = 1;
세 번째 커맨드가 범위 밖 위치로 실패했지만, 앞의 두 커맨드가 취소되어 문서는 손대기 전과 같다.
커맨드 로그 재생
커맨드를 직렬화할 수 있으면 상태 대신 커맨드 기록을 저장하고, 기록을 다시 실행해 상태를 복원할 수 있다. 데이터베이스의 WAL, 이벤트 소싱, 협업 편집기의 연산 로그가 같은 원리다.
// replay.cpp — 커맨드를 텍스트 로그로 남기고, 로그만으로 상태를 다시 만든다
#include <iostream>
#include <sstream>
#include <vector>
#include "editor.hpp"
std::unique_ptr<Command> parse(const std::string &line)
{
std::istringstream in(line);
std::string op;
std::size_t pos;
in >> op >> pos;
if (op == "ins") {
std::string s;
std::getline(in >> std::ws, s);
return std::make_unique<Insert>(pos, s);
}
std::size_t len;
in >> len;
return std::make_unique<Erase>(pos, len);
}
int main()
{
std::vector<std::string> log; // 저장소(파일, 네트워크)를 흉내
Doc live;
for (std::string line : {"ins 0 hello", "ins 5 world", "ins 5 , ", "era 0 1", "ins 0 H"}) {
parse(line)->execute(live);
log.push_back(line); // 실행한 커맨드를 기록
}
std::cout << "live : " << live.text << " (" << log.size() << " commands logged)\n";
Doc rebuilt; // 다른 프로세스/다음 실행에서
for (const auto &line : log)
parse(line)->execute(rebuilt);
std::cout << "replayed: " << rebuilt.text << '\n';
Doc partial; // 특정 시점까지만 재생
for (std::size_t i = 0; i < 3; ++i)
parse(log[i])->execute(partial);
std::cout << "at step3: " << partial.text << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o replay replay.cpp && ./replay
live : Hello, world (5 commands logged)
replayed: Hello, world
at step3: hello, world
로그만으로 같은 문서를 다시 만들었고, 앞의 3개만 재생하면 그 시점의 상태가 나온다.
작업 큐: 요청과 실행의 분리
요청을 값으로 만들었으니 요청한 스레드가 아니라 다른 스레드에서, 나중에 실행할 수 있다. 여기서는 커맨드 인터페이스 대신 std::function<void()>을 커맨드로 썼다.
// queue.cpp — 요청을 객체로 만들었으니 큐에 넣고 다른 스레드에서 실행할 수 있다
#include <condition_variable>
#include <functional>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
class JobQueue {
public:
JobQueue() : worker_([this] { loop(); }) {}
~JobQueue()
{
post({}); // 빈 커맨드 = 종료 신호
worker_.join();
}
void post(std::function<void()> job)
{
{ std::lock_guard lk(m_); q_.push(std::move(job)); }
cv_.notify_one();
}
private:
void loop()
{
for (;;) {
std::function<void()> job;
{
std::unique_lock lk(m_);
cv_.wait(lk, [this] { return !q_.empty(); });
job = std::move(q_.front());
q_.pop();
}
if (!job)
return;
job(); // 요청한 스레드와 다른 곳, 다른 시점에 실행
}
}
std::mutex m_;
std::condition_variable cv_;
std::queue<std::function<void()>> q_;
std::thread worker_;
};
int main()
{
auto main_id = std::this_thread::get_id();
{
JobQueue jobs;
std::cout << "posting 3 jobs from main thread\n";
for (int i = 1; i <= 3; ++i)
jobs.post([i, main_id] {
std::cout << "job " << i << " on "
<< (std::this_thread::get_id() == main_id ? "main" : "worker") << " thread\n";
});
} // 소멸자가 남은 작업을 모두 처리
std::cout << "queue drained\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -pthread -o queue queue.cpp && ./queue
posting 3 jobs from main thread
job 1 on worker thread
job 2 on worker thread
job 3 on worker thread
queue drained
C 구현: 지연 실행 큐
C에서는 함수 포인터를 가진 작은 구조체를 각 작업 구조체의 첫 멤버로 넣고, 실행 시 container_of로 바깥 구조체를 되찾는다. 리눅스 커널의 struct work_struct와 INIT_WORK/queue_work가 이 구조다.
/* workq.c — C 커맨드: 함수 포인터 + 인자를 구조체에 담아 나중에 실행(커널 work_struct와 같은 모양) */
#include <stddef.h>
#include <stdio.h>
struct work {
void (*func)(struct work *w); /* 실행할 동작 */
struct work *next;
};
/* 구체 커맨드: work를 첫 멤버로 품고, 자기 데이터를 함께 갖는다 */
struct flush_work { struct work w; const char *dev; int blocks; };
struct log_work { struct work w; const char *msg; };
#define container_of(ptr, type, member) ((type *)((char *)(ptr) - offsetof(type, member)))
static void do_flush(struct work *w)
{
struct flush_work *f = container_of(w, struct flush_work, w);
printf(" flush %s: %d blocks\n", f->dev, f->blocks);
}
static void do_log(struct work *w)
{
struct log_work *l = container_of(w, struct log_work, w);
printf(" log: %s\n", l->msg);
}
static struct work *head, **tail = &head;
static void queue_work(struct work *w) { w->next = NULL; *tail = w; tail = &w->next; }
static int run_pending(void)
{
int n = 0;
for (struct work *w = head; w; w = w->next, n++)
w->func(w); /* 무엇을 하는지 모른 채 실행 */
head = NULL;
tail = &head;
return n;
}
int main(void)
{
struct flush_work f1 = { { do_flush, NULL }, "sda", 128 };
struct log_work l1 = { { do_log, NULL }, "suspend requested" };
struct flush_work f2 = { { do_flush, NULL }, "nvme0n1", 4096 };
queue_work(&l1.w); /* 인터럽트 문맥 등에서 등록만 하고 */
queue_work(&f1.w);
queue_work(&f2.w);
printf("queued, running later:\n");
printf("ran %d works\n", run_pending()); /* 나중에 안전한 문맥에서 실행 */
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -o workq workq.c && ./workq
queued, running later:
log: suspend requested
flush sda: 128 blocks
flush nvme0n1: 4096 blocks
ran 3 works
큐를 비우는 run_pending()은 각 작업이 무엇을 하는지 모르고 func만 부른다. 인터럽트 핸들러처럼 오래 걸리는 일을 할 수 없는 문맥에서 등록만 하고, 실행은 나중에 미루는 용도로 쓰인다.
std::function과 커맨드 인터페이스
| 필요한 것 | std::function<void()> | 커맨드 클래스 |
|---|---|---|
| 나중에 실행, 큐잉 | 충분 | 가능 |
| 취소(undo) | 쌍으로 따로 관리해야 함 | 메서드로 함께 |
| 병합, 이름 표시 | 불가 | 가능 |
| 직렬화·재생 | 불가(람다는 내용을 알 수 없음) | 필드를 직렬화 |
주의사항
- undo에 필요한 상태는 execute 때 저장:
Erase는 지운 문자열을 실행 시점에 보관한다. 생성 시점에 저장하면 그 사이에 문서가 바뀌어 틀린 값을 복원한다. - Receiver의 수명: 큐에 들어간 커맨드가 참조하는 객체는 실행될 때까지 살아 있어야 한다. 람다가
this나 지역 변수를 참조로 캡처하면 특히 위험하다. - 병합 규칙은 좁게: 병합 조건이 넓으면 사용자가 기대한 것보다 많이 되돌아간다. 예제처럼 연속 위치, 같은 종류, 단어 경계 같은 조건을 명확히 둔다.
- 재생 가능한 커맨드는 결정적이어야: 현재 시각, 난수, 외부 상태를 실행 중에 읽으면 재생 결과가 달라진다. 이런 값은 커맨드를 만들 때 필드로 고정한다.
- 작업 큐의 종료: 예제의 소멸자는 종료 신호를 넣고 남은 작업을 모두 처리한 뒤 끝난다. 즉시 중단이 필요하면 큐를 비우는 별도 정책이 필요하다.
마무리
커맨드는 “호출”을 “값”으로 바꾼다. 값이 되면 저장하고, 미루고, 되돌리고, 합치고, 다시 실행할 수 있다. 다음 글에서는 컬렉션의 내부 구조를 드러내지 않고 원소를 순회하는 반복자(Iterator)를 GoF 관점에서 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Command
- Linux kernel docs — Workqueue
- cppreference — std::function