junyeokk
Blog
Design Pattern·2026. 02. 15

State Machine 패턴

주문 상태를 관리하는 코드를 작성한다고 하자. 주문은 "대기 → 결제완료 → 배송중 → 배송완료" 순서로 진행되고, 취소도 가능하다. 처음에는 이렇게 작성하게 된다.

typescript
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)로 선언하는 것이다.

typescript
// 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이 없으니 불가능. 코드를 뒤질 필요가 없다.

typescript
// 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;
  }
}

사용 예시:

typescript
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)

실제 애플리케이션에서는 상태가 바뀔 때 무언가를 해야 한다. 결제 완료 시 영수증 발송, 취소 시 환불 처리 등. 이런 부수 효과를 전이 규칙에 함께 정의할 수 있다.

typescript
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을 실행하도록 수정한다:

typescript
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: 조건부 전이

전이가 항상 허용되는 건 아니다. "결제 완료 상태에서 배송으로 넘어가려면 재고가 있어야 한다" 같은 조건이 필요할 수 있다.

typescript
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(),
    },
  },
};
typescript
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과 달리 상태 자체에 바인딩된다.

typescript
const stateHooks: Partial<Record<OrderState, {
  onEnter?: () => void;
  onExit?: () => void;
}>> = {
  [OrderState.Shipping]: {
    onEnter: () => startTrackingTimer(),
    onExit: () => stopTrackingTimer(),
  },
  [OrderState.Cancelled]: {
    onEnter: () => archiveOrder(),
  },
};

전이 실행 순서는 이렇다:

  1. 현재 상태의 onExit 실행
  2. 전이의 action 실행
  3. 다음 상태의 onEnter 실행
typescript
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;
}

이 구분이 중요한 이유는 같은 상태로 들어오는 경로가 여러 개일 때 드러난다. "취소" 상태는 대기 중에서도, 결제 후에서도 진입할 수 있는데, onEnterarchiveOrder()를 한 번만 정의하면 어디서 진입하든 자동으로 실행된다. 전이별 action에 넣었다면 각 전이마다 중복해서 넣어야 한다.


제네릭 State Machine 클래스

매번 도메인별로 State Machine을 새로 만들 필요 없이, 제네릭으로 재사용 가능한 클래스를 만들 수 있다.

typescript
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);
    };
  }
}

사용 예시:

typescript
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으로 관리하면 도메인 규칙을 엔티티 안에 캡슐화할 수 있다.

typescript
@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();
  }
}

서비스 레이어에서는 엔티티의 메서드만 호출하면 된다:

typescript
@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 같은 흐름을 명시적으로 관리할 수 있다.

typescript
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에서 사용한다면:

tsx
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의 가장 큰 장점 중 하나는 시각화가 가능하다는 것이다. 전이 테이블 자체가 방향 그래프이기 때문에 상태 다이어그램으로 그릴 수 있다.

text
[Pending] --PAY--> [Paid] --SHIP--> [Shipping] --DELIVER--> [Delivered]
    |                 |
    +---CANCEL---+    +---CANCEL---+
                 |                 |
                 v                 v
             [Cancelled]      [Cancelled]

코드에서 전이 테이블을 추출해서 Mermaid나 Graphviz 형식으로 자동 생성할 수도 있다:

typescript
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으로 상태 관리하면 되는 거 아닌가?"라는 의문이 들 수 있다. 차이를 비교해보면:

기준단순 enumState Machine
잘못된 전이 방지서비스 레이어에서 if/else로 검증전이 테이블에서 구조적으로 차단
전체 흐름 파악코드 전체를 읽어야 함전이 테이블 하나로 파악
부수 효과 위치서비스 레이어에 분산전이 규칙에 함께 정의
새 상태 추가모든 관련 코드를 찾아 수정전이 테이블에 행 추가
시각화불가능자동 다이어그램 생성 가능
복잡도 정당성상태 3개 이하면 충분상태 4개 이상, 전이 규칙 복잡할 때

상태가 2~3개이고 전이가 단순하다면 State Machine은 과도한 추상화다. 하지만 상태가 4개 이상이거나, 전이에 조건(guard)이 필요하거나, 잘못된 전이가 비즈니스적으로 심각한 문제를 일으킬 수 있다면 State Machine이 정당화된다.


XState: 프로덕션 레벨 State Machine 라이브러리

직접 구현하는 것도 좋지만, 복잡한 요구사항에는 검증된 라이브러리가 더 적합할 수 있다. XState는 JavaScript/TypeScript 생태계에서 가장 널리 쓰이는 State Machine 라이브러리다.

typescript
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 패턴의 핵심을 요약하면:

  1. 상태를 명시적으로 열거한다. 문자열이 아닌 enum으로 가능한 상태를 제한한다.
  2. 전이 규칙을 선언적으로 정의한다. 전이 테이블 하나에 모든 규칙을 집중시킨다.
  3. 불가능한 전이를 구조적으로 차단한다. 테이블에 없는 전이는 실행 자체가 불가능하다.
  4. 부수 효과를 전이와 함께 관리한다. action, guard, onEnter/onExit로 관련 로직을 모은다.

상태 전이가 복잡한 도메인이라면 if/else 분기 대신 State Machine을 고려해보자. 코드가 자기 자신을 설명하는 문서가 된다.


관련 문서