코딩 · 디자인 패턴
상태 패턴 예제로 쉽게 이해하기
핵심 답변
상태(State)는 내부 상태가 바뀔 때 객체의 동작이 바뀌게 하여 마치 클래스가 바뀐 것처럼 보이게 하는 행동 패턴입니다. 각 상태는 같은 인터페이스를 구현하는 별도의 클래스가 되고, 컨텍스트라 부르는 주 객체는 작업을 현재 상태 객체에 위임합니다. 전이는 이 상태 객체를 교체하는 방식으로 일어나며, 모든 메서드에서 상태 필드를 검사하는 긴 switch나 if 연쇄가 사라집니다.
의도와 해결하는 문제
의도: 객체의 내부 상태가 바뀔 때 객체가 동작을 바꾸도록 허용합니다. 객체는 클래스가 바뀐 것처럼 보입니다. 상태는 GoF(Gang of Four) 책에 실린 행동 패턴 중 하나이며, 고전적인 예는 대기, 연결됨, 닫힘 상태에 따라 다르게 동작하는 TCP 연결입니다.
온라인 주문을 생각해 봅시다. 주문은 대기, 결제 완료, 배송됨, 취소됨 중 하나입니다. 결제는 대기 상태에서만, 배송은 결제 완료 상태에서만 가능합니다. 취소는 대기나 결제 완료 상태에서 가능하고 배송 후에는 불가능합니다. 첫 버전은 status 문자열을 저장하고 모든 메서드를 그에 대한 switch로 시작합니다. 동작 4개와 상태 4개면 메서드 4개에 걸쳐 케이스가 16개입니다. “환불됨” 같은 새 상태를 추가하려면 모든 메서드를 고쳐야 하고 하나를 빠뜨리기 쉽습니다.
상태 패턴에서는 각 상태가 그 상태에서 각 동작이 무엇을 뜻하는지 아는 클래스가 됩니다. 주문은 현재 상태 객체에 대한 참조를 가지고 모든 동작을 그곳으로 전달합니다.
구조와 참여자
| 역할 | 책임 |
|---|---|
| 컨텍스트(Context) | 클라이언트가 사용하는 객체로, 현재 상태 객체를 가지고 상태에 따른 동작을 그 객체에 위임합니다 |
| 상태(State) | 상태에 따라 동작이 달라지는 연산을 선언하는 인터페이스 |
| 구체 상태(Concrete state) | 하나의 상태에 대한 동작을 구현하고 다음 상태를 결정합니다 |
전이는 상태 자신이 결정하거나(아래처럼 각 상태가 컨텍스트에 다음 상태를 설정) 컨텍스트가 전이 표를 사용해 결정할 수 있습니다. 상태가 결정하면 관련 로직이 한곳에 모이고, 중앙 표를 쓰면 모든 전이를 한눈에 볼 수 있습니다.
TypeScript 구현
기반 클래스가 기본 동작인 동작 거부를 제공합니다. 각 구체 상태는 자신에게 유효한 동작만 오버라이드합니다.
abstract class OrderState {
abstract readonly name: string;
pay(order: Order): void { this.reject("pay"); }
ship(order: Order): void { this.reject("ship"); }
cancel(order: Order): void { this.reject("cancel"); }
protected reject(action: string): never {
throw new Error("Cannot " + action + " an order that is " + this.name);
}
}
class Pending extends OrderState {
readonly name = "pending";
pay(order: Order) { order.setState(new Paid()); }
cancel(order: Order) { order.setState(new Cancelled()); }
}
class Paid extends OrderState {
readonly name = "paid";
ship(order: Order) { order.setState(new Shipped()); }
cancel(order: Order) { order.setState(new Cancelled()); } // refund here
}
class Shipped extends OrderState { readonly name = "shipped"; }
class Cancelled extends OrderState { readonly name = "cancelled"; }
class Order {
private state: OrderState = new Pending();
setState(next: OrderState) { this.state = next; }
get status() { return this.state.name; }
pay() { this.state.pay(this); }
ship() { this.state.ship(this); }
cancel() { this.state.cancel(this); }
}
const order = new Order();
order.pay();
order.ship();
console.log(order.status); // shipped
try { order.cancel(); } catch (e) { console.log((e as Error).message); }
// Cannot cancel an order that is shipped“환불됨” 상태를 추가하려면 새 클래스 하나를 작성하고, 그 상태로 이어질 수 있는 상태만 고치면 됩니다. Order 자체는 바뀌지 않습니다.
적용 전과 후
같은 규칙을 상태 필드와 switch 문으로 작성한 코드입니다. 상태가 몇 개 안 될 때는 동작하지만, 모든 메서드가 전체 목록을 반복합니다.
type Status = "pending" | "paid" | "shipped" | "cancelled";
class OrderBefore {
status: Status = "pending";
ship() {
switch (this.status) {
case "paid": this.status = "shipped"; break;
default: throw new Error("Cannot ship an order that is " + this.status);
}
}
// pay() and cancel() repeat the same switch
}
const o = new OrderBefore();
try { o.ship(); } catch (e) { console.log((e as Error).message); }
// Cannot ship an order that is pending작은 경우에는 이 버전도 충분히 괜찮고 오히려 읽기 쉽다고 할 수 있습니다. TypeScript에서는 Status에 유니온 타입을 쓰면 컴파일러가 모든 케이스를 처리했는지 확인해 줍니다. 상태별 동작이 한두 줄을 넘어 커지면 상태 클래스로 옮기세요.
언제 쓰고 언제 쓰지 말아야 할까
- 객체가 상태에 따라 매우 다르게 동작하고, 같은 상태 검사가 여러 메서드에 나타날 때 사용합니다.
- 상태와 전이가 시간이 지나며 바뀌고 각 상태의 규칙을 한곳에 두고 싶을 때 사용합니다.
- 주문, 문서, 티켓 같은 워크플로, 프로토콜 연결, 게임 캐릭터, 미디어 플레이어 같은 UI 컴포넌트에 사용합니다.
- 동작이 사소한 상태가 두세 개뿐이라면 사용하지 않습니다. enum이나 유니온 타입과
switch가 더 간단합니다. - 중첩되거나 병렬인 상태를 가진 복잡한 머신에는 직접 클래스를 작성하는 대신 상태 머신 라이브러리나 스테이트차트 표기법을 고려하세요.
실제 사용 사례
- 네트워크 프로토콜 구현은 대기, 연결됨, 닫힘 같은 연결 상태를 모델링하며, 이것이 GoF 책에서 쓰인 예입니다.
- XState는 상태 머신과 스테이트차트를 위한 JavaScript 및 TypeScript 라이브러리로, UI 로직과 워크플로에 쓰입니다.
- W3C SCXML 명세는 스테이트차트를 위한 XML 형식을 정의합니다.
- 게임 개발에서는 캐릭터(대기, 달리기, 점프)에 상태 객체를 사용하며, 상태마다 입력을 다르게 처리합니다.
상태 패턴과 전략 등 다른 패턴 비교
| 패턴 | 상태 패턴과의 차이 |
|---|---|
| 전략 | 클래스 구조는 거의 같습니다. 전략은 보통 클라이언트가 선택하고 스스로 바뀌지 않지만, 상태는 객체 자신의 동작의 결과로 서로를 교체합니다. |
| 플라이웨이트 | 필드가 없는 상태 객체는 플라이웨이트나 상수로 모든 컨텍스트가 공유할 수 있습니다. |
| 싱글턴 | 무상태 상태 객체는 흔히 하나의 공유 인스턴스입니다. |
| 메멘토 | 메멘토는 나중에 복원하려고 객체의 데이터를 저장하고, 상태 패턴은 객체가 사용하는 동작을 바꿉니다. |
자주 묻는 질문
상태 패턴과 전략 패턴의 차이는 무엇인가요?
둘 다 동작을 교체 가능한 객체에 위임합니다. 전략에서는 보통 클라이언트가 알고리즘을 고르고 그것이 고정됩니다. 상태에서는 상태 객체가 서로를 알고 정상 동작의 일부로 컨텍스트를 새 상태로 전환합니다.
상태 패턴은 유한 상태 기계와 같은 것인가요?
상태 패턴은 객체지향 코드에서 유한 상태 기계를 구현하는 한 가지 방법입니다. 각 상태는 클래스이고 각 전이는 현재 상태 객체를 교체합니다. 기계는 전이 표나 라이브러리로 구현할 수도 있습니다.
다음 상태는 컨텍스트와 상태 중 누가 결정해야 하나요?
둘 다 가능합니다. 상태가 결정하면 각 상태의 로직이 함께 모입니다. 컨텍스트나 전이 표가 결정하면 모든 전이가 한곳에 보이고 상태가 서로를 알 필요가 없습니다.
상태 객체를 공유할 수 있나요?
네, 자체 데이터를 갖지 않는다면 가능합니다. 그러면 각 상태의 인스턴스 하나를 모든 컨텍스트 객체가 공유할 수 있어 할당을 줄일 수 있습니다.