12. State — 조리 중인 주문을 취소하려 하면 어떻게 되나
작성일:2026.05.21|수정일:2026.08.04|조회수:0

에이든 피자의 시범 운영 12일차. 이제 주문은 검증의 사슬을 통과해 주방으로 무사히 입성한다. 하지만 주방 안에서의 삶은 생각보다 다채롭다. 주문은 '접수됨'에서 시작해 '조리 중'을 거쳐 '완성'이 되고, 배달 주문이라면 '배달 중'과 '배달 완료'라는 산을 넘어야 한다.
문제는 손님들이 이 '상태'를 가만히 두지 않는다는 것이다. 피자가 이미 오븐에 들어갔는데("조리 중") 갑자기 주문을 취소하겠다고 전화를 하거나, 이미 배달 기사님이 출발했는데("배달 중") 피자 종류를 바꿔달라고 떼를 쓰는 손님이 생겼다. 처음에는 string 타입의 status 필드 하나로 모든 상황을 해결하려 했다.
class Order { status: string = 'pending'; cancel() { if (this.status === 'pending') { this.status = 'cancelled'; console.log('주문이 취소되었습니다.'); } else if (this.status === 'preparing') { throw new Error('조리 중에는 취소할 수 없습니다.'); } else if (this.status === 'ready') { console.log('조리 완료 상태에서는 취소하지 않고, 별도 환불 절차를 시작합니다. 주문 상태는 유지합니다.'); this.refund(); } else if (this.status === 'delivering') { throw new Error('배달 중에는 취소할 수 없습니다.'); } } progress() { if (this.status === 'pending') { this.status = 'preparing'; } else if (this.status === 'preparing') { this.status = 'ready'; } // ... 끝도 없이 이어지는 if-else }}이 코드를 보고 있으면 한숨부터 나온다. cancel() 뿐만 아니라 progress(), getEstimatedTime(), updateAddress() 등 상태에 따라 행동이 달라져야 하는 모든 메서드에 동일한 if-else 지옥이 반복된다. 상태가 하나 추가되거나 전이 규칙이 바뀌기라도 하면, 우리는 클래스 전체를 뒤져서 모든 분기문을 수정해야 한다. 주문의 상태가 늘어날수록 코드는 점점 더 읽기 힘든 암호문에 가까워졌다.
무엇이 불편한가
지금의 가장 큰 문제는 "상태에 따른 행동"이 한곳에 뭉쳐 있다는 점이다. Order 클래스는 주문 그 자체의 정보를 담는 곳이어야 하는데, 지금은 '접수됨'일 때의 행동 규칙과 '배달 중'일 때의 행동 규칙을 모두 암기하고 있다. 클래스 하나가 너무 많은 역할을 수행하면서 비대해진 것이다.
또한 상태 전이의 규칙이 선명하게 보이지 않는다. 어떤 상태에서 어떤 상태로 넘어갈 수 있는지, 그 규칙이 여러 메서드에 파편화되어 흩어져 있다. 실수로 '배달 완료' 상태에서 다시 '조리 중'으로 돌아가는 코드를 짜더라도, 컴파일러는 우리를 지켜주지 않는다. 그저 런타임에 누군가 항의 전화를 하기 전까지는 말이다.
우리는 상태 그 자체를 객체로 만들고 싶었다. '접수됨' 상태 객체는 취소가 가능하다고 말하고, '조리 중' 상태 객체는 취소하면 안 된다고 말하는 구조 말이다. Order는 그저 현재 자신이 어떤 상태인지만 알고, 구체적인 행동은 현재 상태 객체에게 물어보기만 하면 된다.
State 패턴
State 패턴은 객체의 내부 상태가 바뀜에 따라 객체의 행동을 변경할 수 있게 하는 패턴이다. 객체는 마치 자신의 클래스를 바꾸는 것처럼 보인다. GoF의 원래 의도는 다음과 같다.
객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허용한다. 객체는 마치 자신의 클래스를 바꾸는 것처럼 보일 것이다.
에이든 피자에 대입해보면, Order는 컨텍스트(Context)가 되고, 각 상태(Pending, Preparing, Ready 등)는 상태(State) 인터페이스를 구현하는 구체적인 클래스가 된다. 주문이 들어오면 PendingState 객체를 가지게 되고, 조리가 시작되면 이 객체를 PreparingState로 갈아 끼운다. Order.cancel()을 호출하면? Order는 직접 고민하지 않고 현재 가지고 있는 상태 객체에게 처리를 위임한다.
이 패턴의 핵심은 "분기문을 클래스로 치환"하는 것이다. 거대한 if-else를 여러 개의 작은 클래스로 쪼개면, 각 상태의 로직은 해당 클래스 안에 캡슐화된다. 새로운 상태를 추가하는 것은 새로운 클래스를 하나 더 만드는 일이 될 뿐, 기존 코드를 건드릴 필요가 없어진다.
상태를 객체로 독립시키기
리팩토링을 시작해보자. 먼저 주문 상태가 가져야 할 공통 인터페이스를 만든다.
interface OrderState { cancel(order: Order): void; progress(order: Order): void; getLabel(): string;}이제 구체적인 상태 클래스들을 구현한다. '접수됨' 상태는 취소와 진행이 모두 가능하다.
class PendingState implements OrderState { getLabel() { return '접수됨'; } cancel(order: Order) { console.log('주문을 취소합니다.'); order.setState(new CancelledState()); } progress(order: Order) { console.log('조리를 시작합니다.'); order.setState(new PreparingState()); }}class PreparingState implements OrderState { getLabel() { return '조리 중'; } cancel(order: Order) { throw new Error('조리 중에는 취소할 수 없습니다.'); } progress(order: Order) { console.log('조리가 완료되었습니다.'); order.setState(new ReadyForDeliveryState()); }}마지막으로 Order 클래스는 현재 상태 객체에 일을 시키기만 한다.
class Order { private state: OrderState; constructor() { this.state = new PendingState(); // 초기 상태 } setState(state: OrderState) { this.state = state; } cancel() { try { this.state.cancel(this); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error(`취소 실패: ${message}`); } } progress() { this.state.progress(this); } getStatusLabel() { return this.state.getLabel(); }}이제 Order 클래스 안에서 지저분한 if-else가 사라진다. 상태 전이 규칙은 각 상태 클래스 안에 들어간다. '조리 중'일 때 취소하면 안 된다는 규칙은 PreparingState 클래스만 보면 바로 알 수 있다. 만약 '반품 중'이라는 새로운 상태가 필요하다면, OrderState를 구현하는 새로운 클래스를 하나 추가하기만 하면 된다.
TypeScript 포인트
TypeScript 개발자라면 클래스 기반의 State 패턴을 보면서 "그냥 디스크리미네이티드 유니온(Discriminated Unions)을 쓰면 안 되나?"라고 생각할 수 있다.
type OrderStatus = | { type: 'pending', canCancel: true } | { type: 'preparing', canCancel: false } // ...실제로 상태가 단순하고 행동이 많지 않다면 TypeScript의 Union 타입을 사용하는 것이 훨씬 간결하고 타입 안전하다. 하지만 행동(메서드)이 많아지고 상태 전이 로직이 복잡해지면, Union 타입만으로는 switch 문이 거대해지는 것을 막기 어렵다.
클래스 기반의 State 패턴은 각 상태가 "데이터"뿐만 아니라 "동작"을 함께 가져야 할 때 진가를 발휘한다. 또한 상태 객체들이 내부에 데이터를 가지지 않는다면(무상태), 매번 new 하지 않고 싱글턴으로 관리하여 메모리 효율을 높일 수도 있다. 시범 운영 중인 에이든 피자에서는 확장성을 고려해 클래스 기반의 정석적인 패턴을 따르기로 했다. 나중에 상태 전이 이력을 저장하는 Memento 패턴(21편)과 조합하기에도 이 구조가 더 유리하기 때문이다.
State vs. Strategy
글을 마치기 전에 많은 개발자가 헷갈려 하는 점을 짚고 넘어가려 한다. 바로 다음 편에서 다룰 Strategy(전략) 패턴과의 차이다. 두 패턴은 구조가 거의 똑같다. 둘 다 인터페이스를 통해 행동을 외부 객체에 위임한다.
차이는 "의도"와 "누가 교체하는가"에 있다. Strategy 패턴은 사용자가 런타임에 직접 알고리즘을 바꿔 끼우는 것이 목적이다. 전략끼리는 서로의 존재를 모른다. 반면 State 패턴은 객체의 상태가 변함에 따라 행동이 자동으로 바뀌는 것이 목적이다. 그래서 상태 클래스들은 대개 "다음 상태가 무엇인지"를 알고 있으며, 스스로 상태를 전이시킨다.
에이든 피자의 주문이 스스로 상태를 바꾸는 것은 State 패턴이고, 배달 방식을 '오토바이'에서 '드론'으로 바꾸는 것은 Strategy 패턴이다. 이 둘의 미묘하지만 결정적인 차이는 다음 편에서 더 확실히 느끼게 될 것이다.
트레이드오프
State 패턴은 상태에 따른 복잡한 분기문을 줄이고 코드를 상태 단위로 모으는 데 유용하다. 상태 전이 규칙을 캡슐화하면 실수를 줄일 수 있고, 새로운 상태를 추가하는 길도 비교적 선명해진다. "상태도 객체다"라는 관점은 생각보다 강하다.
하지만 상태의 수가 적다면 오히려 과한 설계(Over-engineering)가 될 수 있다. 클래스 수가 급격히 늘어나는 것도 부담이다. 또한 상태 전이가 여러 곳에 흩어져 있어 전체적인 흐름을 한눈에 파악하기 위해 여러 파일을 오가야 한다는 점도 단점이다. 하지만 조리 중인 피자를 취소하려는 손님과 싸우는 것보다는, 코드 몇 줄을 더 쓰는 편이 정신 건강에 낫다는 사실을 우리는 시범 운영을 통해 배웠다.
전체 코드
/** * Context: 주문 클래스 */class Order { private state: OrderState; constructor(public id: string) { this.state = new PendingState(); console.log(`[주문 ${id}] 생성: 현재 상태 - ${this.state.getLabel()}`); } public setState(state: OrderState): void { this.state = state; console.log(`[주문 ${this.id}] 상태 전환 -> ${this.state.getLabel()}`); } public cancel(): void { this.state.cancel(this); } public progress(): void { this.state.progress(this); } public getStatus(): string { return this.state.getLabel(); }}/** * State: 상태 인터페이스 */interface OrderState { cancel(order: Order): void; progress(order: Order): void; getLabel(): string;}/** * ConcreteState 1: 접수됨 */class PendingState implements OrderState { getLabel() { return "접수됨"; } cancel(order: Order) { console.log("결정: 접수 단계이므로 즉시 취소 처리합니다."); order.setState(new CancelledState()); } progress(order: Order) { console.log("결정: 주방에 전달하여 조리를 시작합니다."); order.setState(new PreparingState()); }}/** * ConcreteState 2: 조리 중 */class PreparingState implements OrderState { getLabel() { return "조리 중"; } cancel(order: Order) { console.log("결정: 이미 조리가 시작되어 취소가 불가능합니다!"); // 에러를 던지거나 무시할 수 있음 } progress(order: Order) { console.log("결정: 조리가 완료되어 포장 단계로 넘어갑니다."); order.setState(new ReadyForDeliveryState()); }}/** * ConcreteState 3: 완료 (배달 대기) */class ReadyForDeliveryState implements OrderState { getLabel() { return "준비 완료"; } cancel(order: Order) { console.log("결정: 조리가 완료되었으므로 주문 상태는 유지한 채 별도 환불 절차를 진행합니다."); // 환불은 결제 도메인의 별도 절차로 시작한다. 주문 상태는 준비 완료로 유지한다. } progress(order: Order) { console.log("결정: 배달 기사님께 피자를 전달합니다."); order.setState(new DeliveringState()); }}/** * ConcreteState 4: 배달 중 */class DeliveringState implements OrderState { getLabel() { return "배달 중"; } cancel(order: Order) { console.log("결정: 이미 배달이 시작되어 취소가 불가능합니다!"); } progress(order: Order) { console.log("결정: 배달이 무사히 완료되었습니다."); order.setState(new DeliveredState()); }}/** * ConcreteState 5: 완료 및 취소 (최종 상태들) */class DeliveredState implements OrderState { getLabel() { return "배달 완료"; } cancel() { console.log("이미 완료된 주문입니다."); } progress() { console.log("이미 배달이 끝난 주문입니다."); }}class CancelledState implements OrderState { getLabel() { return "취소됨"; } cancel() { console.log("이미 취소된 주문입니다."); } progress() { console.log("취소된 주문은 진행할 수 없습니다."); }}// 에이든 피자 주문 시뮬레이션console.log("=== 시나리오 1: 정상 배달 ===");const order1 = new Order("ORD-001");order1.progress(); // 조리 시작order1.progress(); // 조리 완료order1.progress(); // 배달 시작order1.progress(); // 배달 완료console.log("\n=== 시나리오 2: 조리 중 취소 시도 ===");const order2 = new Order("ORD-002");order2.progress(); // 조리 시작order2.cancel(); // 취소 거절됨console.log(`최종 상태: ${order2.getStatus()}`);console.log("\n=== 시나리오 3: 접수 시 취소 ===");const order3 = new Order("ORD-003");order3.cancel(); // 즉시 취소주문의 상태가 관리되기 시작하면서 에이든 피자의 운영은 한층 더 성숙해졌다. 어떤 시점에 어떤 일이 가능한지 코드가 스스로 증명하고 있으니, 이제 주방장과 홀 직원이 서로 얼굴 붉히며 싸울 일도 줄어들 것이다.
이제 주문이 '준비 완료'를 넘어 '배달 중'으로 넘어가야 할 차례다. 그런데 배달 방식이 참 다양하다. 매장 안에서 먹는 홀 주문, 직접 가져가는 포장 주문, 가게 소속 기사님이 가는 직접 배달, 그리고 각종 배달 앱을 통한 외부 배달까지. 이 다양한 배달 알고리즘을 어떻게 하면 런타임에 자유자재로 갈아 끼울 수 있을까? 다음 편에서는 드디어 디자인 패턴의 꽃이라 불리는 Strategy 패턴을 만나본다.
댓글
댓글을 불러오는 중...