From 0501a8d3d1ed50425dc3b542202ee990a965bcc1 Mon Sep 17 00:00:00 2001 From: jihyeon Date: Tue, 24 Mar 2026 20:41:59 +0900 Subject: [PATCH] =?UTF-8?q?docs:=206=EC=9E=A5=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=20=ED=8C=A8=ED=84=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0\353\223\234_\355\214\250\355\204\264.md" | 636 ++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 "jihyeon/06.\354\273\244\353\247\250\353\223\234_\355\214\250\355\204\264.md" diff --git "a/jihyeon/06.\354\273\244\353\247\250\353\223\234_\355\214\250\355\204\264.md" "b/jihyeon/06.\354\273\244\353\247\250\353\223\234_\355\214\250\355\204\264.md" new file mode 100644 index 0000000..41dd136 --- /dev/null +++ "b/jihyeon/06.\354\273\244\353\247\250\353\223\234_\355\214\250\355\204\264.md" @@ -0,0 +1,636 @@ +# **커맨드 패턴 (Command Pattern)** + +**요청(동작)을 객체로 캡슐화해서, 요청을 보내는 쪽과 실제 실행하는 쪽을 분리하는 패턴** + +- 어떤 작업을 **직접 호출**하지 않고 +- 그 작업을 **“실행 가능한 명령”** 으로 감싸서 +- 다른 객체가 그 명령을 실행하게 만드는 것 + +즉, “무엇을 할지”를 하나의 객체로 표현하는 패턴이라고 볼 수 있다. + +### **정의** + +> 요청을 객체로 캡슐화하여, 서로 다른 요청·큐·로그로 클라이언트를 매개변수화할 수 있게 하고, 실행 취소(Undo) 같은 기능도 지원하게 하는 패턴 +> + +--- + +## **2. 왜 필요한가** + +책의 예시는 **만능 리모컨**입니다. + +처음에는 리모컨이 기기를 직접 제어한다고 가정할 수 있다. + +```tsx +class RemoteControl { + buttonWasPressed(device: string): void { + if (device === 'light') { + light.on() + } else if (device === 'fan') { + fan.high() + } else if (device === 'hottub') { + hottub.jetsOn() + } + } +} +``` + +이 구조는 금방 문제가 생깁니다. + +### **문제점** + +- 리모컨이 전등, 선풍기, 욕조 등 **모든 기기 구현을 직접 알아야 함** +- 새 기기가 추가될 때마다 if/else 또는 switch가 계속 늘어남 +- **확장에는 약하고 수정에는 민감한 구조** +- Undo 같은 기능을 붙이기 어려움 +- 버튼, 단축키, 매크로처럼 실행 경로가 늘어나면 로직이 흩어짐 + +즉, **“버튼을 누르는 쪽”과 “실제 일을 하는 쪽”이 너무 강하게 붙어 있는 것**이 문제 + +--- + +## **3. 해결 아이디어** + +**동작 자체를 객체로 만든다.** 즉, + +- “전등 켜기” +- “전등 끄기” +- “선풍기 강풍으로 바꾸기” +- “욕조 제트 켜기” + +이런 요청 하나하나를 **커맨드 객체**로 만들고, 리모컨은 기기를 직접 모르고 **그냥 execute()만 호출**하게 만든다. + +이렇게 되면 리모컨은 더 이상 “전등을 어떻게 켜는지” 몰라도 된다. + +그저 “이 슬롯에 들어 있는 명령을 실행한다”만 알면 된다. + +--- + +## **4. 비유로 이해하기** + +**식당 주문** + +- 손님: 주문을 요청하는 쪽 +- 웨이터: 주문서를 받아 전달하는 쪽 +- 주문서: 어떤 요리를 만들라는 명령 +- 요리사: 실제 작업을 수행하는 쪽 + +손님은 주방 구조를 몰라도 되고, 웨이터도 요리를 직접 만들 필요가 없다. + +중간의 **주문서(Command)** 가 요청을 대신 전달해준다. + +--- + +## **5. 패턴 구조** + +### **1) Command** + +실행할 명령의 공통 인터페이스 + +```tsx +interface Command { + execute(): void + undo(): void +} +``` + +### **2) ConcreteCommand** + +실제 명령 구현체 + +예: `LightOnCommand`, `LightOffCommand` + +### **3) Receiver** + +실제 작업을 수행하는 객체 + +예: `Light`, `CeilingFan`, `GarageDoor` + +### **4) Invoker** + +커맨드를 실행시키는 객체 + +예: `RemoteControl` + +### **5) Client** + +Receiver와 Command를 조립해서 Invoker에 주입하는 것 + +--- + +## **6. 역할별 의미** + +| **역할** | **의미** | +| --- | --- | +| Command | 명령의 공통 규약 | +| ConcreteCommand | 특정 작업을 캡슐화한 객체 | +| Receiver | 실제 비즈니스 로직/기기 동작 수행 | +| Invoker | 명령을 보관하고 실행 | +| Client | 조립 담당 | + +> **Invoker는 Receiver를 몰라도 된다.** 그냥 execute()를 호출하면 된다. +> + +--- + +## **7. 기본 구현 예시** + +## **7-1. Command 인터페이스** + +```tsx +export interface Command { + execute(): void + undo(): void +} +``` + +--- + +## **7-2. Receiver** + +```tsx +export class Light { + private level = 0 + + on(): void { + this.level = 100 + console.log('Light is on') + } + + off(): void { + this.level = 0 + console.log('Light is off') + } + + dim(level: number): void { + this.level = level + console.log(`Light is dimmed to ${level}%`) + } + + getLevel(): number { + return this.level + } +} +``` + +Light는 실제 일을 하는 객체. + +커맨드 패턴의 핵심 로직은 여기에 있지 않고, 이 객체를 **어떻게 호출할지 감싸는 데** 있다. + +--- + +## **7-3. ConcreteCommand** + +```tsx +export class LightOnCommand implements Command { + private prevLevel = 0 + + constructor(private light: Light) {} + + execute(): void { + this.prevLevel = this.light.getLevel() + this.light.on() + } + + undo(): void { + this.light.dim(this.prevLevel) + } +} +``` + +```tsx +export class LightOffCommand implements Command { + private prevLevel = 0 + + constructor(private light: Light) {} + + execute(): void { + this.prevLevel = this.light.getLevel() + this.light.off() + } + + undo(): void { + this.light.dim(this.prevLevel) + } +} +``` + +- execute()에서 실행 +- undo()에서 이전 상태 복원 +- Receiver(Light)는 실제 동작 수행 +- Command는 **실행 절차와 상태 복원 책임** 담당 + +--- + +## **7-4. NoCommand** + +빈 슬롯에 null을 넣으면 매번 체크해야 하므로, 보통 **Null Object 패턴**을 같이 쓴다. + +```tsx +export class NoCommand implements Command { + execute(): void {} + undo(): void {} +} +``` + +이렇게 하면 슬롯이 비어 있어도 안전하게 호출할 수 있다. + +--- + +## **7-5. Invoker** + +```tsx +export class RemoteControl { + private onCommands: Command[] + private offCommands: Command[] + + constructor() { + const noCommand = new NoCommand() + this.onCommands = Array(7).fill(noCommand) + this.offCommands = Array(7).fill(noCommand) + } + + setCommand(slot: number, onCommand: Command, offCommand: Command): void { + this.onCommands[slot] = onCommand + this.offCommands[slot] = offCommand + } + + onButtonWasPushed(slot: number): void { + this.onCommands[slot].execute() + } + + offButtonWasPushed(slot: number): void { + this.offCommands[slot].execute() + } +} +``` + +RemoteControl은 전등인지 선풍기인지 모른다. 그냥 슬롯에 들어 있는 커맨드의 execute()만 호출한다. + +이 부분이 결합도를 낮추는 핵심이다. + +--- + +## **7-6. Client** + +```tsx +const remote = new RemoteControl() + +const livingRoomLight = new Light() + +const lightOn = new LightOnCommand(livingRoomLight) +const lightOff = new LightOffCommand(livingRoomLight) + +remote.setCommand(0, lightOn, lightOff) + +remote.onButtonWasPushed(0) +remote.offButtonWasPushed(0) +``` + +- Receiver 생성 +- Command 생성 +- Invoker에 연결 + +즉, **객체 조립 책임**을 담당 + +--- + +## **8. Undo가 왜 자연스럽게 붙는가** + +커맨드 패턴의 큰 장점 중 하나는 **Undo/Redo 구현 하기 좋다**는 점입니다. + +- 실행이 하나의 객체로 캡슐화되어 있고 +- 실행 전 상태를 저장할 수 있기 때문입니다 + +예를 들어 선풍기 속도처럼 상태가 여러 단계라면: + +```tsx +export class CeilingFanHighCommand implements Command { + private prevSpeed = 0 + + constructor(private ceilingFan: CeilingFan) {} + + execute(): void { + this.prevSpeed = this.ceilingFan.getSpeed() + this.ceilingFan.high() + } + + undo(): void { + switch (this.prevSpeed) { + case CeilingFan.HIGH: + this.ceilingFan.high() + break + case CeilingFan.MEDIUM: + this.ceilingFan.medium() + break + case CeilingFan.LOW: + this.ceilingFan.low() + break + default: + this.ceilingFan.off() + } + } +} +``` + +즉, 커맨드는 단순히 “실행만 하는 객체”가 아니라 **“실행과 복구를 하나의 단위로 묶는 객체”** 가 된다. + +--- + +## **9. MacroCommand** + +커맨드 패턴은 여러 명령을 묶어서 **하나의 명령처럼 다루기**도 쉽 + +```tsx +export class MacroCommand implements Command { + constructor(private commands: Command[]) {} + + execute(): void { + for (const command of this.commands) { + command.execute() + } + } + + undo(): void { + for (let i = this.commands.length - 1; i >= 0; i--) { + this.commands[i].undo() + } + } +} +``` + +예를 들어 “파티 모드” 버튼 하나로 아래의 기능을 한 번에 실행할 수 있다. + +- 전등 켜기 +- 오디오 켜기 +- 욕조 가동 +- 선풍기 조절 + +여기서 undo()를 역순으로 처리하는 것도 중요하다. + +실행 순서의 반대로 되돌려야 상태 일관성이 깨지지 않기 때문이다. + +--- + +## **10. 장점** + +## **10-1. 결합도 감소** + +Invoker는 Receiver를 몰라도 된다. Command 인터페이스에만 의존하면 된다. + +## **10-2. OCP에 유리** + +새로운 기기가 생겨도 기존 Invoker를 수정할 필요 없이 새 커맨드만 추가하면 된다. + +## **10-3. Undo/Redo에 유리** + +실행 로직과 복구 로직을 한 곳에 모을 수 있다. + +## **10-4. 로깅, 큐잉, 재실행이 쉬움** + +명령이 객체이므로 저장, 기록, 예약 실행이 쉬워진다. + +## **10-5. 조합이 쉬움** + +MacroCommand처럼 여러 명령을 하나로 묶을 수 있다. + +--- + +## **11. 단점** + +## **11-1. 클래스 수 증가** + +동작마다 커맨드 클래스가 생길 수 있다. + +## **11-2. 작은 문제에는 과할 수 있음** + +단순 토글 하나를 위해 커맨드 계층을 만드는 건 오버엔지니어링일 수 있다. + +## **11-3. 조립 비용이 생김** + +Client가 Receiver, Command, Invoker를 연결해야 하므로 구조가 길어질 수 있다. + +즉, 커맨드 패턴은 **복잡성을 관리하려고 쓰는 패턴**이기 때문에 무분별하게 사용하면 안된다. + +--- + +# **12. 프론트엔드 관점** + +프론트엔드에서는 **UI 이벤트와 비즈니스 로직을 분리하는 방식**으로 자주 나타난다. + +핵심 질문은 같습니다. + +> 버튼이 직접 일을 하게 할까? +> +> +> 아니면 버튼은 “명령”만 보내고, 다른 계층이 실행하게 할까? +> + +프론트엔드에서는 후자가 꽤 자주 등장합니다. + +--- + +## **13. 프론트엔드에서의 대표적인 형태** + +## **13-1. 버튼/단축키/메뉴를 하나의 실행 경로로 통합** + +예를 들어 “굵게 만들기” 기능이 있다고 해본다면, 이 기능은 다음 여러 경로에서 호출될 수 있다. + +- 툴바 버튼 클릭 +- 단축키 Cmd+B +- 컨텍스트 메뉴 선택 + +이때 각각이 직접 에디터 로직을 호출하면 실행 코드가 흩어진다. + +```tsx +type EditorCommand = + | { type: 'bold' } + | { type: 'italic' } + | { type: 'insert-link'; payload: { href: string } } + +function runEditorCommand(command: EditorCommand) { + switch (command.type) { + case 'bold': + editor.toggleBold() + break + case 'italic': + editor.toggleItalic() + break + case 'insert-link': + editor.insertLink(command.payload.href) + break + } +} +``` + +이제 UI는 그냥 명령만 보낸다. + +```tsx + +``` + +```tsx +useHotkeys('mod+b', () => runEditorCommand({ type: 'bold' })) +``` + +--- + +## **13-2. 상태관리의 액션 디스패치** + +프론트엔드에서는 클래스 기반 커맨드보다 **객체 리터럴 기반 커맨드**가 훨씬 흔하다. + +```tsx +dispatch({ type: 'modal/open', payload: { id: 'login' } }) +dispatch({ type: 'cart/addItem', payload: product }) +dispatch({ type: 'editor/bold' }) +``` + +이런 구조는 “액션”이라고 많이 부르지만, 관점상으로는 커맨드 패턴과 매우 가깝다. + +- 컴포넌트는 의도만 보냄 +- 실제 실행은 다른 계층이 담당 +- 미들웨어, 로깅, 분석, 부수효과 삽입이 쉬움 + +즉, 프론트엔드에서는 **class Command** 대신 type + payload 형태의 데이터 명령이 자주 쓰인다. + +--- + +## **13-3. Overlay 시스템** + +모달, 바텀시트, 토스트 같은 UI도 커맨드로 다루기 좋다. + +```tsx +type OverlayCommand = + | { type: 'modal/open'; payload: { id: string; props?: unknown } } + | { type: 'modal/close'; payload: { id: string } } + | { type: 'toast/show'; payload: { message: string } } + +function dispatchOverlay(command: OverlayCommand) { + switch (command.type) { + case 'modal/open': + overlayStore.open(command.payload.id, command.payload.props) + break + case 'modal/close': + overlayStore.close(command.payload.id) + break + case 'toast/show': + toastStore.show(command.payload.message) + break + } +} +``` + +이런 구조를 쓰면, + +UI 어디서든 같은 방식으로 모달과 토스트를 제어할 수 있고, + +정책도 한곳에 모을 수 있습니다. + +예: + +- 이미 열려 있으면 무시 +- 열릴 때 analytics 기록 +- 닫힐 때 focus 복원 + +--- + +## **13-4. Undo/Redo가 필요한 UI** + +- 드로잉 툴 +- 리치 텍스트 에디터 +- 페이지 빌더 +- 피그마 같은 편집 툴 + +```tsx +interface Command { + execute(): void + undo(): void +} + +class MoveLayerCommand implements Command { + constructor( + private layerId: string, + private from: Position, + private to: Position, + ) {} + + execute(): void { + moveLayer(this.layerId, this.to) + } + + undo(): void { + moveLayer(this.layerId, this.from) + } +} +``` + +히스토리 스택 + +```tsx +const undoStack: Command[] = [] +const redoStack: Command[] = [] + +function execute(command: Command) { + command.execute() + undoStack.push(command) + redoStack.length = 0 +} + +function undo() { + const command = undoStack.pop() + if (!command) return + command.undo() + redoStack.push(command) +} +``` + +--- + +## **13-5. 비동기 실행 묶기** + +프론트엔드에서 명령은 상태 변경뿐 아니라 **부수효과 묶음**이 되기도 한다. + +```tsx +type UserCommand = + | { type: 'user/saveProfile'; payload: ProfileForm } + | { type: 'user/deleteAccount' } + +async function executeUserCommand(command: UserCommand) { + switch (command.type) { + case 'user/saveProfile': + await api.saveProfile(command.payload) + toast.success('저장되었습니다') + queryClient.invalidateQueries(['profile']) + break + + case 'user/deleteAccount': + await api.deleteAccount() + router.replace('/goodbye') + break + } +} +``` + +단순 명령이 아니라 **“하나의 유스케이스 실행 단위”** 로 작동한다. + +--- + +## 13-6 정리 + +전통적인 정의: + +> 요청을 객체로 캡슐화해서 호출자와 실행자를 분리하는 패턴 +> + +프론트엔드식으로 바꾸면: + +> **UI가 직접 일을 하지 않고, “의도”를 명령으로 표현해서 실행 계층에 위임하는 패턴** +> + +즉, 클릭 핸들러가 모든 일을 하지 않고 아래의 기능들을 하는 구조 + +- 명령을 만들고 +- 실행기에게 넘기고 +- 실행기는 상태 변경, API 호출, 로깅, 포커스 이동 등을 처리 \ No newline at end of file