State Machine 패턴
주문 상태를 관리하는 코드를 작성한다고 하자. 주문은 "대기 → 결제완료 → 배송중 → 배송완료" 순서로 진행되고, 취소도 가능하다. 처음에는 이렇게 작성하게 된다.
function updateOrderStatus(order: Order, newStatus: string) {
if (order.status === 'pending' && newStatus === 'paid') {
order.status = 'paid';
} else if (order.status === 'paid' && newStatus === 'shipping') {
order.status = 'shipping';
} else if (order.status === 'shipping' && newStatus === 'delivered') {
order.status = 'delivered';
} else if (order.status === 'pending' && newStatus === 'cancelled') {
order.status = 'cancelled';
} else if (order.status === 'paid' && newStatus === 'cancelled') {
order.refund();
order.status = 'cancelled';
} else {
throw new Error(`Cannot transition from ${order.status} to ${newStatus}`);
}
}
상태가 5개만 되어도 if/else 분기가 폭발한다. 새 상태가 추가될 때마다 모든 분기를 확인해야 하고, "배송중에서 대기로 돌아가는 건 불가능한가?" 같은 규칙이 코드 전체에 흩어져서 전체 흐름을 파악하기 어렵다. 더 심각한 문제는 잘못된 전이가 발생해도 컴파일 타임에 잡을 수 없다는 것이다.
State Machine(상태 기계)은 이런 문제를 구조적으로 해결한다. 가능한 상태를 명시적으로 열거하고, 어떤 상태에서 어떤 상태로 전이할 수 있는지를 규칙으로 정의해서 허용되지 않은 전이를 원천 차단한다.
유한 상태 기계(FSM)의 구성 요소
State Machine은 수학적으로 다음 5가지 요소로 정의된다.
| 요소 | 설명 | 예시 |
|---|---|---|
| States (상태 집합) | 시스템이 가질 수 있는 모든 상태 | pending, paid, shipping, delivered, cancelled |
| Events (이벤트 집합) | 전이를 유발하는 입력 | PAY, SHIP, DELIVER, CANCEL |
| Transitions (전이 함수) | (현재 상태, 이벤트) → 다음 상태 매핑 | (pending, PAY) → paid |
| Initial State (초기 상태) | 시작 시점의 상태 | pending |
| Final States (최종 상태) | 더 이상 전이할 수 없는 상태 | delivered, cancelled |
핵심은 전이 함수다. 현재 상태와 이벤트의 조합으로 다음 상태가 결정적(deterministic)으로 결정된다. 정의되지 않은 조합은 불가능한 전이로 간주된다.
기본 구현: 전이 테이블
가장 직관적인 구현 방식은 전이 규칙을 테이블(Map)로 선언하는 것이다.
// 1. 상태와 이벤트를 열거형으로 정의
enum OrderState {
Pending = 'PENDING',
Paid = 'PAID',
Shipping = 'SHIPPING',
Delivered = 'DELIVERED',
Cancelled = 'CANCELLED',
}
enum OrderEvent {
Pay = 'PAY',
Ship = 'SHIP',
Deliver = 'DELIVER',
Cancel = 'CANCEL',
}
// 2. 전이 테이블 정의
type TransitionMap = {
[state in OrderState]?: {
[event in OrderEvent]?: OrderState;
};
};
const transitions: TransitionMap = {
[OrderState.Pending]: {
[OrderEvent.Pay]: OrderState.Paid,
[OrderEvent.Cancel]: OrderState.Cancelled,
},
[OrderState.Paid]: {
[OrderEvent.Ship]: OrderState.Shipping,
[OrderEvent.Cancel]: OrderState.Cancelled,
},
[OrderState.Shipping]: {
[OrderEvent.Deliver]: OrderState.Delivered,
},
// Delivered, Cancelled: 최종 상태 → 전이 없음
};
이 테이블 하나만 보면 전체 상태 흐름이 파악된다. "배송중에서 취소 가능한가?" → transitions[Shipping]에 Cancel이 없으니 불가능. 코드를 뒤질 필요가 없다.
// 3. State Machine 클래스
class OrderStateMachine {
private state: OrderState;
constructor(initial: OrderState = OrderState.Pending) {
this.state = initial;
}
getState(): OrderState {
return this.state;
}
send(event: OrderEvent): OrderState {
const stateTransitions = transitions[this.state];
const nextState = stateTransitions?.[event];
if (!nextState) {
throw new Error(
`Invalid transition: ${this.state} + ${event}`
);
}
this.state = nextState;
return this.state;
}
}
사용 예시:
const order = new OrderStateMachine();
console.log(order.getState()); // PENDING
order.send(OrderEvent.Pay); // PENDING → PAID
order.send(OrderEvent.Ship); // PAID → SHIPPING
order.send(OrderEvent.Deliver); // SHIPPING → DELIVERED
order.send(OrderEvent.Cancel); // Error: Invalid transition: DELIVERED + CANCEL
전이 시 부수 효과 (Actions)
실제 애플리케이션에서는 상태가 바뀔 때 무언가를 해야 한다. 결제 완료 시 영수증 발송, 취소 시 환불 처리 등. 이런 부수 효과를 전이 규칙에 함께 정의할 수 있다.
interface Transition {
target: OrderState;
action?: () => void | Promise<void>;
}
type TransitionMap = {
[state in OrderState]?: {
[event in OrderEvent]?: Transition;
};
};
const transitions: TransitionMap = {
[OrderState.Pending]: {
[OrderEvent.Pay]: {
target: OrderState.Paid,
action: () => sendReceipt(),
},
[OrderEvent.Cancel]: {
target: OrderState.Cancelled,
action: () => logCancellation('before_payment'),
},
},
[OrderState.Paid]: {
[OrderEvent.Ship]: {
target: OrderState.Shipping,
action: () => notifyShipment(),
},
[OrderEvent.Cancel]: {
target: OrderState.Cancelled,
action: () => processRefund(),
},
},
[OrderState.Shipping]: {
[OrderEvent.Deliver]: {
target: OrderState.Delivered,
action: () => sendDeliveryConfirmation(),
},
},
};
send() 메서드도 action을 실행하도록 수정한다:
async send(event: OrderEvent): Promise<OrderState> {
const transition = transitions[this.state]?.[event];
if (!transition) {
throw new Error(`Invalid transition: ${this.state} + ${event}`);
}
// 전이 전에 action 실행
if (transition.action) {
await transition.action();
}
this.state = transition.target;
return this.state;
}
부수 효과가 전이 테이블에 같이 있으니 "이 전이가 일어나면 무슨 일이 발생하는지"를 한 곳에서 확인할 수 있다.
Guards: 조건부 전이
전이가 항상 허용되는 건 아니다. "결제 완료 상태에서 배송으로 넘어가려면 재고가 있어야 한다" 같은 조건이 필요할 수 있다.
interface Transition {
target: OrderState;
guard?: () => boolean;
action?: () => void | Promise<void>;
}
const transitions: TransitionMap = {
[OrderState.Paid]: {
[OrderEvent.Ship]: {
target: OrderState.Shipping,
guard: () => inventory.hasStock(order.productId),
action: () => notifyShipment(),
},
},
};
send(event: OrderEvent): OrderState {
const transition = transitions[this.state]?.[event];
if (!transition) {
throw new Error(`Invalid transition: ${this.state} + ${event}`);
}
// guard가 있으면 조건 검사
if (transition.guard && !transition.guard()) {
throw new Error(
`Transition guard failed: ${this.state} + ${event}`
);
}
transition.action?.();
this.state = transition.target;
return this.state;
}
Guard는 전이 규칙 자체를 동적으로 만든다. 같은 이벤트라도 런타임 조건에 따라 전이가 허용되거나 거부될 수 있다.
진입/퇴장 훅 (onEnter / onExit)
특정 상태에 진입하거나 빠져나올 때 실행할 로직을 정의할 수 있다. 전이별 action과 달리 상태 자체에 바인딩된다.
const stateHooks: Partial<Record<OrderState, {
onEnter?: () => void;
onExit?: () => void;
}>> = {
[OrderState.Shipping]: {
onEnter: () => startTrackingTimer(),
onExit: () => stopTrackingTimer(),
},
[OrderState.Cancelled]: {
onEnter: () => archiveOrder(),
},
};
전이 실행 순서는 이렇다:
- 현재 상태의
onExit실행 - 전이의
action실행 - 다음 상태의
onEnter실행
async send(event: OrderEvent): Promise<OrderState> {
const transition = transitions[this.state]?.[event];
if (!transition) throw new Error(`Invalid transition`);
if (transition.guard && !transition.guard()) throw new Error(`Guard failed`);
// 1. 현재 상태 퇴장
stateHooks[this.state]?.onExit?.();
// 2. 전이 액션
await transition.action?.();
// 3. 상태 변경 + 진입
const prevState = this.state;
this.state = transition.target;
stateHooks[this.state]?.onEnter?.();
return this.state;
}
이 구분이 중요한 이유는 같은 상태로 들어오는 경로가 여러 개일 때 드러난다. "취소" 상태는 대기 중에서도, 결제 후에서도 진입할 수 있는데, onEnter에 archiveOrder()를 한 번만 정의하면 어디서 진입하든 자동으로 실행된다. 전이별 action에 넣었다면 각 전이마다 중복해서 넣어야 한다.
제네릭 State Machine 클래스
매번 도메인별로 State Machine을 새로 만들 필요 없이, 제네릭으로 재사용 가능한 클래스를 만들 수 있다.
interface TransitionConfig<S, E> {
target: S;
guard?: () => boolean;
action?: () => void | Promise<void>;
}
interface StateConfig<S> {
onEnter?: () => void;
onExit?: () => void;
}
interface StateMachineConfig<S extends string, E extends string> {
initial: S;
states: Partial<Record<S, StateConfig<S>>>;
transitions: Partial<Record<S, Partial<Record<E, TransitionConfig<S, E>>>>>;
}
class StateMachine<S extends string, E extends string> {
private state: S;
private config: StateMachineConfig<S, E>;
private listeners: Array<(from: S, to: S, event: E) => void> = [];
constructor(config: StateMachineConfig<S, E>) {
this.config = config;
this.state = config.initial;
config.states[config.initial]?.onEnter?.();
}
getState(): S {
return this.state;
}
can(event: E): boolean {
const transition = this.config.transitions[this.state]?.[event];
if (!transition) return false;
if (transition.guard && !transition.guard()) return false;
return true;
}
async send(event: E): Promise<S> {
const transition = this.config.transitions[this.state]?.[event];
if (!transition) {
throw new Error(`No transition: ${this.state} + ${event}`);
}
if (transition.guard && !transition.guard()) {
throw new Error(`Guard rejected: ${this.state} + ${event}`);
}
const from = this.state;
this.config.states[this.state]?.onExit?.();
await transition.action?.();
this.state = transition.target;
this.config.states[this.state]?.onEnter?.();
this.listeners.forEach((fn) => fn(from, this.state, event));
return this.state;
}
onChange(fn: (from: S, to: S, event: E) => void): () => void {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((l) => l !== fn);
};
}
}
사용 예시:
const orderMachine = new StateMachine<OrderState, OrderEvent>({
initial: OrderState.Pending,
states: {
[OrderState.Cancelled]: {
onEnter: () => console.log('Order archived'),
},
},
transitions: {
[OrderState.Pending]: {
[OrderEvent.Pay]: { target: OrderState.Paid },
[OrderEvent.Cancel]: { target: OrderState.Cancelled },
},
[OrderState.Paid]: {
[OrderEvent.Ship]: { target: OrderState.Shipping },
[OrderEvent.Cancel]: {
target: OrderState.Cancelled,
action: () => processRefund(),
},
},
[OrderState.Shipping]: {
[OrderEvent.Deliver]: { target: OrderState.Delivered },
},
},
});
// 전이 가능 여부 미리 확인
if (orderMachine.can(OrderEvent.Pay)) {
await orderMachine.send(OrderEvent.Pay);
}
can() 메서드로 UI에서 버튼 활성화/비활성화를 제어할 수 있다. guard까지 포함해서 검사하니 "지금 이 액션이 가능한가?"를 정확히 판단할 수 있다.
백엔드에서의 활용: 도메인 엔티티에 State Machine 내장
NestJS + MikroORM 같은 환경에서 엔티티의 상태를 State Machine으로 관리하면 도메인 규칙을 엔티티 안에 캡슐화할 수 있다.
@Entity()
class Order {
@Enum(() => OrderState)
status: OrderState = OrderState.Pending;
// 허용된 전이 규칙
private static readonly TRANSITIONS: Record<OrderState, OrderState[]> = {
[OrderState.Pending]: [OrderState.Paid, OrderState.Cancelled],
[OrderState.Paid]: [OrderState.Shipping, OrderState.Cancelled],
[OrderState.Shipping]: [OrderState.Delivered],
[OrderState.Delivered]: [],
[OrderState.Cancelled]: [],
};
canTransitionTo(next: OrderState): boolean {
return Order.TRANSITIONS[this.status].includes(next);
}
transitionTo(next: OrderState): void {
if (!this.canTransitionTo(next)) {
throw new BadRequestException(
`Cannot transition from ${this.status} to ${next}`
);
}
this.status = next;
}
pay(): void {
this.transitionTo(OrderState.Paid);
this.paidAt = new Date();
}
ship(trackingNumber: string): void {
this.transitionTo(OrderState.Shipping);
this.trackingNumber = trackingNumber;
}
cancel(): void {
this.transitionTo(OrderState.Cancelled);
this.cancelledAt = new Date();
}
}
서비스 레이어에서는 엔티티의 메서드만 호출하면 된다:
@Injectable()
class OrderService {
async cancelOrder(orderId: string): Promise<void> {
const order = await this.orderRepo.findOneOrFail(orderId);
order.cancel(); // 내부에서 전이 규칙 검증
await this.em.flush();
}
}
상태 전이 규칙이 엔티티 안에 있으니 서비스에서 실수로 잘못된 상태를 직접 대입하는 일이 방지된다. order.status = OrderState.Delivered처럼 직접 대입하는 대신 반드시 transitionTo()를 거치도록 강제하는 것이다.
프론트엔드에서의 활용: UI 상태 관리
비동기 데이터 로딩처럼 UI 상태가 복잡해지는 경우에도 State Machine이 유용하다. idle → loading → success/error → retry 같은 흐름을 명시적으로 관리할 수 있다.
type FetchState = 'idle' | 'loading' | 'success' | 'error';
type FetchEvent = 'FETCH' | 'RESOLVE' | 'REJECT' | 'RETRY' | 'RESET';
const fetchMachine = new StateMachine<FetchState, FetchEvent>({
initial: 'idle',
states: {},
transitions: {
idle: {
FETCH: { target: 'loading' },
},
loading: {
RESOLVE: { target: 'success' },
REJECT: { target: 'error' },
},
success: {
RESET: { target: 'idle' },
FETCH: { target: 'loading' },
},
error: {
RETRY: { target: 'loading' },
RESET: { target: 'idle' },
},
},
});
이렇게 하면 "loading 상태에서 또 FETCH를 보내는" 불가능한 상황이 구조적으로 차단된다. isLoading && isError가 동시에 true가 되는 불가능한 상태 조합(impossible state)도 원천적으로 발생하지 않는다.
React에서 사용한다면:
function useFetchMachine() {
const [state, setState] = useState<FetchState>('idle');
const machineRef = useRef(fetchMachine);
const send = useCallback((event: FetchEvent) => {
machineRef.current.send(event);
setState(machineRef.current.getState());
}, []);
return { state, send };
}
function UserProfile() {
const { state, send } = useFetchMachine();
useEffect(() => {
send('FETCH');
fetchUser()
.then(() => send('RESOLVE'))
.catch(() => send('REJECT'));
}, []);
if (state === 'loading') return <Spinner />;
if (state === 'error') return <button onClick={() => send('RETRY')}>재시도</button>;
if (state === 'success') return <Profile />;
return null;
}
boolean 플래그 조합 (isLoading, isError, isSuccess)으로 상태를 관리하면 조합이 기하급수적으로 늘어난다. 3개의 boolean이면 8가지 조합인데 그 중 유효한 건 4가지뿐이다. State Machine은 유효한 상태만 열거하니 이런 문제가 없다.
시각화: 상태 다이어그램
State Machine의 가장 큰 장점 중 하나는 시각화가 가능하다는 것이다. 전이 테이블 자체가 방향 그래프이기 때문에 상태 다이어그램으로 그릴 수 있다.
[Pending] --PAY--> [Paid] --SHIP--> [Shipping] --DELIVER--> [Delivered]
| |
+---CANCEL---+ +---CANCEL---+
| |
v v
[Cancelled] [Cancelled]
코드에서 전이 테이블을 추출해서 Mermaid나 Graphviz 형식으로 자동 생성할 수도 있다:
function toMermaid<S extends string, E extends string>(
config: StateMachineConfig<S, E>
): string {
const lines = ['stateDiagram-v2'];
for (const [state, events] of Object.entries(config.transitions)) {
for (const [event, transition] of Object.entries(events as any)) {
lines.push(` ${state} --> ${(transition as any).target}: ${event}`);
}
}
return lines.join('\n');
}
팀원이 코드를 처음 볼 때 이 다이어그램만으로 전체 비즈니스 플로우를 이해할 수 있다.
State Machine vs 단순 enum 상태 관리
"그냥 enum으로 상태 관리하면 되는 거 아닌가?"라는 의문이 들 수 있다. 차이를 비교해보면:
| 기준 | 단순 enum | State Machine |
|---|---|---|
| 잘못된 전이 방지 | 서비스 레이어에서 if/else로 검증 | 전이 테이블에서 구조적으로 차단 |
| 전체 흐름 파악 | 코드 전체를 읽어야 함 | 전이 테이블 하나로 파악 |
| 부수 효과 위치 | 서비스 레이어에 분산 | 전이 규칙에 함께 정의 |
| 새 상태 추가 | 모든 관련 코드를 찾아 수정 | 전이 테이블에 행 추가 |
| 시각화 | 불가능 | 자동 다이어그램 생성 가능 |
| 복잡도 정당성 | 상태 3개 이하면 충분 | 상태 4개 이상, 전이 규칙 복잡할 때 |
상태가 2~3개이고 전이가 단순하다면 State Machine은 과도한 추상화다. 하지만 상태가 4개 이상이거나, 전이에 조건(guard)이 필요하거나, 잘못된 전이가 비즈니스적으로 심각한 문제를 일으킬 수 있다면 State Machine이 정당화된다.
XState: 프로덕션 레벨 State Machine 라이브러리
직접 구현하는 것도 좋지만, 복잡한 요구사항에는 검증된 라이브러리가 더 적합할 수 있다. XState는 JavaScript/TypeScript 생태계에서 가장 널리 쓰이는 State Machine 라이브러리다.
import { createMachine, interpret } from 'xstate';
const orderMachine = createMachine({
id: 'order',
initial: 'pending',
states: {
pending: {
on: {
PAY: 'paid',
CANCEL: 'cancelled',
},
},
paid: {
on: {
SHIP: {
target: 'shipping',
cond: 'hasStock', // guard
},
CANCEL: {
target: 'cancelled',
actions: 'processRefund',
},
},
},
shipping: {
on: { DELIVER: 'delivered' },
},
delivered: { type: 'final' },
cancelled: { type: 'final' },
},
});
XState는 단순 FSM을 넘어서 Statechart를 지원한다. Statechart는 FSM에 계층적 상태(nested states), 병렬 상태(parallel states), 히스토리 상태(history states)를 추가한 확장 모델이다. 예를 들어 "배송중" 상태 안에 "포장", "운송", "배달" 하위 상태를 둘 수 있다.
하지만 대부분의 경우 앞에서 만든 간단한 구현으로 충분하다. 라이브러리 도입은 복잡도가 정당화될 때만 고려하면 된다.
정리
State Machine 패턴의 핵심을 요약하면:
- 상태를 명시적으로 열거한다. 문자열이 아닌 enum으로 가능한 상태를 제한한다.
- 전이 규칙을 선언적으로 정의한다. 전이 테이블 하나에 모든 규칙을 집중시킨다.
- 불가능한 전이를 구조적으로 차단한다. 테이블에 없는 전이는 실행 자체가 불가능하다.
- 부수 효과를 전이와 함께 관리한다. action, guard, onEnter/onExit로 관련 로직을 모은다.
상태 전이가 복잡한 도메인이라면 if/else 분기 대신 State Machine을 고려해보자. 코드가 자기 자신을 설명하는 문서가 된다.