OpenCut 탐구 4 — Command 패턴, 함수 호출을 주문서로 바꾸면 생기는 일
에디터에서 Ctrl+Z는 공기 같은 기능입니다. 없으면 그제야 소중함을 알죠. 3편에서 모든 편집이 "Command를 거친다"는 말을 반복했는데, 이번 편에서는 그 Command 패턴 자체를 바닥부터 이해하고, OpenCut의 실제 구현을 뜯어봅니다.
핵심 아이디어는 한 문장입니다. 요청(행위)을 객체로 캡슐화한다. 식당에서 구두로 "김치찌개 하나요"라고 말하는 것이 일반 함수 호출이라면, Command 패턴은 주문서에 적어서 넘기는 것입니다. 말은 뱉는 순간 사라지지만 주문서는 남습니다. 남아 있으니 취소할 수 있고, 같은 주문을 다시 넣을 수 있고, 여러 장을 묶어 한 번에 처리할 수도 있습니다.
- 저장 — 나중에 실행할 수 있음
- 취소 — 되돌릴 수 있음
- 재실행 — 다시 할 수 있음
- 조합 — 여러 명령을 묶을 수 있음
구조 — 주문서가 오가는 세 자리
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ Invoker │────▶│ Command │────▶│ Receiver │
│ (호출자) │ │ (주문서) │ │ (실제 수행자) │
└─────────────┘ └───────────────┘ └──────────────┘
CommandManager InsertElement... EditorCore
- execute() - execute() - timeline
- undo() - undo() - updateTracks()
- redo() - redo()
- Command — "무엇을 할지"를 담은 객체.
execute()와undo()를 구현 - Invoker — 주문서를 접수하고 히스토리를 관리하는 카운터
- Receiver — 주문서대로 실제 일을 하는 주방
미니 구현 — 텍스트 에디터로 익히기
OpenCut과 동일한 구조를 가장 단순한 형태로 만들어 보면 이렇습니다.
// 1. 추상 Command — 주문서 양식
abstract class Command {
abstract execute(): void;
undo(): void {
throw new Error("Undo not implemented");
}
redo(): void {
this.execute(); // 대부분 redo = 재실행이면 충분
}
}
// 2. 구체 Command — 실행 전 상태를 백업해 두고, undo 때 복원
class InsertTextCommand extends Command {
private savedContent: string | null = null;
constructor(
private editor: TextEditor,
private position: number,
private text: string
) {
super();
}
execute(): void {
this.savedContent = this.editor.getContent(); // ← 스냅샷
this.editor.insertAt(this.position, this.text);
}
undo(): void {
if (this.savedContent !== null) {
this.editor.setContent(this.savedContent); // ← 복원
}
}
}
// 3. Invoker — 두 개의 스택으로 히스토리 관리
class CommandManager {
private history: Command[] = [];
private redoStack: Command[] = [];
execute(command: Command): void {
command.execute();
this.history.push(command);
this.redoStack = []; // 새 작업이 들어오면 redo 경로는 폐기
}
undo(): void {
const command = this.history.pop();
if (!command) return;
command.undo();
this.redoStack.push(command);
}
redo(): void {
const command = this.redoStack.pop();
if (!command) return;
command.redo();
this.history.push(command);
}
}
manager.execute(new InsertTextCommand(editor, 0, "Hello"));
manager.execute(new InsertTextCommand(editor, 5, " World"));
// 현재: "Hello World"
manager.undo(); // → "Hello"
manager.undo(); // → ""
manager.redo(); // → "Hello"
이 장난감 구현이 OpenCut의 CommandManager와 정확히 동일한 구조입니다.
OpenCut의 실전 구현 — 알아야 할 여섯 가지
OpenCut은 GoF의 커맨드 패턴을 정석에 가깝게 3계층으로 구현합니다.
| 계층 | 파일 | 역할 |
|---|---|---|
| 추상 인터페이스 | base-command.ts |
execute(), undo(), redo() 계약 |
| 구체 커맨드 | timeline/, media/, scene/ 등 51개 파일 |
도메인별 변경 로직 |
| 호출자(Invoker) | commands.ts (CommandManager) |
실행 + 히스토리 관리 |
① 두 개의 스택. history(undo 대상)와 redoStack(redo 대상)의 상호작용이 시스템 전체의 뼈대입니다.
② execute vs push — 두 가지 접수 방식.
execute({ command }: { command: Command }): Command {
command.execute(); // 실행하고
this.history.push(command); // 기록
this.redoStack = [];
return command;
}
push({ command }: { command: Command }): void {
this.history.push(command); // 실행 없이 기록만
this.redoStack = [];
}
push가 왜 필요할까요? 3편의 Preview 시스템을 떠올리면 됩니다. 드래그 중 이미 화면에 실시간 반영된 변경은 다시 실행하면 안 되고, Undo만 가능하면 됩니다. 이미 조리까지 끝난 요리의 주문서를 장부에만 끼워 넣는 셈입니다.
③ 새 커맨드가 오면 redoStack을 비운다. "분기 히스토리를 허용하지 않는다"는 정책입니다. Undo 후 새 작업을 하면 이전 Redo 경로는 영구 삭제 — Photoshop, VSCode 등 대부분의 에디터가 같은 전략을 씁니다.
④ Snapshot 기반 Undo. 구체 커맨드들은 execute() 시작 시 현재 상태를 통째로 저장하고, undo() 때 그대로 복원합니다. 역연산(inverse operation)을 일일이 계산하는 것보다 구현이 단순하고 버그가 적습니다.
⑤ 기본값의 설계. 추상 클래스에서 undo()의 기본 구현은 throw new Error(...)입니다. "Undo 불가능한 커맨드"를 침묵이 아니라 명시적 에러로 처리하겠다는 선언입니다. redo()의 기본값은 execute() 재호출 — 대부분의 경우에 맞는 합리적 기본값입니다.
⑥ BatchCommand — 원자적 묶음.
export class BatchCommand extends Command {
constructor(private commands: Command[]) { super(); }
execute(): void {
for (const command of this.commands) command.execute();
}
undo(): void {
// undo는 반드시 역순
for (const command of [...this.commands].reverse()) command.undo();
}
}
여러 커맨드를 하나의 Undo 단위로 묶습니다(Composite 패턴). 5개 클립을 동시에 옮겼다면 Ctrl+Z 한 번에 5개가 함께 돌아와야 하니까요. undo가 역순인 것에 주목하세요 — 커맨드 간 의존이 있을 때 순서대로 되돌리면 깨집니다.
언제 쓰고, 언제 쓰지 말아야 하나
| 써야 하는 경우 | 이유 |
|---|---|
| Undo/Redo가 필요한 에디터 | 포토샵, 피그마, VSCode 전부 이 패턴 |
| 트랜잭션성이 필요할 때 | 전부 성공 또는 전부 취소 (BatchCommand) |
| 실행을 지연/큐잉해야 할 때 | 명령을 쌓아두고 특정 시점에 순차 실행 |
실무에서는 에디터 말고도 게임 리플레이(입력을 커맨드로 기록해 재생), 매크로 기록, 작업 큐/스케줄러(실패 시 롤백)에서 자주 만납니다.
반대로 되돌릴 필요 없는 단순 CRUD, 단방향 데이터 흐름으로 충분한 일반 상태 관리라면 과잉 설계입니다. 함수 호출은 순간적이고 사라지지만, Command 객체는 행위가 데이터로 남습니다. 행위를 데이터로 남길 이유가 없다면 쓸 이유도 없습니다.
더 깊이: Snapshot 방식의 비용과 대안
OpenCut의 스냅샷 Undo는 단순하지만 공짜가 아닙니다. 커맨드마다 트랙 배열 전체를 백업하므로 히스토리가 길어질수록 메모리가 비례해서 늘어납니다. 현재 히스토리 크기 제한이 없어서, 대규모 프로젝트에서는 상한선(오래된 커맨드부터 버리는 ring buffer)이 필요해질 수 있습니다.
대안의 스펙트럼은 이렇습니다.
- 역연산(inverse operation) — "삽입"의 undo는 "삭제"처럼 반대 연산만 기록. 메모리는 최소지만, 모든 커맨드의 역연산을 정확히 구현해야 해서 버그 표면적이 넓습니다.
- 구조적 공유(structural sharing) — 상태를 불변 자료구조로 관리하면 스냅샷이 "바뀐 부분만" 새로 만들고 나머지는 참조를 공유합니다. OpenCut도 트랙 배열을 스프레드로 복사하는 불변 업데이트를 쓰므로, 스냅샷의 실제 비용은 "전체 깊은 복사"보다는 훨씬 작습니다.
- diff 기반 — 변경 전후의 차이만 저장. 텍스트 에디터(CodeMirror 등)에서 흔한 방식입니다.
정답은 없고, "구현 단순성 ↔ 메모리 효율"의 트레이드오프에서 어디에 설지의 문제입니다. 편집 상태가 수 MB를 넘지 않는 비디오 에디터의 메타데이터라면, 스냅샷의 단순함이 이기는 선택일 때가 많습니다.
한 줄 요약 — Command 패턴의 본질은 행위를 데이터로 만드는 것이고, 그 순간 저장·취소·재실행·조합이 공짜로 따라온다. OpenCut은 두 스택 + 스냅샷 + BatchCommand라는 가장 검증된 조합으로 이를 구현했다.
스스로 점검할 질문
execute와push의 차이는 무엇이고, 드래그 인터랙션에서 왜push가 필요한가요?- BatchCommand의 undo가 역순이어야 하는 상황을 구체적인 예로 만들어 볼 수 있나요?
- 여러분이 만드는 기능에 Undo를 붙인다면 스냅샷과 역연산 중 무엇을 고르겠습니까? 판단 기준은요?
참고 링크