From a2b8c7e6585015561b54708118bbdbfddd69ea89 Mon Sep 17 00:00:00 2001 From: samuel Date: Tue, 31 Mar 2026 20:56:14 +0900 Subject: [PATCH] docs: adapter & facade --- ...0_\355\215\274\354\202\254\353\223\234.md" | 1330 +++++++++++++++++ 1 file changed, 1330 insertions(+) create mode 100644 "ryongtai/07.\354\226\264\353\214\221\355\204\260_\355\215\274\354\202\254\353\223\234.md" diff --git "a/ryongtai/07.\354\226\264\353\214\221\355\204\260_\355\215\274\354\202\254\353\223\234.md" "b/ryongtai/07.\354\226\264\353\214\221\355\204\260_\355\215\274\354\202\254\353\223\234.md" new file mode 100644 index 0000000..d1c7a7f --- /dev/null +++ "b/ryongtai/07.\354\226\264\353\214\221\355\204\260_\355\215\274\354\202\254\353\223\234.md" @@ -0,0 +1,1330 @@ +# 커맨드 패턴 + +## 커맨드 패턴이란? + +### 1. 커맨트 패턴이 해결하려는 근본 문제 : “요청의 객체화” + +> 커맨드 패턴은 ***“요청(명령) 자체를 객체로 만듬”*** +> + +보통 프로그래밍에서 “무언가를 실행한다”는 것은 메서드로 호출하는 것. +→ 호출은 즉시 실행되고, 실행되면 끝. + +하지만 실무에서는 실행 자체를 나중으로 미루거나, 큐에 넣거나, 되돌리거나, 기록해야 하는 상황이 존재 + +매서드 호출은 “행위” +→ 저장할 수 없고, 큐에 넣을 수 없고, 되돌릴 수 없음 + +하지만, 객체는 +→ 저장할 수 있고, 전달할 수 있고, 큐에 넣을 수 있고, 로그로 남길 수 있음 + +즉, 커맨드 패턴은 바로 이 ***“실행할 행위를 객체로 캡슐화 하는 것”*** + +### 2. 커맨드 패턴이 없다면? : 호출자와 실행자의 강결합 + +리모컨 예제 + +```java +class RemoteControl { + private light: Light; + private fan: CeilingFan; + private aircon: AirConditioner; + private audio: AudioSystem; + + pressButton(slot: number) { + if (slot === 0) { + this.light.on(); + } else if (slot === 1) { + this.fan.high(); + } else if (slot === 2) { + this.aircon.setTemperature(24); + this.aircon.on(); + } else if (slot === 3) { + this.audio.setVolume(50); + this.audio.play(); + } + // ... 슬롯이 추가될 때마다 분기 추가 + } +} +``` + +문제는.. + +1. 리모컨이 모든 가전제품의 구체적인 동작 방식을 알고 있음 + 1. 호출자(invoker)가 각 가전제품이라는 실행자(Receiver)의 세부 구현에 강하게 결합되어 있음. +2. 새 가전제품을 추가할 때마다 리모컨 코드를 수정 + 1. 새 가전제품이 추가되면 분기를 추가 → 전략 패턴에서처럼 OCP 위반 +3. 실행 취소(Undo)가 극도로 어려움 + 1. 가전 제품마다 실행 취소 방식이 다름 → `if-else` 지옥 +4. 매크로(여러 동작을 한 번에 실행)를 구현하기 어려움 + +> 즉, 문제는.. +→ *“무엇을 실행할 것인가”*라는 정보가 리모컨의 코드 안에 있다는 것 +→ 실행할 행위가 코드로만 존재한다는 것 +> + +### 3. 커맨드 패턴의 핵심 통찰 : “행위를 객체로 만들자” + +> ***"메서드 호출을 객체로 감싸면, 그 호출을 저장하고, 전달하고, 큐에 넣고, 되돌릴 수 있다."*** +> + +예시를 들면, + +- 직접 메서드 호출 + - 손님이 직접 주방에 가서 “파스타 만들어주세요”라고 주문 +- 커맨드 객체 방식 + - 주문서 작성 → 웨이터가 주문서를 주방에 전달 + - 웨이터는 조리법을 모름, 주방장은 주문자를 모름 + - 주문서가 둘 사이를 분리 + +> 이 **주문서**가 바로 커맨드 객체! +*→ 주문서는 물리적 객체이므로 순서대로 쌓을 수 있고 (큐) +→ 취소 도장을 찍을 수 있고 (Undo) +→ 기록으로 남길 수 있음 (로깅)* +> + +### 4. 커맨드 패턴의 구조적 해결 + +**커맨드 패턴의 역할** + +- **Command(인터페이스)** + - “실행할 수 있는 것”이라는 계약 + - `execute()`와 선택적으로 `undo()`를 정의 +- **ConcreteCommand(구현체)** + - 특정 Receiver의 특정 동작을 캡슐화한 객체 + - “이 조명을 켜라”라는 구체적 명령 +- **Receiver(수신자)** + - 실제 작업을 수행하는 객체 (가전제품) +- **Invoker(호출자)** + - 커맨드를 저장하고 실행을 촉발하는 객체 (리모컨) + - Receiver가 누군지 전혀 모름 + +```java +// Command 인터페이스 +interface Command { + execute(): void; + undo(): void; +} + +// Receiver들 — 실제 동작을 수행하는 객체들 +class Light { + constructor(private location: string) {} + on() { console.log(`${this.location} 조명 켜짐`); } + off() { console.log(`${this.location} 조명 꺼짐`); } +} + +class CeilingFan { + private speed = 0; + + constructor(private location: string) {} + + high() { this.speed = 3; console.log(`${this.location} 선풍기 강풍`); } + medium() { this.speed = 2; console.log(`${this.location} 선풍기 중풍`); } + low() { this.speed = 1; console.log(`${this.location} 선풍기 약풍`); } + off() { this.speed = 0; console.log(`${this.location} 선풍기 꺼짐`); } + getSpeed() { return this.speed; } +} + +// ConcreteCommand들 — 행위를 객체로 캡슐화 +class LightOnCommand implements Command { + constructor(private light: Light) {} + execute() { this.light.on(); } + undo() { this.light.off(); } // 켜기의 반대는 끄기 +} + +class LightOffCommand implements Command { + constructor(private light: Light) {} + execute() { this.light.off(); } + undo() { this.light.on(); } // 끄기의 반대는 켜기 +} + +class CeilingFanHighCommand implements Command { + private prevSpeed = 0; + + constructor(private fan: CeilingFan) {} + + execute() { + this.prevSpeed = this.fan.getSpeed(); // 이전 상태 저장 + this.fan.high(); + } + + undo() { + // 이전 속도로 복원 — 커맨드 객체가 상태를 기억 + switch (this.prevSpeed) { + case 3: this.fan.high(); break; + case 2: this.fan.medium(); break; + case 1: this.fan.low(); break; + case 0: this.fan.off(); break; + } + } +} + +// NoCommand — 널 객체 패턴 (빈 슬롯을 위한 기본 커맨드) +class NoCommand implements Command { + execute() {} + undo() {} +} + +// Invoker — 커맨드를 저장하고 실행만 촉발 +class RemoteControl { + private onCommands: Command[]; + private offCommands: Command[]; + private lastCommand: Command; + + constructor(private slotCount: number) { + const noCommand = new NoCommand(); + this.onCommands = Array(slotCount).fill(noCommand); + this.offCommands = Array(slotCount).fill(noCommand); + this.lastCommand = noCommand; + } + + setCommand(slot: number, onCommand: Command, offCommand: Command) { + this.onCommands[slot] = onCommand; + this.offCommands[slot] = offCommand; + } + + pressOn(slot: number) { + this.onCommands[slot].execute(); + this.lastCommand = this.onCommands[slot]; + } + + pressOff(slot: number) { + this.offCommands[slot].execute(); + this.lastCommand = this.offCommands[slot]; + } + + pressUndo() { + this.lastCommand.undo(); + } +} +``` + +- `RemoteControl`은 + - `Light`도 `CeilingFan`도 전혀 모름 + - `Command` 인터페이스만 앎 + - `execute()`를 호출할 뿐, 그 안에서 무슨 일이 일어나는지 관심 x + +```java +// 사용 +const remote = new RemoteControl(3); + +const livingRoomLight = new Light("거실"); +const bedroomFan = new CeilingFan("침실"); + +remote.setCommand(0, + new LightOnCommand(livingRoomLight), + new LightOffCommand(livingRoomLight), +); + +remote.setCommand(1, + new CeilingFanHighCommand(bedroomFan), + new CeilingFanOffCommand(bedroomFan), +); + +remote.pressOn(0); // "거실 조명 켜짐" +remote.pressOn(1); // "침실 선풍기 강풍" +remote.pressUndo(); // 선풍기가 이전 상태로 복원 +``` + +새 가전제품을 추가하려면? +→ 새 Command 클래스를 만들고, `setCommand()`로 등록하면 된다 +→ `RemoteControl`은 한 글자도 수정하지 않음 + +### 5. 매크로 커맨드 : 커맨드의 조합 + +커맨드는 객체라서 여러 커맨드를 하나의 커맨드로 묶는 것이 가능 + +```java +class MacroCommand implements Command { + constructor(private commands: Command[]) {} + + execute() { + this.commands.forEach(cmd => cmd.execute()); + } + + undo() { + // 역순으로 되돌림 + [...this.commands].reverse().forEach(cmd => cmd.undo()); + } +} + +// "외출 모드" 매크로: 조명 끄기 + 선풍기 끄기 + 에어컨 끄기 +const goOutMacro = new MacroCommand([ + new LightOffCommand(livingRoomLight), + new CeilingFanOffCommand(bedroomFan), + new AirconOffCommand(aircon), +]); + +remote.setCommand(6, goInMacro, goOutMacro); +remote.pressOff(6); // 세 가지가 한꺼번에 실행 +remote.pressUndo(); // 세 가지가 역순으로 되돌려짐 +``` + + + +### 6. 커맨드 패턴이 열어주는 가능성들 + +**큐잉(Queuing)** + +- 커맨드 객체를 큐에 넣으면 순서대로 실행 가능 + - 작업 큐, 태스크 스케쥴러가 이 원리 + +**로깅/감사(Logging/Audit)** + +- 커맨드 객체를 실행 전에 직렬화해서 저장하면, 시스템 장애 후 저장된 커맨드들을 순서대로 재실행하여 상태 복구 가능 + - 데이터베이스 트랜잭션 로그 + +**실행 취소/다시 실행(Undo/Redo)** + +- 실행한 커맨드를 스택에 쌓아두면, 스택을 pop하면서 `undo()`를 호출해서 되돌릴 수 있음. + - 텍스트 에디터의 `Ctrl + Z` + +**트랜잭션** + +- 여러 커맨드를 묶어서, 모두 성공하면 확정하고 하나라도 실패하면 전부 되돌리는 트랜잭션 구현 가능 + +> 이 것들은 행위가 메서드 호출이라는 휘발적인 형태가 아니라, +**객체라는 지속적인 형태로 존재하기 때문에 가능**! +> + +### (부록) 전략 패턴과의 비교 + +> 둘 다 인터페이스를 통해 행위를 캡슐화하고, 외부에서 주입 +→ 하지만 **의도와 사용 맥락이 다르다!** +> + +- 전략 패턴 + - 같은 목적을 달성하는 여러 알고리즘 중 하나를 선택 + - 정렬이라는 **“목적”**은 같지만 **“방법”**이 다름 + - 전략은 보통 **교체**를 위해 존재 +- 커맨드 패턴 + - ***실행할 작업 자체를 객체로 포장*** + - 각 작업들은 목적도 방법도 전혀 다름 (조명켜기, 온도설정, 파일저장) + - 커맨드는 **저장, 큐잉, 되돌리기**를 위해 존재 + +**전략 패턴**의 초점이 **“어떻게(how)”**라면, +**커맨드 패턴**의 초점은 **“무엇을(what)”**임 + +**전략**은 *알고리즘의 교체 가능성*에 관심이 있고, +**커맨드**는 ***작업 자체의 일급 객체화***에 관심이 있음 + +### (부록) 앞선 패턴들과의 관계 + +- 전략 패턴 + - “행위의 캡슐화”라는 공통점은 있지만 + - 전략은 교체를 위해, + - 커맨드는 객체화를 위해 캡슐화 +- 옵저버 + - “분리”라는 공통점 + - Subject/Observer vs Invoker/Receiver + - 옵저버의 분리는 1:N 통지를 위해, + - 커맨드는 행위의 지연/저장/되돌리기를 위해 +- 데코레이터 + - 매크로 커맨드는 데코레이터의 중첩과 유사한 구조 +- 팩토리 + - 둘이 결합해 커맨드 객체의 생성을 팩토리에 위임 가능 + +### 마무리… + +> 커맨드 패턴은 *행위를 “일급 시민”으로 만든다* +> + +프로그래밍에서 “일급 시민”이란 + +- 변수에 할당할 수 있고, +- 함수의 인자로 전달할 수 있고, +- 함수의 반환값이 될 수 있음 + +전통적인 OOP에서 “메서드 호출”은 일급 시민이 아님 +→ *커맨드 패턴은 **메서드 호출을 객체로 감싸서 일급 시민으로 승격** 시킴* + +JS에서는 함수 자체가 일급 시민이므로, +이 “승격”이 언어 수준에서 이미 이루어져 있음 + +→ *프론트엔드에서 커맨드 패턴이 전통적인 클래스 기반 형태 대신 +함수, 액션 객체, 이벤트라는 형태로 나타나는 이유* + +## 실무 적용 - 전통적 프로그래밍 관점 + +### 커맨드 패턴이 없다면,,,? - Undo가 없다면.. + +텍스트 에디터 예시 + +```java +class TextEditor { + private StringBuilder content = new StringBuilder(); + + void insertText(int position, String text) { + content.insert(position, text); + } + + void deleteText(int position, int length) { + content.delete(position, position + length); + } + + void replaceText(int position, int length, String newText) { + content.replace(position, position + length, newText); + } + + // Undo를 구현하려면? + // 💣 "어떤 메서드가 어떤 인자로 호출됐는지"를 기억해야 함 + // 💣 "되돌리려면 어떤 메서드를 어떤 인자로 호출해야 하는지"도 알아야 함 + // 💣 삭제를 되돌리려면 삭제된 텍스트를 어딘가에 저장해두어야 함 + // → 이 모든 것을 에디터 클래스 안에서 관리하면 폭발적으로 복잡해짐 +} +``` + +- Undo가 어려운 근본적 이유는, +→ 메서드 호출은 실행되면 사라지기 때문 +- `deleteText(5, 3)`이 호출되면 텍스트가 삭제되지만, “5번 위치에서 3글자를 삭제 했다”는 사실과 “삭제된 글자가 무엇이었는가”라는 정보는 어디에도 남지 않음 + +### 1. 적용 : 모든 작업을 객체로 기록 + +```java +// Command 인터페이스 +interface EditorCommand { + void execute(); + void undo(); + String describe(); // 로깅/감사용 +} + +// Receiver — 실제 텍스트 조작을 수행 +class TextBuffer { + private StringBuilder content = new StringBuilder(); + + void insert(int position, String text) { + content.insert(position, text); + } + + void delete(int position, int length) { + content.delete(position, position + length); + } + + String getSubstring(int position, int length) { + return content.substring(position, position + length); + } + + String getContent() { return content.toString(); } +} + +// ConcreteCommand — 삽입 명령 +class InsertCommand implements EditorCommand { + private final TextBuffer buffer; + private final int position; + private final String text; + + InsertCommand(TextBuffer buffer, int position, String text) { + this.buffer = buffer; + this.position = position; + this.text = text; + } + + void execute() { + buffer.insert(position, text); + } + + void undo() { + // 삽입의 반대는 삭제 + buffer.delete(position, text.length()); + } + + String describe() { + return String.format("INSERT '%s' at position %d", text, position); + } +} + +// ConcreteCommand — 삭제 명령 +class DeleteCommand implements EditorCommand { + private final TextBuffer buffer; + private final int position; + private final int length; + private String deletedText; // 되돌리기 위해 삭제된 텍스트를 기억 + + DeleteCommand(TextBuffer buffer, int position, int length) { + this.buffer = buffer; + this.position = position; + this.length = length; + } + + void execute() { + // 삭제 전에 텍스트를 저장해둠 — Undo의 핵심 + this.deletedText = buffer.getSubstring(position, length); + buffer.delete(position, length); + } + + void undo() { + // 저장해둔 텍스트를 원래 위치에 복원 + buffer.insert(position, deletedText); + } + + String describe() { + return String.format("DELETE %d chars '%s' at position %d", + length, deletedText, position); + } +} + +// Invoker — 커맨드 실행과 히스토리 관리 +class EditorInvoker { + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + + void executeCommand(EditorCommand command) { + command.execute(); + undoStack.push(command); + redoStack.clear(); // 새 작업 실행 시 redo 히스토리 초기화 + } + + void undo() { + if (undoStack.isEmpty()) return; + EditorCommand command = undoStack.pop(); + command.undo(); + redoStack.push(command); // redo를 위해 보관 + } + + void redo() { + if (redoStack.isEmpty()) return; + EditorCommand command = redoStack.pop(); + command.execute(); + undoStack.push(command); + } + + List getHistory() { + return undoStack.stream() + .map(EditorCommand::describe) + .collect(Collectors.toList()); + } +} +``` + +사용하면, + +```java +TextBuffer buffer = new TextBuffer(); +EditorInvoker editor = new EditorInvoker(); + +editor.executeCommand(new InsertCommand(buffer, 0, "Hello")); +// buffer: "Hello" + +editor.executeCommand(new InsertCommand(buffer, 5, " World")); +// buffer: "Hello World" + +editor.executeCommand(new DeleteCommand(buffer, 5, 6)); +// buffer: "Hello" + +editor.undo(); // DeleteCommand.undo() → "Hello World" +editor.undo(); // InsertCommand.undo() → "Hello" +editor.redo(); // InsertCommand.execute() → "Hello World" + +editor.getHistory(); +// ["INSERT 'Hello' at position 0"] +``` + +- `EditorInvoker`는 `TextBuffer`의 존재를 전혀 모름 +- `EditorCommand`라는 인터페이스만 알고, `execute()`와 `undo()`만 호출 +- 새로운 동작(치환, 서식 변경 등)을 추가해도 Invoker는 수정하지 않음 + +### 2. 작업 큐 - 커맨드를 지연 실행 + +생성 시점과 실행 시점을 분리 +→ **작업 큐(Job Queue)**의 핵심! + +```java +interface Job extends Command { + String getJobId(); + int getPriority(); +} + +class SendEmailJob implements Job { + private final EmailService emailService; + private final String to; + private final String subject; + private final String body; + + // 생성 시점: 실행에 필요한 모든 정보를 캡슐화 + SendEmailJob(EmailService emailService, String to, String subject, String body) { + this.emailService = emailService; + this.to = to; + this.subject = subject; + this.body = body; + } + + public void execute() { + // 실행 시점: 나중에 워커가 꺼내서 실행 + emailService.send(to, subject, body); + } + + public String getJobId() { return "email-" + UUID.randomUUID(); } + public int getPriority() { return 5; } +} + +class ResizeImageJob implements Job { + public void execute() { + // 이미지 리사이즈 — 이메일과 전혀 다른 작업 + } + // ... +} + +// 작업 큐 — 어떤 Job이든 상관없이 execute()만 호출 +class JobQueue { + private final PriorityQueue queue; + private final ExecutorService workers; + + void enqueue(Job job) { + queue.offer(job); + } + + void processNext() { + Job job = queue.poll(); + if (job != null) { + workers.submit(() -> { + try { + job.execute(); // 무슨 작업인지 모름, 그냥 실행 + } catch (Exception e) { + handleFailure(job, e); + } + }); + } + } +} +``` + +- `JobQueue` 는 이메일을 보내는 것인지, 이미지를 처리하는 것인지 전혀 모름 +- `execute()`를 호출할 수 있는 객체를 순서대로 처리할 뿐… +- 새로운 종류의 작업이 추가되어도 큐 코드는 수정하지 않음 + +### 3. 트랜잭션 - 전부 성공하거나 전부 되돌리거나 + +```java +class TransactionCommand implements Command { + private final List commands; + private final List executedCommands = new ArrayList<>(); + + TransactionCommand(List commands) { + this.commands = commands; + } + + void execute() { + try { + for (Command cmd : commands) { + cmd.execute(); + executedCommands.add(cmd); + } + } catch (Exception e) { + // 하나라도 실패하면 실행된 것들을 역순으로 되돌림 + Collections.reverse(executedCommands); + for (Command cmd : executedCommands) { + cmd.undo(); + } + throw new TransactionFailedException(e); + } + } + + void undo() { + List reversed = new ArrayList<>(executedCommands); + Collections.reverse(reversed); + for (Command cmd : reversed) { + cmd.undo(); + } + } +} + +// 주문 처리 트랜잭션: 전부 성공하거나 전부 되돌리거나 +Command orderTransaction = new TransactionCommand(List.of( + new DeductInventoryCommand(inventory, items), + new ChargePaymentCommand(paymentGateway, amount), + new CreateOrderCommand(orderRepository, orderData), + new SendConfirmationCommand(emailService, userEmail), +)); + +orderTransaction.execute(); +// 결제 실패 시 → 재고 감소가 자동으로 되돌려짐 +``` + +## 실무 적용 - 프론트엔드 + +### 프론트엔드에서 커맨드 패턴.. + +이미 JS 함수가 그 역할을 함.. + +하지만, 함수만으로는 부족한 경우가 있음. +*되돌리기 위한 상태 보존, +직렬화 가능한 형태의 작업 기술, +작업 히스토리의 추적이 필요할 때,* +→ 단순한 함수 호출을 넘어서 **커맨드 패턴의 구조**가 필요! + +주로, Redux의 Action과 에디터의 Undo/Redo + +### 1. Redux Action + +Redux의 Action은 커맨드 패턴의 직접적인 구현 +→ 이것을 인식하면 Redux의 설계 철학이 훨씬 명확! + +```tsx +// Action = Command 객체 +// "무엇을 할 것인가"를 객체로 기술 +type Action = + | { type: 'todo/added'; payload: { id: string; text: string } } + | { type: 'todo/toggled'; payload: { id: string } } + | { type: 'todo/deleted'; payload: { id: string } } + | { type: 'todo/reordered'; payload: { fromIndex: number; toIndex: number } }; + +// Reducer = Receiver의 execute() 로직 +// Action을 받아서 실제 상태 변경을 수행 +function todoReducer(state: TodoState, action: Action): TodoState { + switch (action.type) { + case 'todo/added': + return { + ...state, + todos: [...state.todos, { + id: action.payload.id, + text: action.payload.text, + completed: false, + }], + }; + case 'todo/toggled': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, completed: !todo.completed } + : todo + ), + }; + case 'todo/deleted': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload.id), + }; + default: + return state; + } +} + +// dispatch = Invoker의 executeCommand() +// Action 객체를 받아서 실행을 촉발 +store.dispatch({ type: 'todo/added', payload: { id: '1', text: '우유 사기' } }); +store.dispatch({ type: 'todo/toggled', payload: { id: '1' } }); +``` + +커맨드 패턴의 4요소에 대응시키면,, + +- Command + - Action 객체(`{ type, payload }`) + - 실행할 작업을 데이터로 기술 +- Receiver + - Reducer + - Action을 받아서 실제 상태 변경을 수행 +- Invoker + - `store.dispatch()` + - Action을 받아서 Reducer에 전달 + - Action이 무엇인지 모르고, 그냥 전달만 함 +- Client + - 컴포넌트, Hook + - Action 객체를 생성해서 dispatch에 넘김 + +> Redux가 Action을 **순수 객체(plain object)**로 설계한 이유! +*→ 객체이기 때문에 직렬화할 수 있고(`JSON.stringify`), +→ 기록할 수 있고 (Redux DevTools의 Action 히스토리) +→ 네트워크를 통해 전송할 수 있고(서버에 Action 로그를 보내서 디버깅) +→ 되돌릴 수 있음 (time-travel debugging)* +> + +```tsx +// Redux DevTools가 가능한 이유 = Action이 직렬화 가능한 커맨드 객체이기 때문 +// 모든 Action이 순서대로 기록됨 +[ + { type: 'todo/added', payload: { id: '1', text: '우유 사기' } }, + { type: 'todo/toggled', payload: { id: '1' } }, + { type: 'todo/added', payload: { id: '2', text: '빵 사기' } }, +] +// → 이 목록을 역순으로 "되돌리면" 시간 여행 디버깅 +// → 이 목록을 저장하면 세션 복구 +// → 이 목록을 다른 클라이언트에 보내면 상태 동기화 +``` + +### 2. Undo/Redo 시스템 + +에디터, 디자인 툴, 스트레드 시트 등.. + +```tsx +// Command 인터페이스 +interface EditorCommand { + execute(): void; + undo(): void; + description: string; +} + +// 커맨드 히스토리 매니저 +class CommandHistory { + private undoStack: EditorCommand[] = []; + private redoStack: EditorCommand[] = []; + + execute(command: EditorCommand) { + command.execute(); + this.undoStack.push(command); + this.redoStack = []; // 새 작업 시 redo 초기화 + } + + undo(): string | null { + const command = this.undoStack.pop(); + if (!command) return null; + command.undo(); + this.redoStack.push(command); + return command.description; + } + + redo(): string | null { + const command = this.redoStack.pop(); + if (!command) return null; + command.execute(); + this.undoStack.push(command); + return command.description; + } + + get canUndo() { return this.undoStack.length > 0; } + get canRedo() { return this.redoStack.length > 0; } +} + +// 캔버스 드로잉 앱의 구체적인 커맨드들 +interface Shape { + id: string; + type: string; + x: number; + y: number; + width: number; + height: number; + color: string; +} + +class CanvasState { + shapes: Shape[] = []; + + addShape(shape: Shape) { this.shapes.push(shape); } + removeShape(id: string) { this.shapes = this.shapes.filter(s => s.id !== id); } + updateShape(id: string, updates: Partial) { + this.shapes = this.shapes.map(s => s.id === id ? { ...s, ...updates } : s); + } + getShape(id: string) { return this.shapes.find(s => s.id === id); } +} + +// 도형 추가 커맨드 +class AddShapeCommand implements EditorCommand { + description: string; + + constructor(private canvas: CanvasState, private shape: Shape) { + this.description = `Add ${shape.type}`; + } + + execute() { this.canvas.addShape(this.shape); } + undo() { this.canvas.removeShape(this.shape.id); } +} + +// 도형 이동 커맨드 — 이전 위치를 기억해야 함 +class MoveShapeCommand implements EditorCommand { + description: string; + private previousX: number; + private previousY: number; + + constructor( + private canvas: CanvasState, + private shapeId: string, + private newX: number, + private newY: number, + ) { + const shape = canvas.getShape(shapeId)!; + this.previousX = shape.x; + this.previousY = shape.y; + this.description = `Move ${shape.type} to (${newX}, ${newY})`; + } + + execute() { this.canvas.updateShape(this.shapeId, { x: this.newX, y: this.newY }); } + undo() { this.canvas.updateShape(this.shapeId, { x: this.previousX, y: this.previousY }); } +} + +// 색상 변경 커맨드 +class ChangeColorCommand implements EditorCommand { + description: string; + private previousColor: string; + + constructor( + private canvas: CanvasState, + private shapeId: string, + private newColor: string, + ) { + const shape = canvas.getShape(shapeId)!; + this.previousColor = shape.color; + this.description = `Change color to ${newColor}`; + } + + execute() { this.canvas.updateShape(this.shapeId, { color: this.newColor }); } + undo() { this.canvas.updateShape(this.shapeId, { color: this.previousColor }); } +} +``` + +사용, + +```tsx +function useCommandHistory() { + const historyRef = useRef(new CommandHistory()); + const [, forceUpdate] = useReducer(x => x + 1, 0); + + const execute = useCallback((command: EditorCommand) => { + historyRef.current.execute(command); + forceUpdate(); + }, []); + + const undo = useCallback(() => { + historyRef.current.undo(); + forceUpdate(); + }, []); + + const redo = useCallback(() => { + historyRef.current.redo(); + forceUpdate(); + }, []); + + return { + execute, + undo, + redo, + canUndo: historyRef.current.canUndo, + canRedo: historyRef.current.canRedo, + }; +} + +// 컴포넌트에서 사용 +function DrawingApp() { + const canvasRef = useRef(new CanvasState()); + const { execute, undo, redo, canUndo, canRedo } = useCommandHistory(); + + const addRectangle = () => { + const shape: Shape = { + id: crypto.randomUUID(), + type: 'rectangle', + x: 100, y: 100, + width: 200, height: 150, + color: '#6366f1', + }; + execute(new AddShapeCommand(canvasRef.current, shape)); + }; + + // Ctrl+Z / Ctrl+Shift+Z 단축키 + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'z') { + e.shiftKey ? redo() : undo(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [undo, redo]); + + return ( +
+ + + + +
+ ); +} +``` + +### 3. 이벤트 소싱 패턴 - 확장 + +이벤트 소싱은 커맨드 패턴의 아이디어를 극한까지 밀어 붙인 것 + +상태를 직접 저장하는 대신, +→ **상태를 변경하는 모든 이벤트(커맨드)를 순서대로 저장**하고, +→ 이벤트를 처음부터 재생해서 현재 상태를 도출 + +```tsx +// 이벤트(커맨드)를 순서대로 기록 +type CartEvent = + | { type: 'item_added'; payload: { productId: string; quantity: number; price: number }; timestamp: number } + | { type: 'item_removed'; payload: { productId: string }; timestamp: number } + | { type: 'quantity_changed'; payload: { productId: string; quantity: number }; timestamp: number } + | { type: 'coupon_applied'; payload: { code: string; discount: number }; timestamp: number }; + +// 이벤트 스토어 +class CartEventStore { + private events: CartEvent[] = []; + + append(event: CartEvent) { + this.events.push(event); + this.persist(event); // DB나 localStorage에 저장 + } + + // 이벤트를 처음부터 재생해서 현재 상태 도출 + getCurrentState(): CartState { + return this.events.reduce((state, event) => { + switch (event.type) { + case 'item_added': + return { + ...state, + items: [...state.items, { + productId: event.payload.productId, + quantity: event.payload.quantity, + price: event.payload.price, + }], + }; + case 'item_removed': + return { + ...state, + items: state.items.filter(i => i.productId !== event.payload.productId), + }; + case 'quantity_changed': + return { + ...state, + items: state.items.map(i => + i.productId === event.payload.productId + ? { ...i, quantity: event.payload.quantity } + : i + ), + }; + case 'coupon_applied': + return { ...state, discount: event.payload.discount }; + default: + return state; + } + }, { items: [], discount: 0 } as CartState); + } + + // 특정 시점의 상태를 복원 (시간 여행) + getStateAt(timestamp: number): CartState { + const eventsUntil = this.events.filter(e => e.timestamp <= timestamp); + return eventsUntil.reduce(/* 위와 동일한 리듀서 */, { items: [], discount: 0 }); + } + + // 모든 변경 이력을 완벽하게 추적 + getHistory(): CartEvent[] { + return [...this.events]; + } + + private persist(event: CartEvent) { + // localStorage, IndexedDB, 또는 서버로 전송 + } +} +``` + +- Redux 자체가 이벤트 소싱의 단순화 버전 + - Action 로그가 이벤트 로그 + - Reducer가 이벤트를 상태로 변환하는 프로젝트 + - Redux DevTools의 time-travel debugging이 가능한 이유 + +### 4. 폼 작업의 커맨드화 + +복잡한 폼(다단계 설문, 주문 양식 등)에서 +”이전 단계로 돌아가기”도 커맨드 패턴으로 깔끔하게 구현 가능 + +```tsx +interface FormCommand { + execute(state: T): T; + undo(state: T): T; + description: string; +} + +class SetFieldCommand implements FormCommand { + private previousValue: any; + description: string; + + constructor( + private field: keyof T, + private value: any, + ) { + this.description = `Set ${String(field)} = ${value}`; + } + + execute(state: T): T { + this.previousValue = state[this.field]; + return { ...state, [this.field]: this.value }; + } + + undo(state: T): T { + return { ...state, [this.field]: this.previousValue }; + } +} + +class AddItemCommand implements FormCommand { + description: string; + + constructor(private item: any) { + this.description = `Add item: ${item.name}`; + } + + execute(state: T): T { + return { ...state, items: [...state.items, this.item] } as T; + } + + undo(state: T): T { + return { ...state, items: state.items.slice(0, -1) } as T; + } +} + +// 훅으로 래핑 +function useFormWithHistory(initialState: T) { + const [state, setState] = useState(initialState); + const undoStack = useRef[]>([]); + const redoStack = useRef[]>([]); + const [, forceUpdate] = useReducer(x => x + 1, 0); + + const execute = useCallback((command: FormCommand) => { + setState(prev => { + const next = command.execute(prev); + undoStack.current.push(command); + redoStack.current = []; + return next; + }); + forceUpdate(); + }, []); + + const undo = useCallback(() => { + const command = undoStack.current.pop(); + if (!command) return; + setState(prev => { + const next = command.undo(prev); + redoStack.current.push(command); + return next; + }); + forceUpdate(); + }, []); + + return { + state, + execute, + undo, + redo: /* ... */, + canUndo: undoStack.current.length > 0, + history: undoStack.current.map(c => c.description), + }; +} + +// 사용 +function OrderForm() { + const { state, execute, undo, canUndo, history } = useFormWithHistory({ + name: '', + email: '', + items: [], + shippingMethod: 'standard', + }); + + return ( +
+ execute(new SetFieldCommand('name', e.target.value))} + /> + + + {/* 변경 히스토리 표시 */} +
    + {history.map((desc, i) =>
  • {desc}
  • )} +
+
+ ); +} +``` + +### 5. 지연 실행과 큐잉 - API 요청의 커맨드화 + +네트워크 요청을 커맨드로 만들면 +→ 오프라인 지원, 재시도, 배치 처리가 자연스러워짐 + +```tsx +interface ApiCommand { + execute(): Promise; + rollback?(): Promise; + description: string; + retryable: boolean; +} + +class CreatePostCommand implements ApiCommand { + description: string; + retryable = true; + private createdId: string | null = null; + + constructor(private postData: { title: string; content: string }) { + this.description = `Create post: ${postData.title}`; + } + + async execute() { + const response = await api.post('/posts', this.postData); + this.createdId = response.data.id; + return response.data; + } + + async rollback() { + if (this.createdId) { + await api.delete(`/posts/${this.createdId}`); + } + } +} + +class UpdatePostCommand implements ApiCommand { + description: string; + retryable = true; + private previousData: any = null; + + constructor(private postId: string, private updates: Partial) { + this.description = `Update post: ${postId}`; + } + + async execute() { + // 변경 전 데이터를 저장 (rollback용) + const { data: current } = await api.get(`/posts/${this.postId}`); + this.previousData = current; + return api.patch(`/posts/${this.postId}`, this.updates); + } + + async rollback() { + if (this.previousData) { + await api.put(`/posts/${this.postId}`, this.previousData); + } + } +} + +// 오프라인 큐 — 네트워크 복구 시 순서대로 실행 +class OfflineCommandQueue { + private queue: ApiCommand[] = []; + private processing = false; + + enqueue(command: ApiCommand) { + this.queue.push(command); + this.persist(); // IndexedDB에 저장 + + if (navigator.onLine) { + this.processQueue(); + } + } + + private async processQueue() { + if (this.processing || this.queue.length === 0) return; + this.processing = true; + + while (this.queue.length > 0) { + const command = this.queue[0]; + try { + await command.execute(); + this.queue.shift(); // 성공 시 큐에서 제거 + this.persist(); + } catch (error) { + if (command.retryable) { + // 재시도 가능하면 잠시 후 다시 시도 + await new Promise(r => setTimeout(r, 5000)); + } else { + this.queue.shift(); // 재시도 불가능하면 건너뜀 + } + } + } + + this.processing = false; + } + + // 온라인 복구 시 큐 처리 시작 + constructor() { + window.addEventListener('online', () => this.processQueue()); + } + + private persist() { + // IndexedDB에 큐 저장 — 페이지 새로고침에도 유지 + } +} + +// 사용 +const offlineQueue = new OfflineCommandQueue(); + +function handleCreatePost(data: PostData) { + // 오프라인이어도 커맨드를 큐에 넣음 + // 온라인이 되면 자동으로 실행 + offlineQueue.enqueue(new CreatePostCommand(data)); + + // 낙관적 업데이트: UI에서는 즉시 반영 + addPostToLocalState(data); +} +``` + +### 6. useReducer - React + +`useReducer`는 커맨드 패턴의 축소판 + +- Action이 커맨드 +- dispatch가 Invoker +- Reducer가 Receiver + +```tsx +// Action = Command 객체 +type CounterAction = + | { type: 'increment' } + | { type: 'decrement' } + | { type: 'increment_by'; payload: number } + | { type: 'reset' }; + +// Reducer = Receiver +function counterReducer(state: number, action: CounterAction): number { + switch (action.type) { + case 'increment': return state + 1; + case 'decrement': return state - 1; + case 'increment_by': return state + action.payload; + case 'reset': return 0; + } +} + +function Counter() { + // dispatch = Invoker + const [count, dispatch] = useReducer(counterReducer, 0); + + return ( +
+ {count} + {/* 클릭 시 커맨드 객체를 생성해서 Invoker에 전달 */} + + + + +
+ ); +} +``` + +### 7. 이미 쓰고 있는 커맨드 패턴들 + +```tsx +// document.execCommand — 브라우저 내장 커맨드 (deprecated지만 개념적으로 정확) +document.execCommand('bold'); // 커맨드 객체: "볼드 적용" +document.execCommand('undo'); // 커맨드 객체: "되돌리기" +document.execCommand('insertText', false, 'hello'); + +// History API — 네비게이션을 커맨드로 +history.pushState(state, '', '/page'); // 커맨드: 이 페이지로 이동 +history.back(); // Undo: 이전 페이지로 + +// requestAnimationFrame — 실행할 작업을 큐에 등록 +const commands: (() => void)[] = []; +commands.push(() => updatePosition(element, 100, 200)); +commands.push(() => updateOpacity(element, 0.5)); +requestAnimationFrame(() => commands.forEach(cmd => cmd())); + +// git — 커밋이 커맨드, revert가 Undo, cherry-pick이 커맨드 재실행 +// git commit → 변경 사항을 커맨드 객체(커밋)로 저장 +// git revert → 특정 커맨드를 되돌리는 새 커맨드 생성 +// git log → 커맨드 히스토리 조회 +``` + +### 정리하자면,,, + +커맨드 패턴의 요소는,, + +- Command 인터페이스 +- ConcreteCommand 클래스 +- Invoker +- Receiver + +핵심은 + +> ***“실행할 행위를 데이터(객체)로 표현하면, 그 행위를 저장하고, 전달하고, 되돌리고, 재생할 수 있다”*** +> \ No newline at end of file