
XState로 복잡한 UI 상태 길들이기: if문 20개에서 상태 머신으로
결제 플로우의 상태를 if문과 boolean 플래그로 관리하다 버그 지옥에 빠졌다. XState의 상태 머신으로 불가능한 상태를 원천 차단한 경험.

결제 플로우의 상태를 if문과 boolean 플래그로 관리하다 버그 지옥에 빠졌다. XState의 상태 머신으로 불가능한 상태를 원천 차단한 경험.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

단순한 계산기가 어떻게 논리적인 사고를 하게 되었을까? 튜링 머신이 알려주는 컴퓨터의 본질과, 이것이 내 코드의 상태 관리와 어떻게 연결되는지 정리했습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

결제 플로우를 만들고 있었다. 카드 정보 입력 → 유효성 검사 → 결제 요청 → 결과 처리. 별거 없어 보였다. 그래서 useState로 상태를 하나씩 추가하기 시작했다.
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isPending, setIsPending] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
처음엔 두 개였다. 그다음 엣지 케이스가 생겼다. 재시도 로직이 붙었다. 3D Secure 인증 단계가 추가됐다. 타임아웃 처리가 필요해졌다. 어느새 boolean 다섯 개가 동시에 공중을 떠다니고 있었다.
그러던 어느 날 QA에서 버그 리포트가 올라왔다.
"결제가 완료됐는데 로딩 스피너가 계속 돌고 있어요."
콘솔을 열었다. isLoading: true, isSuccess: true. 동시에.
이게 어떻게 가능하냐고? 나도 몰랐다. 코드 어딘가에서 setIsLoading을 호출해야 할 타이밍에 놓쳤을 것이다. 아니면 레이스 컨디션이었을 것이다. 어쨌든 현실에서는 불가능해야 할 상태가 코드 안에서는 아무 문제 없이 존재하고 있었다.
그게 진짜 문제였다.
boolean 플래그 다섯 개가 있으면 이론상 가능한 상태 조합은 2⁵ = 32가지다. 내 결제 플로우에서 실제로 유효한 상태는 몇 개였을까. 대략 이런 것들이었다.
7개. 유효한 상태는 7개인데, 코드는 32개의 조합을 허용하고 있었다. 나머지 25개 조합은 "이게 발생하면 안 된다"는 암묵적 약속에 의존하고 있었다. 그 약속을 깨는 건 언제나 쉬웠다. setIsLoading(false) 한 줄을 빠뜨리거나, 비동기 처리 순서가 꼬이거나, 팀원이 상태 관리 흐름을 완전히 이해하지 못한 채 코드를 수정하거나.
이걸 비유하면 저글링과 같다. boolean이 하나일 때는 공 하나를 던지고 받는 것이다. 두 개일 때는 두 손으로 두 개. 다섯 개면? 저글러가 아닌 이상 공 하나는 바닥에 떨어진다. 그리고 그 공이 "로딩 중인데 동시에 성공 상태"라는 이상한 UI로 사용자 앞에 등장한다.
당시 내 컴포넌트 코드는 이런 형태였다.
function PaymentButton() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isPending, setIsPending] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handlePayment = async () => {
setIsLoading(true);
setIsError(false); // 이전 에러 초기화... 맞나?
setIsPending(false); // 이것도?
try {
const result = await requestPayment();
if (result.requiresAuth) {
setIsLoading(false);
setIsPending(true); // 3DS 대기
await handle3DS(result.authUrl);
setIsPending(false);
setIsLoading(true); // 다시 로딩
}
const finalResult = await confirmPayment(result.id);
setIsLoading(false);
setIsSuccess(true);
} catch (error) {
setIsLoading(false);
setIsError(true);
setErrorMessage(error.message);
if (isRetryable(error)) {
setIsRetrying(true); // isError가 true인 채로 isRetrying도 true?
}
}
};
// 렌더링 로직...
if (isLoading && !isPending) return <Spinner />;
if (isPending) return <AuthWaiting />;
if (isRetrying) return <RetryButton />; // isError도 true인 상태
if (isError) return <ErrorMessage text={errorMessage} />;
if (isSuccess) return <SuccessScreen />;
return <PayButton onClick={handlePayment} />;
}
코드를 쭉 읽으면서도 "이게 맞나?"라는 의심이 계속 들었다. isError가 true인 채로 isRetrying이 true가 되는 게 의도한 건지, 실수인지 스스로도 확신이 없었다. 동료가 이 코드를 읽어도 마찬가지였을 것이다.
XState를 처음 들었을 때는 "오버엔지니어링 아닌가?" 싶었다. Redux를 써본 적도 있었고, 그것만으로도 충분히 복잡했다. 새로운 라이브러리를 또 배우기는 싫었다.
그런데 개념을 이해하고 나서 생각이 바뀌었다.
상태 머신(State Machine)의 핵심은 단순하다. 어떤 순간에도 정확히 하나의 상태만 존재한다. 신호등을 생각하면 와닿는다.
신호등은 세 가지 상태를 가진다. 빨강, 노랑, 초록. 신호등이 동시에 빨강이면서 초록일 수는 없다. "빨강에서 초록으로 전환"이라는 전이(transition)가 명시적으로 정의되어 있고, 정해진 경로 외에는 상태가 바뀌지 않는다. 불가능한 상태는 시스템 설계 자체에서 차단된다.
boolean 플래그 방식은 이와 반대다. "빨강 여부", "노랑 여부", "초록 여부"를 각각 boolean으로 관리하는 것과 같다. 코드 어딘가에서 실수로 isRed = true, isGreen = true가 되어도 시스템은 아무 말 없이 그 상태를 유지한다.
유한 상태 머신(Finite State Machine, FSM)의 세 가지 핵심은 이거였다.
설치부터.
npm install xstate @xstate/react
그다음 결제 플로우의 상태 머신을 정의했다. boolean 스파게티 대신 명시적인 상태와 전이로.
import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';
// 상태 머신 정의
const paymentMachine = createMachine({
id: 'payment',
initial: 'idle',
context: {
errorMessage: '',
paymentId: '',
},
states: {
idle: {
on: {
SUBMIT: 'requesting', // idle에서 SUBMIT 이벤트 → requesting으로
},
},
requesting: {
invoke: {
src: 'requestPayment',
onDone: [
{
guard: 'requiresAuth', // 3DS 필요 시
target: 'authenticating',
actions: assign({ paymentId: ({ event }) => event.output.id }),
},
{
target: 'confirming', // 바로 확인 단계로
actions: assign({ paymentId: ({ event }) => event.output.id }),
},
],
onError: {
target: 'failed',
actions: assign({ errorMessage: ({ event }) => event.error.message }),
},
},
},
authenticating: {
invoke: {
src: 'handle3DS',
onDone: 'confirming',
onError: {
target: 'failed',
actions: assign({ errorMessage: ({ event }) => event.error.message }),
},
},
},
confirming: {
invoke: {
src: 'confirmPayment',
onDone: 'success',
onError: [
{
guard: 'isRetryable',
target: 'retrying',
actions: assign({ errorMessage: ({ event }) => event.error.message }),
},
{
target: 'failed',
actions: assign({ errorMessage: ({ event }) => event.error.message }),
},
],
},
},
retrying: {
after: {
2000: 'requesting', // 2초 후 자동으로 requesting 재시도
},
on: {
CANCEL_RETRY: 'failed',
},
},
success: {
type: 'final',
},
failed: {
on: {
RETRY: 'requesting',
RESET: 'idle',
},
},
},
});
// 컴포넌트에서 사용
function PaymentButton() {
const [state, send] = useMachine(paymentMachine, {
actors: {
requestPayment: () => requestPaymentAPI(),
handle3DS: ({ context }) => handle3DSAuth(context.paymentId),
confirmPayment: ({ context }) => confirmPaymentAPI(context.paymentId),
},
guards: {
requiresAuth: ({ event }) => event.output?.requiresAuth === true,
isRetryable: ({ event }) => isRetryableError(event.error),
},
});
// 상태 비교가 이렇게 깔끔해진다
if (state.matches('requesting') || state.matches('confirming')) {
return <Spinner />;
}
if (state.matches('authenticating')) return <AuthWaiting />;
if (state.matches('retrying')) {
return <RetryCountdown message={state.context.errorMessage} />;
}
if (state.matches('failed')) {
return (
<ErrorMessage
text={state.context.errorMessage}
onRetry={() => send({ type: 'RETRY' })}
onReset={() => send({ type: 'RESET' })}
/>
);
}
if (state.matches('success')) return <SuccessScreen />;
return <PayButton onClick={() => send({ type: 'SUBMIT' })} />;
}
달라진 게 뭔지 바로 보인다. isLoading && isSuccess가 동시에 true가 되는 건 이제 구조적으로 불가능하다. requesting과 success는 서로 다른 상태 노드이고, 머신은 그 둘 사이에 어디에 있는지 정확히 알고 있다. 동시에 두 곳에 있을 수 없다.
전이 경로도 명시적이다. requesting에서는 오직 confirming, authenticating, failed로만 갈 수 있다. 코드 어딘가에서 setIsPending(true)를 호출해서 예상치 못한 상태를 만드는 일이 없다. 모든 전이는 선언된 경로만 따른다.
XState를 쓰면서 가장 와닿은 것 중 하나가 시각화 도구다.
XState Visualizer에 상태 머신 코드를 붙여넣으면, 상태와 전이가 그래프로 그려진다. 어떤 상태에서 어떤 이벤트가 발생하면 어디로 가는지가 한눈에 보인다.
기존 boolean 스파게티 코드였다면? 화이트보드에 직접 그리지 않는 한 전체 흐름을 파악하기 어렵다. 코드를 처음 보는 사람은 모든 if-else 분기를 머릿속에서 조립해야 한다.
상태 머신은 코드 자체가 문서다. 시각화 도구 없이 코드만 읽어도 "이 상태에서 이 이벤트가 오면 저기로 간다"가 선언적으로 드러난다. 디버깅 방식도 달라진다. "왜 이 버그가 났지?"를 물을 때, boolean 코드라면 모든 setState 호출 순서를 추적해야 한다. 상태 머신이라면 "현재 상태가 뭔지"와 "어떤 이벤트가 발생했는지"만 알면 된다.
이걸 비유하면 지하철 노선도와 같다. 서울 지하철 노선이 boolean으로 관리된다면 "현재 역의 latitude가 37.5xxx이고 longitude가 127.0xxx인지"를 체크하는 것과 같다. 상태 머신은 "지금 홍대입구역에 있고, 다음 역은 합정역이다"라고 말하는 것과 같다. 어디에 있는지가 명확하고, 갈 수 있는 다음 목적지가 정해져 있다.
솔직히 말하면, 모든 상태 관리에 XState가 필요한 건 아니다. 버튼 하나의 loading 상태나 모달 열고 닫기에 createMachine을 쓰는 건 확실히 오버킬이다.
내가 정리한 기준은 이렇다.
상태 머신을 고려할 때:실제로 나는 XState 도입 전에 먼저 중간 단계를 거쳤다. string literal union type으로 상태를 표현하는 것이다.
type PaymentStatus =
| 'idle'
| 'requesting'
| 'authenticating'
| 'confirming'
| 'retrying'
| 'success'
| 'failed';
const [status, setStatus] = useState<PaymentStatus>('idle');
boolean 다섯 개 대신 상태를 하나의 값으로 관리하는 것만으로도 "동시에 두 상태"가 불가능해진다. 전이 로직이 간단하다면 이 정도로도 충분하다. 복잡도가 올라가거나 비동기 흐름이 붙기 시작하면 그때 XState를 도입해도 늦지 않다.
boolean 플래그 n개는 2ⁿ개의 상태 조합을 허용한다. 유효한 조합이 10개여도, 코드는 나머지 54개 조합이 생기는 것을 막지 않는다.
상태 머신은 유효한 상태만 정의하고, 나머지를 원천 차단한다. "로딩 중이면서 동시에 성공 상태"는 설계 자체에서 불가능해진다.
전이를 선언적으로 정의하면 흐름이 코드에서 드러난다. if문을 추적하지 않아도 어떤 상태에서 어디로 갈 수 있는지 한눈에 보인다.
XState Visualizer로 상태 다이어그램이 자동 생성된다. 문서를 따로 쓸 필요 없이 코드가 곧 다이어그램이다.
단순한 경우엔 string union type이 충분한 중간 단계다. 모든 상태에 XState가 필요한 건 아니다. 복잡도에 맞게 도구를 선택하면 된다.
버그 리포트가 올라왔을 때 "isLoading이 왜 true지?"를 찾아 헤매는 대신, "현재 상태가 requesting이고 success 이벤트가 아직 발생하지 않았다"로 바로 좁혀지는 게 얼마나 다른지 한번 써보면 바로 알 수 있다.