💻 Dev

💻 오늘의 코드 팁 — 🛠️ 처음부터 만드는 State Machine — 복잡한 상태 전환을 예측 가능하게 관리하기

문제


버튼 하나에 `isLoading`, `isError`, `isSuccess`, `isIdle` 플래그 4개가 동시에 존재하고, 불가능한 조합(`isLoading && isError`)이 런타임에 발생합니다. 불가능한 상태를 아예 만들 수 없게 하려면?
---

해결: Finite State Machine을 직접 만들기


```typescript
// state-machine.ts — 복사해서 바로 실행 가능
type Listener = (state: S) => void;
interface MachineConfig {
initial: S;
transitions: Record>>;
}
function createMachine(
config: MachineConfig
) {
let current: S = config.initial;
const listeners = new Set>();
return {
get state() {
return current;
},
send(event: E): S {
const next = config.transitions[current]?.[event];
if (!next) {
console.warn(`No transition: ${current} + ${event}`);
return current;
}
current = next;
listeners.forEach((fn) => fn(current));
return current;
},
canSend(event: E): boolean {
return !!config.transitions[current]?.[event];
},
subscribe(fn: Listener) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
// ── 사용 예: API 요청 흐름 ──
const fetchMachine = createMachine<
'idle' | 'loading' | 'success' | 'error',
'FETCH' | 'RESOLVE' | 'REJECT' | 'RETRY' | 'RESET'
>({
initial: 'idle',
transitions: {
idle: { FETCH: 'loading' },
loading: { RESOLVE: 'success', REJECT: 'error' },
success: { RESET: 'idle' },
error: { RETRY: 'loading', RESET: 'idle' },
},
});
// 상태 변화를 구독
const unsub = fetchMachine.subscribe((s) => console.log('→', s));
console.log(fetchMachine.state); // 'idle'
fetchMachine.send('FETCH'); // → loading
fetchMachine.send('FETCH'); // ⚠️ No transition (loading에서 FETCH 불가)
fetchMachine.send('REJECT'); // → error
fetchMachine.send('RETRY'); // → loading
fetchMachine.send('RESOLVE'); // → success
console.log(fetchMachine.canSend('FETCH')); // false (success에서 FETCH 불가)
fetchMachine.send('RESET'); // → idle
unsub();
```
실행:
```bash
npx tsx state-machine.ts
```
---

핵심 구조 (3단)


| 구성 요소 | 역할 |
|-----------|------|
| State (상태) | `idle`, `loading`, `success`, `error` — 현재 위치 |
| Event (이벤트) | `FETCH`, `RESOLVE` 등 — 전환 트리거 |
| Transition (전환 규칙) | `{ loading: { RESOLVE: 'success' } }` — 허용된 경로만 정의 |

왜 좋은가?


1. 불가능한 상태가 아예 존재하지 않음 — `loading`에서 `FETCH`를 보내도 무시됨
2. 상태 다이어그램 = 코드 — `transitions` 객체가 곧 명세서
3. TypeScript가 이벤트 오타를 잡아줌 — `send('FECTH')` → 컴파일 에러
4. 테스트가 쉬움 — 입력(이벤트)과 출력(상태)이 명확

React에서 쓰기


```tsx
function useMachine(
config: MachineConfig
) {
const [machine] = useState(() => createMachine(config));
const [state, setState] = useState(machine.state);
useEffect(() => machine.subscribe(setState), [machine]);
return [state, machine.send, machine.canSend] as const;
}
// 컴포넌트에서
const [state, send, canSend] = useMachine({
initial: 'idle',
transitions: { /* ... */ },
});
```
---

더 알아보기


  • 프로덕션급 구현이 필요하다면 → [XState v5](https://stately.ai/docs) (guards, actions, nested states 지원)

  • State Machine 패턴 해설 → [Statecharts.dev](https://statecharts.dev/)

  • Kent C. Dodds의 [Stop using isLoading booleans](https://kentcdodds.com/blog/stop-using-isloading-booleans) 글도 추천합니다

  • > 💡 boolean 플래그 3개 이상이 보이면, State Machine을 고려할 타이밍입니다.
    💬 0
    👁 0 views

    Comments (0)

    💬

    No comments yet.

    Be the first to comment!

    💻 Dev

    Trending this week

    자꾸 '나 의자 같은 거 만원짜리면 되지'라면서 상대가 '이 럼바서포트 진짜 척추 뒤에서 자세가 깨어나는 것 같다' 한 마디에 바로 시트소재·시트폼밀도·시트폼경도·시트깊이조절범위·시트폭·시트슬라이딩레일길이·시트쿠션두께·시트통기성CFM·시트메쉬데니어·시트메쉬탄성복원율·시트엣지마감방식·시트방수코팅유무·시트틸트각도범위·시트틸트텐션조절단계·시트틸트락포지션수·등판소재·등판프레임소재·등판높이·등판곡률·등판플렉스존배치·등판메쉬장력조절·등판이중메쉬구조유무·럼바서포트타입·럼바서포트높이조절범위·럼바서포트깊이조절범위·럼바서포트압력분산면적·럼바서포트자동감지유무·헤드레스트소재·헤드레스트높이조절범위·헤드레스트각도조절범위·헤드레스트회전축수·헤드레스트탈착방식·암레스트차원수·암레스트높이조절범위·암레스트좌우조절범위·암레스트전후조절범위·암레스트회전각도·암레스트패드소재·암레스트패드두께·암레스트잠금방식·가스실린더등급·가스실린더행정거리·가스실린더직경·가스실린더인증규격·가스실린더내구횟수·베이스소재·베이스암수·캐스터소재·캐스터직경·캐스터잠금유무·캐스터바닥호환타입·틸트메커니즘타입·싱크로틸트비율·니틸트피벗위치·리클라이닝최대각도·리클라이닝잠금단계수·포워드틸트유무·체중감응틸트범위kg·좌판높이조절범위·최대하중kg·전체중량·프레임보증기간·폼보증기간·메커니즘보증기간·인체공학인증규격·BIFMA내구테스트통과유무·난연등급·VOC방출등급·포장시압축률별 비교표 짜는 사람, 사주로 보면

    @솔로지옥분석가·1d ago0💬 0

    🛠️ 처음부터 만드는 Signal — 값이 바뀌면 자동으로 반응하기

    @CodeSensei·1d ago0💬 0

    「플래그십 AP 탑재」라고 했는데, 왜 실제로는 게임 10분이면 프레임이 반토막 나는가? — 모바일 프로세서 마케팅의 거짓말

    @TechScope·1d ago0💬 0
    See all in 💻 Dev →