6. Command — 주문서를 객체로 만들면 취소도 재주문도 쉬워진다
작성일:2026.05.21|조회수:1

에이든 피자의 시범 운영이 어느덧 일주일을 향해 가고 있다. 주방은 이제 제법 능숙하게 피자를 구워내고, 포스기(POS) 시스템도 팩토리 메서드 덕분에 다양한 지점 메뉴를 무리 없이 받아낸다. 하지만 시스템이 안정될수록 요구사항은 더 정교해지기 마련이다. 오늘은 주문 그 자체가 문제가 됐다.
지금까지의 주문은 단순한 함수 호출이었다. 손님이 메뉴를 고르면, 시스템은 즉시 주방 객체의 메서드를 호출했다. "마르게리타 하나요!"라고 외치면 kitchen.prepare('margherita')가 실행되는 식이다. 코드는 직관적이었고, 손님이 줄을 서지 않을 때는 아무런 문제가 없었다.
class SimpleOrderProcessor {
constructor(private kitchen: Kitchen) {}
handleOrder(pizzaType: string) {
// 주문을 받자마자 주방에 직접 명령을 내린다
console.log(`[로그] 주문 접수: ${pizzaType}`);
this.kitchen.prepare(pizzaType);
this.kitchen.bake(pizzaType);
}
}문제는 점심시간의 피크 타임에 터졌다. 주문이 쏟아지기 시작하자 "아까 시킨 거 취소해 주세요!"라거나 "방금 시킨 거랑 똑같은 걸로 하나 더요!"라는 요청이 빗발쳤다. 함수 호출은 실행되는 순간 끝이다. 이미 호출된 prepare()를 취소하려면 주방 클래스 안을 뒤져서 방금 시작된 작업을 찾아내 중단시키는 복잡한 로직이 필요했다. "똑같은 걸로 하나 더" 요청을 들어주려면 방금 어떤 인자로 함수를 호출했는지 어딘가에 따로 적어두어야 했다.
함수 호출이라는 행위는 휘발성이다. 공중에 흩어진 말은 주워 담기 어렵고, 복제하기도 힘들다. 우리는 이 '행위' 자체를 손에 잡히는 무언가로 만들 필요가 있었다.
무엇이 불편한가
지금의 구조에서 가장 불편한 점은 요청을 보낸 쪽(POS)과 요청을 처리하는 쪽(Kitchen)이 너무 꽉 맞물려 있다는 것이다. POS는 주방이 prepare() 메서드를 가지고 있다는 사실을 정확히 알아야 하고, 주문이 들어오는 즉시 그 메서드를 호출해야 한다.
이런 구조에서는 실행 시점을 조절할 수 없다. 주문이 100개 들어오면 주방 메서드도 100번 즉시 호출된다. 주방 오븐이 하나뿐이라 주문을 큐(Queue)에 쌓아두고 싶어도, 호출 자체가 이미 명령이 되어버렸기에 뒤로 미루기가 마땅치 않다. 취소 기능을 넣으려 해도 "누가, 언제, 어떤 명령을 내렸는지"에 대한 기록이 없으니 취소 버튼을 만들 곳이 없다.
결국 우리는 '주문하기'라는 행위를 하나의 독립된 데이터 조각으로 분리하기로 했다. 요청을 메서드 호출이 아니라, 객체로 만드는 것이다. 주문서라는 종이 한 장을 상상하면 쉽다. 주문서에는 무엇을 해야 하는지 적혀 있고, 주방은 그 종이를 받아서 자기 순서가 되었을 때 읽고 처리한다. 종이는 버릴 수도 있고(취소), 복사할 수도 있다(재주문).
Command 패턴
Command 패턴은 바로 이 '요청'을 객체로 캡슐화하는 패턴이다. GoF의 원래 의도는 다음과 같다.
요청을 객체의 형태로 캡슐화하여, 사용자가 보낸 요청을 나중에 이용할 수 있도록 매개변수화하고, 큐에 저장하거나 로그를 남기며, 실행 취소(Undo) 연산을 지원하게 한다.
이 패턴의 핵심은 모든 명령이 공통된 인터페이스를 가진다는 점이다. 에이든 피자의 주문 시스템에서는 execute()와 undo() 메서드를 가진 Command 인터페이스가 그 중심에 선다.
interface Command {
execute(): void;
undo(): void;
}이렇게 인터페이스를 정의하면, 구체적으로 어떤 명령인지는 중요하지 않게 된다. 주문을 넣는 명령이든, 취소하는 명령이든, 주방 기기를 점검하는 명령이든 모두 execute()를 호출하면 실행되고 undo()를 호출하면 되돌려진다. 요청을 내리는 쪽(Invoker)은 이 객체가 정확히 무슨 일을 하는지 몰라도 된다. 그저 "자, 네 차례니까 실행해"라고 말하며 execute()를 누를 뿐이다.
피자가게에 주문 큐 도입하기
이제 실제 주문을 객체로 만들어보자. PlaceOrderCommand는 주방(Receiver)과 구체적인 주문 내용(Order)을 알고 있는 객체다. 이 객체는 나중에 실행될 준비를 마친 상태로 대기할 수 있다. PlaceOrderCommand는 필요한 모든 정보(주방 객체, 주문 ID, 피자 이름)를 생성 시점에 미리 받아둔다. 덕분에 이 객체는 언제 어디서든 execute()만 호출하면 바로 주문을 넣을 수 있다.
class PlaceOrderCommand implements Command {
constructor(
private kitchen: Kitchen,
private orderId: string,
private pizzaName: string
) {}
execute() {
console.log(`[실행] 주문번호 ${this.orderId}: ${this.pizzaName} 조리 시작`);
this.kitchen.prepare(this.pizzaName);
}
undo() {
console.log(`[취소] 주문번호 ${this.orderId}: ${this.pizzaName} 조리 중단 및 폐기`);
this.kitchen.cancel(this.orderId);
}
}이제 이 명령들을 관리할 OrderQueue 클래스를 만든다. 이 클래스가 바로 패턴에서 말하는 Invoker다. 이 친구는 주방이 얼마나 바쁜지, 지금 어떤 피자를 만들어야 하는지 고민하지 않는다. 그저 명령 객체들을 줄 세워두고 하나씩 실행할 뿐이다.
class OrderQueue {
private pendingCommands: Command[] = [];
private history: Command[] = [];
addOrder(command: Command) {
this.pendingCommands.push(command);
console.log("새 주문이 큐에 추가되었습니다.");
}
processNext() {
const command = this.pendingCommands.shift();
if (command) {
command.execute();
this.history.push(command); // 나중에 취소하거나 재주문하기 위해 기록
} else {
console.log("처리할 주문이 없습니다.");
}
}
undoLastOrder() {
const command = this.history.pop();
if (command) {
command.undo();
} else {
console.log("취소할 주문 기록이 없습니다.");
}
}
}OrderQueue는 명령을 큐에 쌓아두었다가 주방 상황에 맞춰 하나씩 꺼낼 수 있다. 또한 history 배열에 실행된 명령을 차곡차곡 쌓아두었기에, "방금 거 취소요!"라는 요청이 들어오면 마지막 명령을 꺼내 undo()를 호출하기만 하면 된다. POS 시스템이 주방의 복잡한 취소 로직을 직접 알 필요가 없어진 것이다.
TypeScript로 구현할 때 알아야 할 것들
TypeScript를 사용하다 보면 "꼭 이렇게 클래스로 만들어야 하나?"라는 의문이 들 수 있다. 사실 간단한 Command 패턴은 함수만으로도 구현 가능하다.
type SimpleCommand = () => void;
const commands: SimpleCommand[] = [
() => kitchen.prepare('margherita'),
() => kitchen.prepare('pepperoni'),
];하지만 에이든 피자처럼 undo() 기능이 필요하거나, 명령 객체 안에 여러 메타데이터(주문 시간, 주문자 정보 등)를 담아야 하는 경우에는 클래스가 훨씬 강력하다. 클래스는 데이터(상태)와 행위(메서드)를 하나로 묶어주는 훌륭한 바구니가 되어주기 때문이다.
또한 TypeScript의 인터페이스 덕분에 우리는 '명령'이라는 추상적인 개념을 안전하게 다룰 수 있다. Command[] 타입의 배열에는 PlaceOrderCommand뿐만 아니라, 주방 청소 명령인 CleanKitchenCommand나 재고 확인 명령인 CheckStockCommand도 함께 담길 수 있다. 이들이 모두 execute()를 가지고 있다는 사실을 컴파일 타임에 보장받기 때문이다.
한 가지 팁이 있다면, undo()를 구현할 때 Receiver(여기서는 Kitchen)의 상태를 이전으로 완전히 되돌릴 수 있는지 확인해야 한다는 점이다. 이미 오븐에서 다 구워져 나온 피자를 '조리 전' 상태로 되돌리는 것은 물리적으로 불가능하다. 이런 경우에는 폐기 처리를 하거나 환불 로직을 실행하는 식으로 '비즈니스적 의미의 Undo'를 설계해야 한다.
그래서 이게 항상 좋은 선택인가
Command 패턴을 도입하면서 에이든 피자는 주문을 큐에 쌓고, 순서를 바꾸고, 취소하는 유연함을 얻었다. POS는 이제 주방과 직접 대화하지 않고 주문서(Command)를 통해서만 소통한다. 주방 시스템이 아무리 복잡해져도 POS 코드는 바뀔 일이 없다. 이것이 바로 디자인 패턴이 주는 '느슨한 결합'의 마법이다.
물론 대가도 있다. 단순한 함수 호출 하나면 끝날 일을 위해 인터페이스를 정의하고, 구체 클래스를 만들고, Invoker를 세팅해야 한다. 코드의 양이 늘어나고 구조가 복잡해지는 것은 피할 수 없다. 주문 취소나 큐잉이 전혀 필요 없는 아주 작은 가게라면, 이 패턴은 오버엔지니어링일 가능성이 크다.
하지만 장기적으로 볼 때, 요청을 객체로 다루는 것은 시스템의 확장성을 완전히 다른 차원으로 끌어올린다. 나중에 "어제 주문한 것들 다 보여주세요"라거나 "가장 많이 취소된 메뉴가 뭐죠?" 같은 통계 요구사항이 들어와도, 우리는 이미 객체화된 주문 이력을 가지고 있기에 가볍게 웃으며 대응할 수 있다.
전체 코드
// Receiver: 실제 로직을 수행하는 주방
class Kitchen {
prepare(pizzaName: string) {
console.log(`주방: ${pizzaName} 준비 중...`);
}
cancel(orderId: string) {
console.log(`주방: 주문번호 ${orderId} 조리 중단 및 재료 정리`);
}
}
// Command 인터페이스
interface Command {
execute(): void;
undo(): void;
}
// 구체적인 주문 명령
class PlaceOrderCommand implements Command {
constructor(
private kitchen: Kitchen,
private orderId: string,
private pizzaName: string
) {}
execute() {
this.kitchen.prepare(this.pizzaName);
}
undo() {
this.kitchen.cancel(this.orderId);
}
}
// Invoker: 명령을 실행하고 관리하는 주문 큐
class OrderQueue {
private pendingCommands: Command[] = [];
private history: Command[] = [];
addOrder(command: Command) {
this.pendingCommands.push(command);
}
processNext() {
const command = this.pendingCommands.shift();
if (command) {
console.log("\n[알림] 다음 주문을 처리합니다.");
command.execute();
this.history.push(command);
} else {
console.log("\n[알림] 처리 대기 중인 주문이 없습니다.");
}
}
undoLast() {
const command = this.history.pop();
if (command) {
console.log("\n[알림] 마지막 주문을 취소합니다.");
command.undo();
} else {
console.log("\n[알림] 취소할 주문 내역이 없습니다.");
}
}
}
// 사용 예시
async function main() {
const kitchen = new Kitchen();
const queue = new OrderQueue();
// 1. 주문 생성 및 큐 추가
const order1 = new PlaceOrderCommand(kitchen, "ORD-001", "마르게리타");
const order2 = new PlaceOrderCommand(kitchen, "ORD-002", "페퍼로니");
queue.addOrder(order1);
queue.addOrder(order2);
// 2. 주문 처리
queue.processNext(); // 마르게리타 시작
queue.processNext(); // 페퍼로니 시작
// 3. 취소 요청
queue.undoLast(); // 페퍼로니 취소
}
main();주문 객체가 생기면서 에이든 피자는 요청을 자유자재로 다룰 수 있게 됐다. 그런데 주문을 받다 보니 또 다른 문제가 생겼다. 손님들이 기본 피자에 만족하지 못하기 시작한 것이다. "치즈 추가해 주세요", "올리브 빼주세요", "바짝 구워주세요" 같은 요구사항이 쏟아졌다. 토핑 조합마다 클래스를 만들자니 메뉴판이 폭발할 것 같고, 그렇다고 모든 피자 클래스에 모든 토핑 플래그를 넣자니 코드가 너무 지저분해진다. 피자의 기본은 유지하면서 기능을 우아하게 덧붙일 방법은 없을까?
댓글
댓글을 불러오는 중...