- Published on
유한상태머신(Finite State Machine)이란 무엇인가
- Authors

- Name
- Nostrss
- Github
- Github

참고: Stately 공식 문서
오늘은 유한상태머신(Finite State Machine, 이하 FSM) 패턴을 정리하면서 이 글을 작성했다.
평소에 React에서 isLoading, isError, isSuccess 같은 boolean 플래그를 잔뜩 만들어 두고 if 분기로 화면을 그리다 보면, 어느 순간 "어? 이 두 값이 동시에 true일 수 있나?" 하는 의문이 든다. Stately 공식 문서를 읽으면서 이 불편함의 정체가 결국 "상태를 명시적으로 모델링하지 않은" 탓이라는 걸 알게 됐다. 그 정리 노트다.
왜 상태머신이 필요한가
흔히 비동기 UI를 이렇게 만든다.
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [data, setData] = useState(null)
플래그 4개로 만들 수 있는 조합은 이론상 2^4 = 16가지다. 그런데 실제로 우리가 의도한 상태는 보통 4개뿐이다.
| 플래그 조합 | 의미 | 의도한 상태인가 |
|---|---|---|
loading=false, error=false, success=false | idle | O |
loading=true, error=false, success=false | loading | O |
loading=false, error=false, success=true | success | O |
loading=false, error=true, success=false | error | O |
loading=true, error=true, success=true | ??? | X |
loading=true, error=false, success=true | ??? | X |
나머지 12가지 조합은 존재해서는 안 되는 상태다. 그런데 코드 어디에도 "이 조합은 불가능하다"고 적혀 있지 않다. 그래서 나중에 누가 setIsLoading(true)만 호출하고 setIsSuccess(false)를 깜빡하는 순간 버그가 터진다.
핵심은 불가능한 상태를 코드 차원에서 아예 표현할 수 없게 만드는 것이다.
상태머신은 이걸 정면으로 해결한다. "한 번에 하나의 상태"라는 단순한 규칙을 강제한다.
유한상태머신의 정의
수학적으로 FSM은 다섯 개의 요소로 정의된다.
M = (S, s₀, Σ, δ, F)
| 기호 | 의미 | 예시 (신호등) |
|---|---|---|
S | 가능한 모든 상태의 유한집합 | {red, yellow, green} |
s₀ | 초기 상태 | red |
Σ | 받을 수 있는 이벤트의 집합 | {TIMER} |
δ | 전이 함수: (state, event) -> state | δ(red, TIMER) = green |
F | 종료 상태 집합 (없을 수도 있음) | ∅ |
말은 어렵지만 결국 "지금 어떤 상태이고, 어떤 이벤트가 들어왔을 때 어디로 가는가" 만 정의하는 것이다.
신호등을 그림으로 그리면 이렇다.
red ──TIMER──▶ green ──TIMER──▶ yellow ──TIMER──▶ red
토글 버튼은 더 단순하다.
inactive ──TOGGLE──▶ active ──TOGGLE──▶ inactive
이게 전부다. 상태머신은 이 다이어그램을 그대로 코드로 옮긴 것에 불과하다.
핵심 구성 요소
XState 문서를 따라가면 자주 등장하는 단어들이 있다. 한 번씩 짚고 넘어가자.
State (상태)
머신이 머무를 수 있는 한 지점이다. idle, loading, success, error 같은 이름이 붙는다. 동시에 단 하나의 상태만 활성화된다는 것이 핵심이다.
Event (이벤트)
머신에게 "뭔가 일어났어"라고 알려주는 메시지다. FETCH, TOGGLE, SUBMIT 같은 식이다. 이벤트는 머신 외부에서 send()로 주입된다.
Transition (전이)
"상태 A에서 이벤트 E를 받으면 상태 B로 간다"는 규칙이다. 머신의 행동을 정의하는 핵심이다.
{
idle: {
on: {
FETCH: 'loading'
}
}
}
Action (액션)
전이가 일어날 때 실행되는 부수효과다. 로깅, API 호출, context 업데이트 등이 들어간다. 전이의 "before/after"에 끼어들 수 있는 훅이다.
Guard (가드)
"이 조건을 만족할 때만 전이한다"는 조건부 로직이다. 같은 이벤트라도 가드가 false면 전이가 일어나지 않는다.
{
on: {
SUBMIT: {
target: 'submitted',
guard: ({ context }) => context.isValid
}
}
}
Context (확장 상태)
상태 이름만으로는 표현하기 어려운 동적 데이터를 담는 곳이다. 입력값, fetch 결과, 카운트 같은 것들이 여기에 들어간다. 상태가 "유한한 모드"라면 context는 "그 모드 안의 자유로운 데이터"다.
Vanilla JS로 만들어보자 — 토글 머신
라이브러리 없이 작은 머신을 직접 만들어보면 구조가 한눈에 보인다.
type State = 'inactive' | 'active'
type Event = 'TOGGLE'
const toggleMachine = {
initial: 'inactive' as State,
states: {
inactive: {
on: { TOGGLE: 'active' },
},
active: {
on: { TOGGLE: 'inactive' },
},
},
}
function createMachine<S extends string, E extends string>(config: {
initial: S
states: Record<S, { on: Partial<Record<E, S>> }>
}) {
let current = config.initial
return {
get state() {
return current
},
send(event: E) {
const next = config.states[current].on[event]
if (!next) return // 정의되지 않은 전이는 무시
current = next
},
}
}
const toggle = createMachine(toggleMachine)
console.log(toggle.state) // 'inactive'
toggle.send('TOGGLE')
console.log(toggle.state) // 'active'
toggle.send('TOGGLE')
console.log(toggle.state) // 'inactive'
핵심은 send 함수가 전이 테이블만 보고 다음 상태를 결정한다는 점이다. 현재 상태에서 정의되지 않은 이벤트는 자동으로 무시된다. 이것만으로도 "버튼이 loading 상태에서 또 클릭됐을 때 무시한다" 같은 방어 로직이 공짜로 따라온다.
XState로 다시 쓰기 — fetch 머신
직접 만든 머신은 학습용이고, 실무에서는 XState를 쓰는 게 훨씬 낫다. context, actions, guards, 그리고 시각화까지 전부 지원한다.
조금 더 현실적인 예시로 fetch 머신을 만들어보자.
import { setup, assign } from 'xstate'
type FetchContext = {
data: unknown
error: string | null
}
type FetchEvent =
| { type: 'FETCH' }
| { type: 'RESOLVE'; data: unknown }
| { type: 'REJECT'; error: string }
| { type: 'RETRY' }
const fetchMachine = setup({
types: {
context: {} as FetchContext,
events: {} as FetchEvent,
},
actions: {
setData: assign({
data: ({ event }) => (event.type === 'RESOLVE' ? event.data : null),
error: () => null,
}),
setError: assign({
data: () => null,
error: ({ event }) => (event.type === 'REJECT' ? event.error : null),
}),
},
}).createMachine({
id: 'fetch',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
on: {
RESOLVE: { target: 'success', actions: 'setData' },
REJECT: { target: 'failure', actions: 'setError' },
},
},
success: {
on: { FETCH: 'loading' },
},
failure: {
on: { RETRY: 'loading' },
},
},
})
이 머신이 보장하는 것은 다음과 같다.
idle상태에서RESOLVE를 보내도 아무 일도 일어나지 않는다. → 불가능한 전이는 표현 자체가 막힌다.loading상태가 아닐 때는 절대 데이터가 갱신되지 않는다.failure에서 다시 시도하려면 반드시RETRY이벤트를 거쳐야 한다.
코드를 읽지 않아도 다이어그램만 보면 화면 동작을 100% 설명할 수 있다. 이게 상태머신의 가장 큰 장점이다.
FETCH RESOLVE
idle ─────────▶ loading ─────────▶ success
│
│ REJECT RETRY
└─────▶ failure ─────────┐
▲ │
└────────────┘
한 단계 더 — Hierarchical / Parallel / Actors
상태머신이 단순한 평면 구조라면 대형 앱에는 부족하다. XState는 세 가지 확장을 제공한다.
Hierarchical states (계층적 상태)
상태 안에 또 다른 상태를 중첩한다. 예를 들어 playing 상태 안에 normal / fastForward / rewind 같은 하위 상태를 둘 수 있다. 부모 상태를 빠져나가면 자식 상태도 함께 사라진다.
states: {
playing: {
initial: 'normal',
states: {
normal: { on: { FF: 'fastForward' } },
fastForward: { on: { STOP: 'normal' } },
},
on: { PAUSE: 'paused' },
},
paused: {
on: { PLAY: 'playing' },
},
}
Parallel states (병렬 상태)
서로 독립적인 영역이 동시에 활성화된다. 텍스트 에디터의 "굵게 / 기울임 / 밑줄" 토글처럼, 한 영역의 상태가 다른 영역에 영향을 주지 않을 때 쓴다.
Actors (액터)
머신 하나가 다른 머신을 자식으로 띄우고 메시지를 주고받는 모델이다. Promise, callback, observable도 액터로 다룰 수 있어서 비동기 흐름을 머신 내부로 끌어올 수 있다. 사실상 작은 Erlang/Elixir 같은 모델이다.
어디에 쓰면 좋은가 / 트레이드오프
| 항목 | 내용 |
|---|---|
| 잘 맞는 곳 | fetch UI(idle/loading/success/error), 멀티스텝 폼·위저드, 인증 흐름, 미디어 플레이어, 게임 로직 |
| 그저 그런 곳 | 단순 토글 하나, boolean 한두 개로 끝나는 화면 — 머신을 쓰면 오히려 코드가 늘어난다 |
| 장점 | 불가능한 상태 제거, 다이어그램 = 코드, 디자이너/PM과 같은 그림으로 대화 가능, 테스트 용이성 |
| 단점 | 학습 곡선, 작은 화면에는 오버킬, 머신 정의가 길어지면 가독성 떨어짐 |
| 언제 도입할지 | "이 화면 상태가 자꾸 꼬인다"는 느낌이 두 번 이상 들 때 |
상태머신이 만능은 아니다. 단순한 곳에는 단순한 코드가 맞다. 다만 화면 상태가 조금이라도 복잡해지는 순간, FSM을 떠올릴 수 있는지 없는지가 코드 품질을 가른다.
5분 요약
- 유한상태머신은 "지금 상태 + 들어온 이벤트 → 다음 상태" 규칙의 집합이다.
- boolean 플래그 조합의 가장 큰 문제는 "불가능한 조합"이 표현 가능하다는 것이고, FSM은 이걸 원천 차단한다.
- 핵심 단어 6개: state / event / transition / action / guard / context.
- Vanilla JS로도 30줄이면 토글 머신을 만들 수 있다. 본질은 "전이 테이블 + send 함수"다.
- 실무에서는 XState를 쓰면 hierarchical / parallel / actors까지 자연스럽게 확장된다.
- 모든 화면에 쓰지는 말되, "상태가 꼬인다"는 신호가 보이면 즉시 떠올릴 수 있어야 한다.
정리
상태머신은 새 라이브러리를 배우는 일이 아니라, 상태를 모델링하는 사고방식을 배우는 일이다. boolean 플래그를 늘리는 대신 "이 화면이 가질 수 있는 상태를 모두 적어보자"라고 시작하는 순간, 이미 절반은 FSM을 쓰고 있는 셈이다.
여기에 XState 같은 도구를 얹으면 다이어그램과 코드가 1:1로 대응되고, 디자이너·기획자와도 같은 그림 위에서 대화할 수 있게 된다. Stately 공식 문서가 잘 정리되어 있으니, 다음 번 fetch UI를 만들 때 한 번쯤 머신으로 짜보면 차이를 직접 느낄 수 있을 것이다.
참고: Stately 공식 문서
