복잡한 상태의 미로에서 탈출하기 (with XState)

hayden(헤이든)·2025년 12월 27일

프론트엔드 모음집

목록 보기
20/20
post-thumbnail

이 글은 XState v5 기준 공식문서 기반으로 작성되었습니다

개발하다 보면 수많은 useStateuseEffect가 뒤엉켜 "상태의 늪"에 빠지는 경험, 한 번쯤 있을 거예요.

// 익숙하지만 고통스러운 코드...
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState(null);
// 실수로 로딩 중인데 에러가 true로 남아있다면? 끔찍합니다. 🤪

변수가 늘어날수록 우리의 앱은 예측 불가능해집니다. 오늘은 이 복잡한 상태 관리를 수학적으로 깔끔하게 해결해 주는 XState와 그 기반 이론인 유한 상태 기계(FSM)에 대해 이야기해 보려 합니다.

1. 유한 상태 기계(Finite State Machine)

XState를 이해하려면 먼저 유한 상태 기계(Finite State Machine, FSM)가 무엇인지 알아야 합니다. 이름은 어렵지만 원리는 아주 간단합니다.

"시스템은 한 번에 오직 하나의 상태(State)만 가질 수 있다."

🚦신호등을 생각해 봅시다.

신호등은 '초록불'이면서 동시에 '빨간불'일 수 없습니다. 만약 두 불이 동시에 켜진다면 그건 고장 난 신호등(버그)입니다.

기존의 useState 방식이 변수들의 조합으로 상태를 추론하는 방식이라 버그 발생 가능성이 높다면, FSM은 "지금은 빨간불이야!" 라고 명확하게 하나의 상태를 지정해 주는 방식입니다.

이를 통해 '불가능한 상태(Impossible States)'가 발생하는 것을 원천 봉쇄합니다.

2. XState의 핵심 구성 요소 (feat. 자판기)

What is XState?
XState is a state management and orchestration solution for JavaScript and TypeScript apps.

It uses event-driven programming, state machines, statecharts, and the actor model to handle complex logic in predictable, robust, and visual ways. XState provides a powerful and flexible way to manage application and workflow state by allowing developers to model logic as actors and state machines. It integrates well with React, Vue, Svelte, and other frameworks and can be used in the frontend, backend, or wherever JavaScript runs.

State Machines

https://stately.ai/docs/state-machines-and-statecharts

XState를 다룬다는 것은 단순히 라이브러리를 쓰는 것이 아니라, '상태 차트(Statecharts)'라는 강력한 시각적 모델을 코드로 구현하는 것을 의미합니다. XState의 작동 방식을 우리에게 친숙한 자판기에 비유해 핵심 개념을 정리해 보겠습니다.

1️⃣ 상태 (States)

상태는 머신이 머물 수 있는 특정한 상황이나 모드를 말합니다. "시스템은 한 번에 오직 하나의 상태만 가질 수 있다"는 것이 핵심입니다.

  • idle: 동전이 투입되기를 기다리는 대기 상태 (Initial State)
  • selecting: 금액이 확인되어 상품을 고르는 상태
  • dispensing: 음료가 나오고 있는 상태
// 머신 정의 내부
states: {
  idle: {
    // 대기 중 상태에 대한 정의
  },
  selecting: {
    // 상품 선택 중 상태에 대한 정의
  },
  dispensing: {
    // 음료 나오는 중 상태에 대한 정의
  }
}

2️⃣ 이벤트 (Events)

Transition을 일으키는(상태를 변화시키는) 외부의 자극입니다.

  • INSERT_COIN (동전 넣기)
  • SELECT_ITEM (버튼 누르기)
// idle 상태 내부
idle: {
  on: { // 이 상태에서 받을 수 있는 이벤트들
    INSERT_COIN: { // 'INSERT_COIN' 이벤트가 발생하면...
      target: 'selecting' // 'selecting' 상태로 전이(이동)한다
    }
  }
}

3️⃣ 전이 (Transitions)

이벤트가 발생했을 때 다음 상태로 이동하는 규칙입니다.

대기 중(idle) 상태에서 -> 동전 넣기(INSERT_COIN) 이벤트 발생 시 -> 선택 중(selecting) 상태로 이동

import { createMachine } from 'xstate';

const vendingMachine = createMachine({
  id: 'vending',
  initial: 'idle', // 초기 상태 설정
  states: {
    // 1. 대기 중
    idle: {
      on: {
        INSERT_COIN: { target: 'selecting' } // 돈을 넣으면 -> 선택 중으로
      }
    },
    // 2. 상품 선택 중
    selecting: {
      on: {
        SELECT_ITEM: { target: 'dispensing' }, // 버튼을 누르면 -> 배출 중으로
        CANCEL: { target: 'idle' }             // 취소하면 -> 다시 대기 중으로
      }
    },
    // 3. 음료 나오는 중
    dispensing: {
      // (예시) 음료 배출이 끝나면 자동으로 대기 상태로 복귀
      after: {
        2000: { target: 'idle' } // 2초 후 idle로 이동
      }
    }
  }
});

특정 상태에서 특정 이벤트가 발생했을 때 갈 수 있는 다음 상태는 언제나 명확하게 정해져 있습니다. 여기서의 핵심은 돈을 넣지 않았는데 음료가 나올 수 없듯이, XState는 정해진 상태에서 허용된 이벤트가 발생했을 때만 다음 단계로 넘어갑니다.

Actor Model

https://stately.ai/docs/actor-model

컴퓨터 과학에서 Actor model은 병렬 연산을 위한 수학적 모델이지만, 프론트엔드 개발에서도 중요한 시사점을 줍니다

현대의 웹 앱은 더 이상 단일한 '진실의 원천(SSOT)'을 가질 수 없기 때문입니다. 로컬 상태, URL, 외부 API, DOM 이벤트 등 수많은 독립적인 요소들이 얽혀 있는 브라우저는 그 자체로 하나의 복잡한 분산 시스템이며, 액터 모델은 이를 관리하는 최적의 도구가 됩니다."

1️⃣ Actor란

액터 모델의 가장 기본이 되는 독립적인 연산 단위입니다. XState에서 상태 머신이 실행되면 하나의 '액터'가 됩니다.

액터의 4가지 핵심 특징

  • 독립성과 캡슐화 (Encapsulation): 액터는 완전히 독립적인 "살아있는" 객체입니다. 자신의 상태는 오직 자기 자신만 수정할 수 있습니다. (외부에서 직접 접근 불가)
  • 비동기 메시지 통신 (Message Passing): 액터끼리는 오직 이벤트(메시지)를 주고받으며 소통합니다. 마치 우리가 이메일을 주고받는 것과 같습니다.
  • 우편함(Mailbox) 시스템: 액터는 들어온 이벤트를 '우편함'에 쌓아두고 한 번에 하나씩 순서대로(Sequential) 처리합니다. 덕분에 복잡한 비동기 상황에서도 경쟁 상태(Race Condition)가 발생하지 않습니다.
  • 유연한 확장과 위임 (Spawn/Invoke): 액터는 필요에 따라 새로운 자식 액터를 생성하여 작업을 위임할 수 있습니다. 이를 통해 복잡한 비즈니스 로직을 계층적으로 구조화하고 관리합니다.

2️⃣ 액터 만들기

상태 머신(createMachine)이 일종의 설계도라면, 액터(createActor)는 그 설계도를 바탕으로 실제로 고용된 일꾼입니다.

import { createMachine, createActor } from 'xstate';

const vendingMachine = createMachine({
  id: 'vending',
  initial: 'idle',
  states: {
    idle: {
      on: { INSERT_COIN: 'selecting' }
    },
    selecting: {
      on: { SELECT_ITEM: 'dispensing' }
    },
    dispensing: { /* ... */ }
  }
});

// 설계도(Logic)를 바탕으로 실제 살아있는 액터(Actor) 생성
const actor = createActor(vendingMachine);
actor.subscribe((state) => console.log(state.value));
actor.start();

// 메시지(이벤트) 전달
actor.send({ type: 'INSERT_COIN' });

3️⃣ 자식 액터 생성하기(spawn)

액터는 새로운 액터를 만들 수 있습니다. 이를 통해 복잡한 일을 다른 일꾼에게 위임(Delegate)할 수 있습니다.

import { createMachine, assign, spawnChild } from 'xstate';

const parentMachine = createMachine({
  context: ({ spawn }) => ({
    // 결제 로직을 담당하는 별도의 일꾼을 생성(spawn)하여 보관
    paymentActor: spawn('paymentLogic')
  }),
  on: {
    REQUEST_PAYMENT: {
      // 결제 일꾼에게 "결제 시작해!"라고 메시지 전송
      actions: sendTo(({ context }) => context.paymentActor, { type: 'START' })
    }
  }
});

3. XState 심화 과정

다양한 상태(States)

단순한 상태만 있어서는 복잡한 어플리케이션을 대응할 수 없습니다. 복잡한 상태들도 다룰 수 있도록 되어 있습니다.

1. Initial & Final States (시작과 끝)

https://stately.ai/docs/initial-states, https://stately.ai/docs/finite-states

모든 머신은 시작점이 있고, 때로는 명확한 종료 지점이 있습니다.

이를 Finite States (유한 상태)라고 할 수 있습니다. 즉, 유한 상태 기계에서 '유한'은 우리가 제어할 수 있는 범위 안에서만 앱이 움직인다는 것을 보장합니다.

  • Initial State: 머신이 인스턴스화될 때 가장 먼저 진입하는 상태입니다. (자판기의 idle)
  • Final State: 해당 머신이나 부모 상태의 작업이 완전히 끝났음을 의미합니다.

예시: 자판기에서 '잔액 반환 완료' 상태가 되면 해당 거래 세션은 완전히 종료(final)됩니다.

const vendingMachine = createMachine({
  initial: 'idle', // 시작 상태
  states: {
    idle: { /* ... */ },
    returnChange: {
      type: 'final' // 종료 상태: 이후엔 어떤 이벤트도 받지 않음
    }
  }
});

2. Parent & Child States (계층적 상태)

https://stately.ai/docs/parent-states

상태 안에 또 다른 상태를 넣을 수 있습니다. 이를 복합 상태(Compound States)라고도 합니다.

예시: 자판기가 '판매 중(active)' 상태일 때, 그 안에서는 '금액 투입됨(waitingForCoin)', '상품 선택 중(selecting)' 같은 세부 상태들이 돌아갑니다. 판매 중이라는 큰 틀을 벗어나지 않으면서 내부 로직을 격리할 수 있습니다.

active: {
  // 내부 상태중 하나로 초기값을 설정해야 합니다.
  initial: 'waitingForCoin',
  states: {
    waitingForCoin: { on: { INSERT: 'selecting' } },
    selecting: { /* ... */ }
  }
}

부모 상태를 가지는 상태 기계는 복잡해질 수 있기에 Best Practices들을 따라야 합니다. (https://stately.ai/docs/parent-states#modeling)

특히 아래와 같은 상황에서는 부모 상태를 피해야 합니다.

  • When states don't share any common behavior or transitions
  • When the hierarchy would make the state machine more complex without adding value
  • When the states represent completely independent features

3. Parallel States (병렬 상태)

https://stately.ai/docs/parallel-states

여러 상태가 독립적으로 존재해야 할 때 사용합니다. 한 상태가 다른 상태에 영향을 주지 않고 각각의 흐름을 가집니다.

예시: 최신형 자판기라면 '판매 시스템'이 돌아가는 동시에, 화면 한쪽에서는 '광고 영상 플레이어'가 재생되고 있을 것입니다. 광고가 끝난다고 해서 판매 시스템이 멈추면 안 되겠죠? 이럴 때 두 상태를 병렬로 둡니다.

// 병렬 상태라는 것을 명시합니다.
type: 'parallel',
states: {
  vendingLogic: { initial: 'idle', states: { ... } },
  advertisement: { initial: 'playing', states: { ... } }
}

병렬 상태를 사용할 때에도 중요한 사항이 있습니다. (https://stately.ai/docs/parallel-states#modeling)

  • Avoid transitions between regions: 각 영역은 독립적이여야 하며, 다른 영역의 상태로 직접 전환되어서는 안됩니다.
  • Consider synchronization: 각 지역은 독립적으로 운영되지만 필요에 따라 서로 협력해야 할 수 있기에 여러 지역에서 처리할 수 있는 이벤트를 활용하세요.

4. History States (히스토리 상태)

이전의 상태를 '기억'했다가 다시 돌아왔을 때 복구해 주는 특수한 상태입니다.
Shallow 와 Deep history도 존재합니다.

  • Shallow history: 바로 직전에 활성화되었던 자식 상태를 기억합니다.
  • Deep history: 모든 활성 하위 상태를 기억합니다.

예시: 사용자가 상품을 선택하던 중(selecting), 자판기 '설정 모드'를 들어갔다가 나왔다고 해봅시다. 이때 처음부터 다시 돈을 넣는 게 아니라, 이전에 작업하던 selecting 단계로 바로 복구시켜 줄 때 사용합니다.

states: {
  shopping: {
    initial: 'browsing',
    states: { 
      browsing: {}, 
      selecting: {}, 
      hist: { type: 'history' } 
    }
  },
  settings: {
    on: { BACK: 'shopping.hist' } // 이전의 세부 상태로 복구!
  }
}

전이(Transition)의 다채로운 문법

앞서 본 전이는 가장 기본적인 이동입니다. 하지만 현실 세계의 자판기는 훨씬 복잡합니다. 돈이 부족하면 버튼을 눌러도 음료를 주지 않고(조건), 동전을 넣으면 잔액 표시를 갱신합니다.(액션).

1. Actions (액션): "이동하면서 수행할 부수 효과"

https://stately.ai/docs/actions

상태가 바뀔 때 단순히 이동만 하는 게 아니라, 콘솔을 찍거나 데이터를 저장하는 등 '실행 후 잊어버리는(Fire-and-forget)' 부수 효과를 정의합니다.

예시: "동전을 넣어서(Event) '선택 중' 상태로 갈 때(Transition), 자판기 화면의 잔액 숫자를 업데이트(Action)해라."

XState에서는 액션을 세 가지 시점에서 실행할 수 있습니다.

  • Transition Actions: 특정 이벤트가 발생해 상태가 바뀔 때 실행 (가장 일반적)
  • Entry Actions: 어떤 경로로든 해당 상태에 진입할 때 실행
  • Exit Actions: 해당 상태에서 벗어날 때 실행
// 자판기 예시
states: {
  dispensing: {
    // 이 상태에 들어오자마자 음료 배출 시작!
    entry: ['startDispensing'],
    // 이 상태를 나갈 때(완료될 때) 감사 메시지 출력
    exit: ['showThankYouMessage'],
    on: {
      CANCEL: { target: 'idle', actions: ['refundMoney'] } // 전이 액션
    }
  }
}

1. 내장 액션(Built-in Actions)을 활용
단순한 함수 실행 외에도 XState가 제공하는 특별한 액션들이 있습니다. 이들은 단순히 부수 효과를 일으키는 게 아니라 머신의 로직 그 자체를 제어합니다.

  • assign(): context(데이터)를 업데이트할 때 사용합니다. (가장 중요!)
  • raise(): 자기 자신에게 이벤트를 다시 보냅니다. (내부 로직 연결 시 유용)
  • sendTo(): 다른 액터(일꾼)에게 이벤트를 보냅니다.
  • log(): 콘솔 로그를 남기는 편리한 방법입니다.

2. 더 정교한 제어: enqueueActions
여러 액션을 순차적으로 실행하거나, 조건에 따라 액션을 실행하고 싶을 때 사용합니다.

actions: enqueueActions(({ enqueue, check }) => {
  // 1. 데이터 업데이트 예약
  enqueue.assign({ count: (c) => c + 1 });
  
  // 2. 조건부 액션 예약
  if (check('isVipCustomer')) {
    enqueue('giveBonusPoint');
  }
  
  // 3. 다른 액터에게 알림
  enqueue.sendTo('logger', { type: 'LOG_EVENT' });
})

2. Guards (가드/조건): "조건부 이동"

https://stately.ai/docs/guards

이벤트가 발생했다고 무조건 이동하는 게 아닙니다. 특정 조건(guard 또는 cond)이 true일 때만 전이가 일어납니다.

예시: "음료 버튼을 눌렀지만(Event), 투입된 금액이 음료 가격보다 적다면(Guard) 음료 배출 상태로 이동하지 마라."

// selecting 상태
on: {
  SELECT_ITEM: {
    target: 'dispensing',
    // 이 함수가 true를 반환해야만 dispensing으로 넘어감
    guard: ({ context }) => context.insertedMoney >= context.itemPrice
  }
}

XState에서는 이외에도 가드를 할 수 있는 다양한 것들이 존재합니다.

1. 인라인 함수 (Inline): 간단한 로직에 적합합니다.

guard: ({ context }) => context.count > 0

2. 문자열 참조 (Named Guards): 머신 정의를 깔끔하게 유지하고 재사용할 때 권장됩니다.

// selecting 상태
on: {
  SELECT_ITEM: {
    target: 'dispensing',
    // 이 함수가 true를 반환해야만 dispensing으로 넘어감
    guard:  { type: 'isValid' }
  },
  {
    guards: {
      isValid: ({ context }, params) => {
        return context.insertedMoney >= context.itemPrice
      },
    },
  },
}

3. 객체 구문 (Higher-level Operators)

  • and([...]): 모든 조건이 true여야 통과 (AND)
  • or([...]): 하나라도 true면 통과 (OR)
  • not(...): 조건을 반전 (NOT)
guard: and(['isLoggedIn', 'hasPermission'])

3. Multiple Targets (다중 분기)

하나의 이벤트에 대해 여러 개의 전이 규칙을 배열로 정의할 수 있습니다. 위에서부터 순서대로 조건을 확인하고, 가장 먼저 만족하는 경로를 탑니다.

예시: "버튼을 눌렀을 때, 돈이 충분하면 '배출 중'으로 가고, 돈이 부족하면 '오류 메시지' 상태로 가라."

on: {
  SELECT_ITEM: [
    // 1순위: 돈이 충분한가? -> 배출
    { 
      target: 'dispensing', 
      guard: 'isEnoughMoney' 
    },
    // 2순위: 위 조건이 실패했는가? -> 잔액 부족 알림 (else와 비슷)
    { 
      target: 'showInsufficientFundsError' 
    }
  ]
}

4. Self-Transitions (자기 전이): "제자리 걸음"

target을 지정하지 않거나, 현재 상태와 동일한 상태를 지정하는 경우입니다. 상태는 바뀌지 않지만 actions를 실행하고 싶을 때 사용합니다.

예시: "상품 선택 중에 동전을 추가로 더 넣는 경우, 상태는 여전히 '선택 중'이지만 잔액만 증가(Action)시킨다."

// selecting 상태
on: {
  INSERT_ADDITIONAL_COIN: {
    // target이 없음 (자기 전이)
    actions: ['addMoneyToContext'] 
  }
}

5. Context Updates (assign): "데이터 변경"

https://stately.ai/docs/context

XState에서 상태(State)가 '신호등 색깔'이라면, 문맥(Context)은 '세부 데이터'입니다. assign 액션을 사용해 데이터를 변경합니다.

예시: "동전을 넣으면 기계 내부의 총 금액 데이터(Context)를 갱신해라."

import { assign } from 'xstate';

const vendingMachine = createMachine({
  id: 'vending',
  // 초기 데이터 설정
  context: {
    insertedMoney: 0,
    inventory: {
      coke: 5,
      water: 10
    },
    message: '금액을 투입하세요'
  },
  initial: 'idle',
  // ...
  actions: assign({
  	insertedMoney: ({ context, event }) => context.insertedMoney + event.amount
	})
});

State vs Context 차이점

  • State (상태): "자판기가 지금 어떤 모드인가?" (예: 대기 중, 판매 중, 고장) → 질적인 상태
  • Context (문맥): "자판기 안에 돈이 얼마 있는가? 재고는 몇 개인가?" → 양적인 데이터

용도에 맞는 Actor(일꾼) 고용하기

1. State Machine Actors

https://stately.ai/docs/state-machine-actors

가장 강력한 액터로, 복잡한 상태 전환과 로직을 담당합니다.

예시: 자판기의 메인 마더보드. 전체 흐름(대기 → 결제 → 배출)을 총괄합니다.

import { createMachine, createActor } from 'xstate';

// 1. 설계도 정의
const toggleMachine = createMachine({
  initial: 'inactive',
  states: {
    inactive: { on: { toggle: 'active' } },
    active: { on: { toggle: 'inactive' } },
  },
});

// 2. 액터 생성 (일꾼 고용)
const toggleActor = createActor(toggleMachine);

// 3. 상태 구독 (snapshot을 통해 현재 상태 확인)
toggleActor.subscribe((snapshot) => {
  console.log('현재 상태:', snapshot.value);
});

// 4. 실행
toggleActor.start();
toggleActor.send({ type: 'toggle' }); // 'active' 로그 출력

자판기를 처음 가동할 때, 기본 음료 가격이나 초기 재고를 설정해야 하듯이 액터에게 외부 데이터를 전달할 수 있습니다.

import { setup, createActor } from 'xstate';

const vendingMachine = setup({
  // setup을 통해 머신의 환경을 미리 설정할 수 있습니다.
}).createMachine({
  context: ({ input }) => ({
    // 외부에서 주입된 'price' 값을 초기 컨텍스트로 사용
    itemPrice: input.defaultPrice,
    insertedMoney: 0
  }),
  initial: 'idle',
  /* ... 나머지 상태 정의 ... */
});

// 액터 생성 시 초기값 전달
const vendingActor = createActor(vendingMachine, {
  input: {
    defaultPrice: 1500
  },
});

vendingActor.start();

2. Promise Actors

https://stately.ai/docs/promise-actors

Promise Actor는 한 번 실행하면 성공(resolve) 또는 실패(reject)의 결과를 반환하고 즉시 업무를 마치는 일꾼입니다.

핵심 특징

  • 이벤트 수신 불가: 실행 중인 Promise Actor에게 외부에서 이벤트를 보낼 수는 없습니다. (오직 기다릴 뿐입니다.)
  • 입력(Input) 가능: 실행 시 필요한 데이터를 넘겨줄 수 있습니다.
  • 출력(Output) 반환: 작업이 완료되면 결과값(output)을 반환합니다.

자판기에서 카드 결제 승인을 기다리는 로직을 예로 들어보겠습니다.

import { fromPromise, createActor } from 'xstate';

// 1. 비동기 로직 정의 (카드 승인 요청)
const payLogic = fromPromise(async ({ input, signal }) => {
  // input으로 받은 금액만큼 결제 요청
  // signal은 작업 중단(Abort) 시 사용됩니다.
  const response = await fetch('/api/pay', {
    method: 'POST',
    body: JSON.stringify({ amount: input.amount }),
    signal
  });
  
  if (!response.ok) throw new Error('결제 실패');
  return await response.json();
});

// 2. 액터 생성 및 실행
const payActor = createActor(payLogic, {
  input: { amount: 1500 } // 초기 데이터 주입
});

// 3. 상태 구독 (Snapshot 확인)
payActor.subscribe((snapshot) => {
  if (snapshot.status === 'active') console.log('결제 진행 중...');
  if (snapshot.status === 'done') console.log('결제 완료:', snapshot.output);
});

payActor.start();

toPromise로 기다리기
액터를 다시 Promise로 변환하여 await 키워드로 결과를 기다릴 수도 있습니다.

import { toPromise } from 'xstate';
const output = await toPromise(payActor);
console.log('최종 결과:', output);

3. Transition Actors

https://stately.ai/docs/transition-actors

복잡한 상태 전환 없이, 데이터(Context)만 업데이트하는 가벼운 일꾼입니다.

핵심 특징

  • Reducer 방식: (현재 상태, 이벤트) => 다음 상태라는 명확하고 단순한 논리로 작동합니다.
  • 이벤트 수신 가능: 외부에서 보내는 이벤트를 받아 자신의 상태를 변경합니다.
  • 경량 로직: createMachine처럼 거창한 구조 없이도 가볍게 데이터를 관리할 수 있습니다.
  • 입력(Input) 가능: 시작할 때 초기값을 주입받을 수 있습니다.

자판기의 '투입 금액 합계 계산기'를 예로 들어보겠습니다. 동전이 들어올 때마다 금액을 더하기만 하면 됩니다.

import { fromTransition, createActor } from 'xstate';

// 1. 로직 정의: (현재 상태, 이벤트) => 다음 상태
const amountLogic = fromTransition(
  (state, event) => {
    switch (event.type) {
      case 'INSERT':
        return { total: state.total + event.value };
      case 'RESET':
        return { total: 0 };
      default:
        return state;
    }
  },
  { total: 0 } // 초기 상태
);

// 2. 액터 생성 및 실행
const amountActor = createActor(amountLogic);

amountActor.subscribe((snapshot) => {
  console.log('현재 합계:', snapshot.context.total);
});

amountActor.start();
amountActor.send({ type: 'INSERT', value: 500 }); // "현재 합계: 500"
amountActor.send({ type: 'INSERT', value: 100 }); // "현재 합계: 600"

4. Callback Actors

https://stately.ai/docs/callback-actors

Callback Actor는 한 번 고용되면 특정 상태가 유지되는 동안 계속 자리를 지키며, 외부 이벤트를 감지해 부모에게 보고하거나(sendBack) 부모의 지시를 기다리는(receive) 일꾼입니다.

핵심 특징

  • 실시간 감지: DOM 이벤트, 소켓 연결 등 외부에서 발생하는 신호를 계속 들여다볼 수 있습니다.
  • 양방향 소통: 부모에게 메시지를 보낼 수 있고(sendBack), 부모가 보낸 메시지를 받을 수도 있습니다(receive).
  • 출력 없음 (No Output): Promise처럼 결과를 내고 끝내는 게 아니라, 중단될 때까지 무기한 활동합니다.
  • 정리(Cleanup) 필수: 액터가 중단될 때 리스너를 제거하는 등 마무리 작업을 수행해야 합니다.

자판기의 '비상 정지 버튼' 감시 로직을 예로 들어보겠습니다.

import { fromCallback, createActor, setup, sendTo } from 'xstate';

// 1. 연락책 로직 정의
const emergencyLogic = fromCallback(({ sendBack, receive, input }) => {
  console.log(`기본 민감도 ${input.sensitivity}로 감시 시작`);

  const handler = (event: MouseEvent) => {
    // 부모에게 'EMERGENCY' 메시지를 보냄
    sendBack({ type: 'EMERGENCY', x: event.clientX });
  };

  // 외부 이벤트(비상 버튼 클릭) 리스너 등록
  const btn = document.getElementById('emergency-btn');
  btn?.addEventListener('click', handler);

  // 부모로부터 오는 메시지 수신
  receive((event) => {
    if (event.type === 'MUTE') {
      console.log('경보음 무소음 모드 전환');
    }
  });

  // 2. 정리 함수 (Cleanup): 액터가 멈출 때 실행됨
  return () => {
    console.log('감시 종료 및 리스너 제거');
    btn?.removeEventListener('click', handler);
  };
});

// 3. 머신에서 사용하기
const machine = setup({
  actors: { emergencyLogic }
}).createMachine({
  invoke: {
    id: 'emergency-monitor',
    src: 'emergencyLogic',
    input: { sensitivity: 5 }
  },
  on: {
    MUTE_ALARM: {
      actions: sendTo('emergency-monitor', { type: 'MUTE' })
    }
  }
});

5. Observable Actors

https://stately.ai/docs/observable-actors

Observable Actor는 시간의 흐름에 따라 연속적으로 발생하는 값의 스트림을 나타내는 일꾼입니다. 한 번의 결과만 주는 Promise와 달리, 중단되기 전까지 새로운 소식을 계속해서 전달합니다.

핵심 특징

  • 스트림 기반: 데이터가 발생할 때마다 구독자(부모)에게 최신 상태를 전달합니다.
  • 이벤트 수신 불가: 외부에서 이벤트를 보낼 수는 없지만, 스스로 데이터를 계속 배출합니다.
  • RxJS 호환: RxJS 같은 외부 라이브러리와 연동하여 강력한 데이터 처리가 가능합니다.
  • 입력(Input) 가능: 초기 구독 시 필요한 설정값(예: 갱신 주기)을 넘겨줄 수 있습니다.

자판기 내부의 '실시간 온도 감지기' 로직을 RxJS의 interval을 이용해 예로 들어보겠습니다.

import { fromObservable, createActor } from 'xstate';
import { interval, map } from 'rxjs';

// 1. 소식통 로직 정의 (온도 감지)
const temperatureLogic = fromObservable(({ input }) => {
  // input으로 받은 주기마다 온도를 측정하는 스트림 생성
  return interval(input.ms).pipe(
    map(() => ({
      temp: 4 + Math.random(), // 4도 근처의 실시간 온도 생성
      timestamp: Date.now()
    }))
  );
});

// 2. 액터 생성 및 실행
const tempActor = createActor(temperatureLogic, {
  input: { ms: 5000 } // 5초마다 보고
});

// 3. 상태 구독 (Snapshot을 통해 소식 듣기)
tempActor.subscribe((snapshot) => {
  // Observable의 최신 값은 snapshot.context에 담깁니다.
  console.log('현재 자판기 온도:', snapshot.context);
});

tempActor.start();

6. Invoke/Spawn

Invoke: https://stately.ai/docs/invoke
Spawn: https://stately.ai/docs/spawn

Invoke(호출)이란?

상태 머신의 특정 상태(State)에 진입할 때 시작되고, 그 상태를 벗어날 때 중단되는 외부 프로세스(액터)를 실행하는 메커니즘입니다.

액터(Actor) vs 액션(Action)의 차이

액션(Action): "실행 후 잊어버리는(Fire-and-forget)" 방식입니다. 실행 즉시 종료되며, 비동기 처리를 기다려주지 않습니다.

Invoke Actor: "생명주기 관리" 방식입니다. 특정 상태에 들어갈 때 시작되고, 그 상태를 벗어나면 자동으로 중단(Cleanup)됩니다. 비동기 작업의 결과를 기다려 onDone이나 onError로 후속 처리를 할 수 있습니다.

⚠️ Invoke 재실행 주의
동일한 상태로 다시 전이할 경우, 기본적으로 invoke는 재실행되지 않습니다.
같은 작업을 다시 실행하려면 reenter: true 옵션이 필요합니다.

REFRESH: { target: 'loading', reenter: true }
import { setup, fromPromise, assign } from 'xstate';
import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient();

export const userMachine = setup({
  types: {
    context: {} as { userData: any; error: any },
  },
  actors: {
    fetchUser: fromPromise(async ({ input }: { input: { userId: string } }) => {
      // TanStack Query의 fetchQuery를 사용하여 캐싱 기능을 그대로 활용!
      return await queryClient.fetchQuery({
        queryKey: ['user', input.userId],
        queryFn: () => fetch(`/api/user/${input.userId}`).then(res => res.json()),
        staleTime: 10000, // 10초 동안은 캐시된 데이터 사용
      });
    }),
  },
}).createMachine({
  initial: 'idle',
  context: { userData: null, error: null },
  states: {
    idle: {
      on: { FETCH: 'loading' },
    },
    loading: {
      invoke: {
        src: 'fetchUser',
        input: ({ event }) => ({ userId: event.userId }),
        onDone: {
          target: 'success',
          actions: assign({ userData: ({ event }) => event.output }),
        },
        onError: {
          target: 'failure',
          actions: assign({ error: ({ event }) => event.error }),
        },
      },
    },
    success: {
      on: { REFRESH: 'loading' }
    },
    failure: {
      on: { RETRY: 'loading' }
    },
  },
});

Spawn(생성)이란?
invoke가 상태(State)의 수명 주기에 종속적이라면, spawn은 상태와 상관없이 동적으로 액터를 생성하고 독립적인 수명 주기를 부여하는 방식입니다.

상태와 관계없이 액터를 동적으로 생성하며, 각각의 액터는 자신만의 수명을 가집니다.

⚠️ Spawn 사용 시 반드시 지켜야 할 "Clean-up" 규칙

Spawn은 편리하지만, 관리를 소홀히 하면 메모리 누수(Memory Leak)의 주범이 됩니다.

  1. 수동 종료의 책임 Invoke는 상태를 벗어나면 자동으로 죽지만, Spawn은 직접 죽여야 합니다. stopChild(id) 액션을 잊지 마세요.

  2. Context 액터를 중지(stopChild)하더라도 context에 저장된 참조(Ref) 값은 자동으로 지워지지 않습니다. 반드시 assign을 병행하여 참조를 제거해야 합니다.

import { setup, assign, spawn, stopChild, createMachine } from 'xstate';

const timerMachine = createMachine({ /* 개별 타이머 로직 */ });

export const managerMachine = setup({
  types: {
    context: {} as { timers: any[] }
  }
}).createMachine({
  initial: 'active',
  context: { timers: [] },
  states: {
    active: {
      on: {
        // 💡 Spawn: 버튼을 누를 때마다 독립적인 타이머 액터를 '동적'으로 생성
        ADD_TIMER: {
          actions: assign({
            timers: ({ context, spawn }) => [
              ...context.timers,
              spawn(timerMachine, { id: `timer-${Date.now()}` }) 
            ]
          })
        },
        // 💡 Stop: 수동으로 특정 액터만 지목해서 꺼줘야 함
        REMOVE_TIMER: {
          actions: [
            stopChild(({ event }) => event.id),
            assign({
              timers: ({ context, event }) => 
                context.timers.filter(t => t.id !== event.id)
            })
          ]
        }
      }
    }
  }
});
구분Invoke (호출)Spawn (생성)
핵심 요약상태(State)의 종속물독립적인 액터(Entity)
수명 주기상태 진입 시 시작, 퇴장 시 자동 종료직접 stopChild를 호출할 때까지 유지
관리 주체상태 머신의 상태 노드가 관리부모 머신의 context에서 참조 관리
생성 시점해당 상태에 머무는 동안 (정적)이벤트 발생 시 assign 액션 내에서 (동적)
통신 패턴onDone, onError로 결과 수신sendTo, onSnapshot 등으로 지속적 소통
메모리 관리자동 (신경 쓸 필요 없음)수동 (중지 후 context 정제 필수)
주요 사례API 요청, 폼 검증, 3분 타이머채팅방 목록, 알림(Toast), 개별 주식 시세

언제 무엇을 선택해야 할까?

  • ✅ 작업이 특정 상태/화면에 묶여 있다 → Invoke
  • ✅ 완료/실패가 명확한 비동기 작업이다 → Invoke
  • ✅ 여러 개의 독립적인 객체를 관리해야 한다 → Spawn
  • ✅ 객체가 상태를 오래 유지한다 → Spawn

4. 실전 예제: 구매 프로세스

이제 이론을 코드로 옮겨보겠습니다. 단순한 클릭이 아니라 재고 확인 -> 결제 수단 선택 -> 결제 처리로 이어지는 복잡한 구매 로직을 XState로 구현해 보겠습니다.

1) 구매 머신 정의 (purchaseMachine.ts)

import { setup, assign, fromPromise } from 'xstate';

// 1. setup을 통해 머신에서 사용할 로직과 타입을 미리 정의합니다.
export const purchaseMachine = setup({
  types: {
    context: {} as {
      itemId: string;
      price: number;
      paymentMethod: string;
      error: string;
    },
    events: {} as
      | { type: 'SELECT_METHOD'; method: string }
      | { type: 'CONFIRM' }
      | { type: 'RETRY' },
    input: {} as { itemId: string; price: number }
  },
  actors: {
    // 비동기 로직을 액터로 등록
    checkInventory: fromPromise(async ({ input }: { input: { itemId: string } }) => {
      const response = await fetch(`/api/inventory/${input.itemId}`);
      const data = await response.json();
      if (data.stock <= 0) throw new Error('재고 없음');
      return data;
    }),
    processPayment: fromPromise(async ({ input }: { input: { amount: number } }) => {
      await new Promise((res) => setTimeout(res, 2000)); // 지연 시뮬레이션
      return { success: true };
    }),
  },
  guards: {
    // 가드 로직을 이름으로 관리
    hasSelectedMethod: ({ context }) => !!context.paymentMethod,
  }
}).createMachine({
  id: 'purchase',
  initial: 'checkingInventory',
  // input을 통해 외부 데이터를 context 초기값으로 주입
  context: ({ input }) => ({
    itemId: input.itemId,
    price: input.price,
    paymentMethod: '',
    error: '',
  }),
  states: {
    // 단계 1: 재고 확인
    checkingInventory: {
      invoke: {
        src: 'checkInventory',
        input: ({ context }) => ({ itemId: context.itemId }),
        onDone: {
          target: 'selectingPayment'
        },
        onError: {
          target: 'failure',
          actions: assign({ error: '재고가 부족하거나 서버 오류가 발생했습니다.' })
        }
      }
    },
    // 단계 2: 결제 수단 선택
    selectingPayment: {
      on: {
        SELECT_METHOD: {
          actions: assign({ paymentMethod: ({ event }) => event.method })
        },
        CONFIRM: {
          target: 'processingPayment',
          guard: 'hasSelectedMethod'
        }
      }
    },
    // 단계 3: 결제 처리
    processingPayment: {
      invoke: {
        src: 'processPayment',
        input: ({ context }) => ({ amount: context.price }),
        onDone: {
          target: 'success'
        },
        onError: {
          target: 'failure',
          actions: assign({ error: '결제 승인 중 오류가 발생했습니다.' })
        }
      }
    },
    success: {
      type: 'final'
    },
    failure: {
      on: {
        RETRY: 'checkingInventory'
      }
    }
  }
});

그림으로 표현하면 다음과 같은 상태머신이 나오게 됩니다.

2) 리액트 컴포넌트 적용 (Checkout.tsx)

import React from 'react';
import { useMachine } from '@xstate/react';
import { purchaseMachine } from './purchaseMachine';

export function Checkout({ itemId, price }: { itemId: string, price: number }) {
  // input을 통해 컴포넌트의 props를 머신에 전달합니다.
  const [state, send] = useMachine(purchaseMachine, {
    input: { itemId, price }
  });

  // matches를 통한 상태 분기
  if (state.matches('checkingInventory') || state.matches('processingPayment')) {
    return <div>진행 중...</div>;
  }

  if (state.matches('success')) {
    return <div>🎉 결제 완료!</div>;
  }

  return (
    <div>
      <h2>결제 금액: {state.context.price}</h2>
      {state.matches('failure') && <p style={{ color: 'red' }}>{state.context.error}</p>}
      
      <button onClick={() => send({ type: 'SELECT_METHOD', method: 'card' })}>
        신용카드 {state.context.paymentMethod === 'card' && '✅'}
      </button>
      
      <button 
        disabled={!state.can({ type: 'CONFIRM' })} 
        onClick={() => send({ type: 'CONFIRM' })}
      >
        {state.matches('failure') ? '다시 결제하기' : '결제하기'}
      </button>
    </div>
  );
}

이 예제에서 XState 사용의 장점

  • state.matches(): "현재 데이터가 로딩 중인가?"를 체크하기 위해 isLoading 변수를 뒤지는 대신, 머신이 어느 "상태(State)"에 있는지를 물어봅니다.

  • state.can(): "지금 상황에서 결제하기 버튼을 눌러도 되는가?"라는 비즈니스 로직을 UI 코드에 if (method && !loading) 식으로 넣지 않습니다. 머신에게 물어보면 Guards 조건을 확인하여 자동으로 버튼 활성 여부를 판단해 줍니다.

  • 비동기 격리: fetch 로직이 컴포넌트 내부에 섞이지 않고 fromPromise 액터로 분리되어 있습니다. UI는 그저 메시지를 보내고 결과(성공/실패)를 기다리기만 하면 됩니다.

5. 왜 굳이 XState를 써야 할까요?

🗺️ 미로의 지도:

기존 코드가 미로 속에서 벽을 더듬으며 길을 찾는 것이라면, XState는 하늘 위에서 미로 전체를 내려다보는 지도를 갖는 것과 같습니다. XState는 코드를 기반으로 실시간 상태 다이어그램을 생성합니다.

  • 기획자나 디자이너에게 코드를 설명하는 대신 다이어그램을 보여주며 소통할 수 있게 됩니다. "에러가 나면 어떤 화면으로 가나요?"라는 질문에 그림으로 답이 됩니다.

🛡️ 견고한 로직 보완:

견고한 로직 모든 상태 전이를 명시적으로 정의해야 하므로, 개발자가 미처 생각하지 못한 '엣지 케이스'를 설계 단계에서 발견하게 됩니다. '이 상태에서는 이 버튼이 눌리면 안 되는데?' 같은 버그를 사전 방지 할 수 있습니다.

🧩 모듈화와 재사용성:

액터 모델 로직을 독립적인 '액터'로 분리하여 관리하므로, 거대한 스파게티 코드를 방지하고 각 로직을 독립적으로 테스트하거나 재사용하기 매우 유리합니다.

6. 하지만 모든 도구에는 대가가 따릅니다.

📈 가파른 학습 곡선

단순히 라이브러리 사용법을 배우는 것을 넘어, FSM(유한 상태 기계)과 액터 모델이라는 이론적 배경을 이해해야 합니다. 팀 전체가 이 개념을 익히는 데 시간이 필요할 수 있습니다.

📝 늘어나는 코드 양(Boilerplate)

단순한 로직에도 createMachine, states, on 등 정의해야 할 코드가 많습니다. useState 한 줄이면 끝날 작업이 수십 줄의 객체 정의로 변할 수 있다는 점은 때로 부담이 됩니다.

🤔 Over-engineering의 위험

정말 간단한 UI 컴포넌트까지 XState로 관리하려다 보면, 오히려 배보다 배꼽이 더 커지는 상황이 발생할 수 있습니다.

7. 마무리

XState는 만능 열쇠가 아닙니다. 프로젝트의 성격에 따라 적절한 도구를 선택하는 혜안이 필요합니다.

❌ 이런 상황에서는 useState가 낫습니다.

  • 모달의 열림/닫힘, 단순한 텍스트 입력 폼.
  • 상태 간의 관계가 단순하고 흐름(Flow)이 없는 경우.
  • 빠르게 프로토타입을 만들어야 하는 소규모 프로젝트.

✅ 이럴 때 XState는 강력한 무기가 됩니다.

  • 다단계 프로세스: 결제, 회원가입, 설문조사처럼 순서가 중요한 경우.
  • 복잡한 비동기 작업: 여러 API 호출이 얽혀 있고, 로딩/실패/재시도 처리가 빈번할 때.
  • 미션 크리티컬한 로직: 단 한 번의 상태 오류가 금전적 손실이나 치명적 버그로 이어지는 핵심 기능.

프로젝트에서 가장 골치 아픈 '상태의 늪'을 찾아보고 그 미로를 XState라는 지도로 그려보시길 바랍니다.

profile
I am a front-end developer with 5 years of experience who believes that there is nothing I cannot do.

0개의 댓글