이 글은 XState v5 기준 공식문서 기반으로 작성되었습니다
개발하다 보면 수많은 useState와 useEffect가 뒤엉켜 "상태의 늪"에 빠지는 경험, 한 번쯤 있을 거예요.
// 익숙하지만 고통스러운 코드...
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState(null);
// 실수로 로딩 중인데 에러가 true로 남아있다면? 끔찍합니다. 🤪
변수가 늘어날수록 우리의 앱은 예측 불가능해집니다. 오늘은 이 복잡한 상태 관리를 수학적으로 깔끔하게 해결해 주는 XState와 그 기반 이론인 유한 상태 기계(FSM)에 대해 이야기해 보려 합니다.
XState를 이해하려면 먼저 유한 상태 기계(Finite State Machine, FSM)가 무엇인지 알아야 합니다. 이름은 어렵지만 원리는 아주 간단합니다.
"시스템은 한 번에 오직 하나의 상태(State)만 가질 수 있다."
신호등은 '초록불'이면서 동시에 '빨간불'일 수 없습니다. 만약 두 불이 동시에 켜진다면 그건 고장 난 신호등(버그)입니다.
기존의 useState 방식이 변수들의 조합으로 상태를 추론하는 방식이라 버그 발생 가능성이 높다면, FSM은 "지금은 빨간불이야!" 라고 명확하게 하나의 상태를 지정해 주는 방식입니다.
이를 통해 '불가능한 상태(Impossible States)'가 발생하는 것을 원천 봉쇄합니다.
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.
XState를 다룬다는 것은 단순히 라이브러리를 쓰는 것이 아니라, '상태 차트(Statecharts)'라는 강력한 시각적 모델을 코드로 구현하는 것을 의미합니다. XState의 작동 방식을 우리에게 친숙한 자판기에 비유해 핵심 개념을 정리해 보겠습니다.
상태는 머신이 머물 수 있는 특정한 상황이나 모드를 말합니다. "시스템은 한 번에 오직 하나의 상태만 가질 수 있다"는 것이 핵심입니다.
idle: 동전이 투입되기를 기다리는 대기 상태 (Initial State)selecting: 금액이 확인되어 상품을 고르는 상태dispensing: 음료가 나오고 있는 상태// 머신 정의 내부
states: {
idle: {
// 대기 중 상태에 대한 정의
},
selecting: {
// 상품 선택 중 상태에 대한 정의
},
dispensing: {
// 음료 나오는 중 상태에 대한 정의
}
}
Transition을 일으키는(상태를 변화시키는) 외부의 자극입니다.
INSERT_COIN (동전 넣기)SELECT_ITEM (버튼 누르기)// idle 상태 내부
idle: {
on: { // 이 상태에서 받을 수 있는 이벤트들
INSERT_COIN: { // 'INSERT_COIN' 이벤트가 발생하면...
target: 'selecting' // 'selecting' 상태로 전이(이동)한다
}
}
}
이벤트가 발생했을 때 다음 상태로 이동하는 규칙입니다.
대기 중(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은 병렬 연산을 위한 수학적 모델이지만, 프론트엔드 개발에서도 중요한 시사점을 줍니다
액터 모델의 가장 기본이 되는 독립적인 연산 단위입니다. XState에서 상태 머신이 실행되면 하나의 '액터'가 됩니다.
액터의 4가지 핵심 특징
상태 머신(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' });
액터는 새로운 액터를 만들 수 있습니다. 이를 통해 복잡한 일을 다른 일꾼에게 위임(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' })
}
}
});
단순한 상태만 있어서는 복잡한 어플리케이션을 대응할 수 없습니다. 복잡한 상태들도 다룰 수 있도록 되어 있습니다.
https://stately.ai/docs/initial-states, https://stately.ai/docs/finite-states
모든 머신은 시작점이 있고, 때로는 명확한 종료 지점이 있습니다.
이를 Finite States (유한 상태)라고 할 수 있습니다. 즉, 유한 상태 기계에서 '유한'은 우리가 제어할 수 있는 범위 안에서만 앱이 움직인다는 것을 보장합니다.
예시: 자판기에서 '잔액 반환 완료' 상태가 되면 해당 거래 세션은 완전히 종료(final)됩니다.
const vendingMachine = createMachine({
initial: 'idle', // 시작 상태
states: {
idle: { /* ... */ },
returnChange: {
type: 'final' // 종료 상태: 이후엔 어떤 이벤트도 받지 않음
}
}
});
상태 안에 또 다른 상태를 넣을 수 있습니다. 이를 복합 상태(Compound States)라고도 합니다.
예시: 자판기가 '판매 중(active)' 상태일 때, 그 안에서는 '금액 투입됨(waitingForCoin)', '상품 선택 중(selecting)' 같은 세부 상태들이 돌아갑니다. 판매 중이라는 큰 틀을 벗어나지 않으면서 내부 로직을 격리할 수 있습니다.
active: {
// 내부 상태중 하나로 초기값을 설정해야 합니다.
initial: 'waitingForCoin',
states: {
waitingForCoin: { on: { INSERT: 'selecting' } },
selecting: { /* ... */ }
}
}
부모 상태를 가지는 상태 기계는 복잡해질 수 있기에 Best Practices들을 따라야 합니다. (https://stately.ai/docs/parent-states#modeling)
특히 아래와 같은 상황에서는 부모 상태를 피해야 합니다.
여러 상태가 독립적으로 존재해야 할 때 사용합니다. 한 상태가 다른 상태에 영향을 주지 않고 각각의 흐름을 가집니다.
예시: 최신형 자판기라면 '판매 시스템'이 돌아가는 동시에, 화면 한쪽에서는 '광고 영상 플레이어'가 재생되고 있을 것입니다. 광고가 끝난다고 해서 판매 시스템이 멈추면 안 되겠죠? 이럴 때 두 상태를 병렬로 둡니다.
// 병렬 상태라는 것을 명시합니다.
type: 'parallel',
states: {
vendingLogic: { initial: 'idle', states: { ... } },
advertisement: { initial: 'playing', states: { ... } }
}
병렬 상태를 사용할 때에도 중요한 사항이 있습니다. (https://stately.ai/docs/parallel-states#modeling)
이전의 상태를 '기억'했다가 다시 돌아왔을 때 복구해 주는 특수한 상태입니다.
Shallow 와 Deep history도 존재합니다.
예시: 사용자가 상품을 선택하던 중(selecting), 자판기 '설정 모드'를 들어갔다가 나왔다고 해봅시다. 이때 처음부터 다시 돈을 넣는 게 아니라, 이전에 작업하던 selecting 단계로 바로 복구시켜 줄 때 사용합니다.
states: {
shopping: {
initial: 'browsing',
states: {
browsing: {},
selecting: {},
hist: { type: 'history' }
}
},
settings: {
on: { BACK: 'shopping.hist' } // 이전의 세부 상태로 복구!
}
}

앞서 본 전이는 가장 기본적인 이동입니다. 하지만 현실 세계의 자판기는 훨씬 복잡합니다. 돈이 부족하면 버튼을 눌러도 음료를 주지 않고(조건), 동전을 넣으면 잔액 표시를 갱신합니다.(액션).
상태가 바뀔 때 단순히 이동만 하는 게 아니라, 콘솔을 찍거나 데이터를 저장하는 등 '실행 후 잊어버리는(Fire-and-forget)' 부수 효과를 정의합니다.
예시: "동전을 넣어서(Event) '선택 중' 상태로 갈 때(Transition), 자판기 화면의 잔액 숫자를 업데이트(Action)해라."
XState에서는 액션을 세 가지 시점에서 실행할 수 있습니다.
// 자판기 예시
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' });
})
이벤트가 발생했다고 무조건 이동하는 게 아닙니다. 특정 조건(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'])
하나의 이벤트에 대해 여러 개의 전이 규칙을 배열로 정의할 수 있습니다. 위에서부터 순서대로 조건을 확인하고, 가장 먼저 만족하는 경로를 탑니다.
예시: "버튼을 눌렀을 때, 돈이 충분하면 '배출 중'으로 가고, 돈이 부족하면 '오류 메시지' 상태로 가라."
on: {
SELECT_ITEM: [
// 1순위: 돈이 충분한가? -> 배출
{
target: 'dispensing',
guard: 'isEnoughMoney'
},
// 2순위: 위 조건이 실패했는가? -> 잔액 부족 알림 (else와 비슷)
{
target: 'showInsufficientFundsError'
}
]
}
target을 지정하지 않거나, 현재 상태와 동일한 상태를 지정하는 경우입니다. 상태는 바뀌지 않지만 actions를 실행하고 싶을 때 사용합니다.
예시: "상품 선택 중에 동전을 추가로 더 넣는 경우, 상태는 여전히 '선택 중'이지만 잔액만 증가(Action)시킨다."
// selecting 상태
on: {
INSERT_ADDITIONAL_COIN: {
// target이 없음 (자기 전이)
actions: ['addMoneyToContext']
}
}
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 (문맥): "자판기 안에 돈이 얼마 있는가? 재고는 몇 개인가?" → 양적인 데이터

가장 강력한 액터로, 복잡한 상태 전환과 로직을 담당합니다.
예시: 자판기의 메인 마더보드. 전체 흐름(대기 → 결제 → 배출)을 총괄합니다.
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();
Promise Actor는 한 번 실행하면 성공(resolve) 또는 실패(reject)의 결과를 반환하고 즉시 업무를 마치는 일꾼입니다.
핵심 특징
자판기에서 카드 결제 승인을 기다리는 로직을 예로 들어보겠습니다.
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);
복잡한 상태 전환 없이, 데이터(Context)만 업데이트하는 가벼운 일꾼입니다.
핵심 특징
자판기의 '투입 금액 합계 계산기'를 예로 들어보겠습니다. 동전이 들어올 때마다 금액을 더하기만 하면 됩니다.
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"
Callback Actor는 한 번 고용되면 특정 상태가 유지되는 동안 계속 자리를 지키며, 외부 이벤트를 감지해 부모에게 보고하거나(sendBack) 부모의 지시를 기다리는(receive) 일꾼입니다.
핵심 특징
자판기의 '비상 정지 버튼' 감시 로직을 예로 들어보겠습니다.
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' })
}
}
});
Observable Actor는 시간의 흐름에 따라 연속적으로 발생하는 값의 스트림을 나타내는 일꾼입니다. 한 번의 결과만 주는 Promise와 달리, 중단되기 전까지 새로운 소식을 계속해서 전달합니다.
핵심 특징
자판기 내부의 '실시간 온도 감지기' 로직을 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();
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)의 주범이 됩니다.
수동 종료의 책임 Invoke는 상태를 벗어나면 자동으로 죽지만, Spawn은 직접 죽여야 합니다. stopChild(id) 액션을 잊지 마세요.
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), 개별 주식 시세 |
언제 무엇을 선택해야 할까?
InvokeInvokeSpawnSpawn이제 이론을 코드로 옮겨보겠습니다. 단순한 클릭이 아니라 재고 확인 -> 결제 수단 선택 -> 결제 처리로 이어지는 복잡한 구매 로직을 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는 그저 메시지를 보내고 결과(성공/실패)를 기다리기만 하면 됩니다.
기존 코드가 미로 속에서 벽을 더듬으며 길을 찾는 것이라면, XState는 하늘 위에서 미로 전체를 내려다보는 지도를 갖는 것과 같습니다. XState는 코드를 기반으로 실시간 상태 다이어그램을 생성합니다.
견고한 로직 모든 상태 전이를 명시적으로 정의해야 하므로, 개발자가 미처 생각하지 못한 '엣지 케이스'를 설계 단계에서 발견하게 됩니다. '이 상태에서는 이 버튼이 눌리면 안 되는데?' 같은 버그를 사전 방지 할 수 있습니다.
액터 모델 로직을 독립적인 '액터'로 분리하여 관리하므로, 거대한 스파게티 코드를 방지하고 각 로직을 독립적으로 테스트하거나 재사용하기 매우 유리합니다.
단순히 라이브러리 사용법을 배우는 것을 넘어, FSM(유한 상태 기계)과 액터 모델이라는 이론적 배경을 이해해야 합니다. 팀 전체가 이 개념을 익히는 데 시간이 필요할 수 있습니다.
단순한 로직에도 createMachine, states, on 등 정의해야 할 코드가 많습니다. useState 한 줄이면 끝날 작업이 수십 줄의 객체 정의로 변할 수 있다는 점은 때로 부담이 됩니다.
정말 간단한 UI 컴포넌트까지 XState로 관리하려다 보면, 오히려 배보다 배꼽이 더 커지는 상황이 발생할 수 있습니다.
XState는 만능 열쇠가 아닙니다. 프로젝트의 성격에 따라 적절한 도구를 선택하는 혜안이 필요합니다.
프로젝트에서 가장 골치 아픈 '상태의 늪'을 찾아보고 그 미로를 XState라는 지도로 그려보시길 바랍니다.