불가능한 상태를 불가능하게: UI를 상태 기계로 모델링

데이터를 불러오는 컴포넌트를 하나 떠올려보자. 목록을 API에서 받아 그리는 아주 흔한 화면이다. 처음엔 스피너를 돌리고, 응답이 오면 목록을 그리고, 실패하면 에러 배너를 띄운다. 여기까지는 아무 문제가 없다.
그런데 어느 날 QA가 스크린샷을 하나 보낸다. 스피너가 돌아가고 있는데, 그 아래에 빨간 에러 배너가 같이 떠 있다. 로딩 중이면서 동시에 실패한 화면이다. 디자인 시안 어디에도 없던 조합이다. 나도 이 스피너-에러 동시 노출 버그를 실제로 만든 적이 있는데, 재현 경로를 따라가 보면 대개 이렇다. 첫 요청이 실패해서 error에 메시지가 담겼고, 사용자가 다시 시도를 눌러 로딩 플래그를 켰는데, 그때 이전 error를 지우는 걸 깜빡한 것이다.
이 버그를 error 초기화를 빠뜨린 한 줄짜리 실수로 정리하고 그 줄만 고치면 그 순간은 넘어간다. 하지만 그건 증상만 덮은 거다. 진짜 원인은 코드가 아니라 상태를 모델링한 방식에 있다. 로딩 중이면서 에러인 상태를 애초에 표현할 수 있게 만들어 뒀기 때문에, 언젠가 그 조합이 화면에 새는 건 시간문제였다.
이제부터 코드가 조금 나온다. 미리 말해두면, 디자이너가 이 코드를 직접 짤 일은 없다. 코드의 모양만 눈으로 읽을 수 있으면 된다. 그 모양이 곧 이 컴포넌트가 가질 수 있는 상태의 목록이고, 그건 디자이너가 시안에서 다루는 것과 정확히 같은 것이기 때문이다.
하나의 비유를 깔고 가자. 택배 배송 상태를 떠올리면 이 글 전체가 쉬워진다. 택배는 늘 딱 하나의 상태에 있다. 배송 준비 중이거나, 배송 중이거나, 배송 완료거나, 반송 중이거나. 넷 중 하나다. 배송 완료이면서 동시에 반송 중인 택배는 없다. 이 당연한 사실이 이 글의 전부다. 아래 코드가 어려워 보이면, 지금 이건 택배 상태 이야기라고 되뇌면 된다.
불린 네 개면 열여섯 가지
컴포넌트 상태를 불린 플래그로 들고 있는 코드는 흔하다. 로딩인가, 에러인가, 성공인가, 아직 시작 안 한 대기 상태인가. 각각을 참/거짓으로 따로 둔다.
type FetchState = {
isIdle: boolean;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
data: User[] | null;
error: string | null;
};
코드를 모르는 눈으로 봐도 모양은 읽힌다. isIdle, isLoading, isSuccess, isError. 이름 앞의 is는 참인가 거짓인가를 묻는 표시다. 곧 켜고 끌 수 있는 스위치 네 개가 나란히 있는 것이다. 대기 중인가(켜짐/꺼짐), 로딩 중인가, 성공했나, 실패했나. 네 스위치를 서로 따로 켜고 끈다.
여기가 함정이다. 스위치가 네 개면 켜고 끈 조합은 2를 네 번 곱한 값, 곧 열여섯 가지다. 그런데 이 중에서 실제로 화면에 있어도 되는 상태는 네 개뿐이다. 대기, 로딩, 성공, 에러. 나머지 열두 가지는 존재하면 안 되는 조합이다. 로딩 스위치와 에러 스위치가 동시에 켜진 경우(스피너와 에러 배너가 같이 뜬다), 성공과 에러가 동시에 켜진 경우(목록과 에러가 같이 뜬다), 넷 다 켜진 경우까지. 택배로 치면 배송 중 스위치와 반송 중 스위치를 따로 두고, 둘 다 켜질 수 있게 만들어 둔 셈이다. 배송 중이면서 반송 중인 택배 화면. 있을 수 없는데 앱은 그릴 수 있다.
유한 상태 기계 진영에서는 이런 걸 불가능한 상태(impossible state)라고 부른다. 뜻은 단순하다. 화면에 나타나면 버그인데, 코드상으로는 멀쩡히 표현 가능한 상태. 문제는 이 열두 가지가 전부 버그 후보로 살아 있다는 점이다. 렌더 코드를 보면 왜 그런지 바로 보인다.
function UserList({ state }: { state: FetchState }) {
return (
<div>
{state.isLoading && <Spinner />}
{state.isError && <ErrorBanner message={state.error} />}
{state.isSuccess && <List items={state.data} />}
</div>
);
}
이 코드도 모양만 읽으면 된다. 가운데 기호 &&는 앞이 켜져 있으면 뒤를 그린다는 뜻이다. 곧 세 줄은 각각 이렇게 읽힌다. 로딩 스위치가 켜져 있으면 스피너를 그린다. 에러 스위치가 켜져 있으면 에러 배너를 그린다. 성공 스위치가 켜져 있으면 목록을 그린다.
문제는 세 줄이 서로를 모른다는 점이다. 각자 자기 스위치만 본다. 그래서 로딩과 에러 스위치가 동시에 켜져 있으면 스피너와 에러 배너가 아무렇지 않게 나란히 뜬다. 아까 그 QA 스크린샷이 바로 이 코드에서 나온다. 각 스위치를 켜고 끄는 책임이 여기저기 흩어져 있으니, 어느 한 곳에서 짝을 맞추는 걸 놓치면 화면이 어긋난다. 스위치가 늘수록 짝을 맞춰야 할 곳도 늘고, 놓칠 확률도 같이 오른다.
상태 하나에 데이터를 묶는다
방향을 틀어야 한다. 상태를 서로 직교하는 불린 여러 개로 들지 말고, 하나의 상태값과 그 상태에서만 유효한 데이터로 든다. 타입으로 쓰면 이렇게 된다.
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; message: string };
이 코드가 이 글에서 가장 중요하니 천천히 읽자. 맨 앞의 세로 막대 기호 |는 또는이라는 뜻이다. 그러니 이 타입은 아래 네 줄 중 하나라고 읽으면 된다. 상태(status)가 대기이거나, 로딩이거나, 성공이거나, 에러이거나. 정확히 택배와 같은 구조다. 택배가 배송 준비, 배송 중, 배송 완료, 반송 중 넷 중 하나이지 둘일 수 없는 것처럼, 이 컴포넌트도 네 상태 중 하나만 갖는다. 스위치 네 개를 따로 켜고 끄던 앞의 방식과 결정적으로 다르다. 이제 상태는 하나의 값이고, 그 값은 넷 중 하나로 못 박힌다.
이걸 태그된 합집합(discriminated union)이라고 부른다. status라는 공통 표시가 지금 어느 갈래인지를 한 값으로 못 박는 역할을 한다. 네 개의 유효한 상태만 나열했으니, 로딩이면서 에러 같은 조합은 타입에 아예 자리가 없다. 표현할 수 없으니 만들어질 수도 없다. 배송 중이면서 반송 중인 택배를 앱이 그리고 싶어도 그릴 수가 없는 것이다.
여기서 결정적인 이점이 하나 더 있다. data는 success 안에만 산다. error의 message도 error 안에만 있다. 그래서 로딩 상태인데 실수로 data를 읽으려 하면 컴파일러가 막는다. 성공인데 data가 없거나, 에러인데 message가 없는 어긋남이 타입 레벨에서 사라진다. 상태와 데이터가 한 몸으로 묶였기 때문이다.
렌더 코드도 덩달아 정직해진다.
function UserList({ state }: { state: FetchState }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <List items={state.data} />;
case "error":
return <ErrorBanner message={state.message} />;
}
}
switch는 지금 상태가 무엇이냐에 따라 하나의 갈래만 타는 구조다. case "loading":은 지금 상태가 로딩이라면이라고 읽으면 된다. 로딩이면 스피너만, 성공이면 목록만, 에러면 에러 배너만 그린다. 택배 조회 화면이 지금 상태 하나만 보고 그에 맞는 안내 한 줄만 보여주는 것과 같다.
이제 스피너와 에러가 같이 뜰 길이 없다. 상태는 한 번에 하나의 값만 갖고, 갈래는 서로 배타적이라 두 개가 동시에 그려질 수 없다. 그리고 목록 데이터는 성공 갈래 안에서만 꺼낼 수 있으니, 로딩이나 에러 상태에서 실수로 목록을 그리려 하면 코드를 작성하는 그 순간 에러로 걸린다. 화면에 버그가 새기 전에, 편집기에서 먼저 막힌다.
이 아이디어는 Richard Feldman이 2016년 Elm Conf에서 “Making Impossible States Impossible"이라는 제목의 발표로 정리해 널리 알려졌다. 리액트 진영에서는 Kent C. Dodds가 거의 같은 제목의 글로 옮겼다. 언어와 프레임워크는 달라도 골자는 하나다. 표현할 수 없으면 버그도 없다.
상태만이 아니라 전이를 명시한다
태그된 합집합이 막아주는 건 상태와 데이터의 어긋남이다. 그런데 그것만으로는 절반이다. success에서 갑자기 idle로 튀거나, error에서 로딩도 거치지 않고 곧장 success로 넘어가는 잘못된 흐름은 여전히 가능하다. setState로 아무 상태나 대입할 수 있으니까. 각 상태는 유효하지만, 상태에서 상태로 넘어가는 경로가 제멋대로다.
여기서 한 단계 더 간다. 상태를 아무 데서나 바꾸지 않고, 정해진 사건이 일어났을 때만 정해진 규칙대로 바꾼다. 이게 유한 상태 기계(finite state machine)의 핵심이다.
다시 택배로 가자. 택배 상태는 아무렇게나 바뀌지 않는다. 배송 중인 택배에 도착 스캔이 찍히면 배송 완료로 바뀐다. 이때 도착 스캔이 사건이고, 배송 중에서 배송 완료로 넘어가는 게 전이다. 그리고 규칙이 있다. 배송 완료된 택배는 도착 스캔이 또 찍혀도 아무 일이 없고, 배송 완료에서 배송 준비 중으로 되돌아가는 일은 없다. 어느 상태에서 어떤 사건이 나면 어디로 가는지, 그리고 어떤 이동은 금지인지를 적은 표가 곧 전이표다.
UI도 똑같다. 사건은 요청 시작, 응답 도착, 응답 실패, 다시 시도 같은 것들이다. 지금 상태와 방금 일어난 사건, 이 둘을 받아서 다음 상태를 돌려주는 함수 하나로 모든 이동을 통제한다. 이 함수를 리듀서라고 부르는데, 라이브러리 없이 쓰면 이렇게 된다.
type Event =
| { type: "FETCH" }
| { type: "RESOLVE"; data: User[] }
| { type: "REJECT"; message: string }
| { type: "RETRY" };
function reducer(state: FetchState, event: Event): FetchState {
switch (state.status) {
case "idle":
return event.type === "FETCH" ? { status: "loading" } : state;
case "loading":
if (event.type === "RESOLVE") return { status: "success", data: event.data };
if (event.type === "REJECT") return { status: "error", message: event.message };
return state;
case "success":
return state;
case "error":
return event.type === "RETRY" ? { status: "loading" } : state;
}
}
코드가 길어 보여도 읽는 법은 앞의 switch와 같다. 지금 상태별로 갈래를 타고, 그 안에서 어떤 사건이 왔는지 본다. 하나씩 택배에 얹어 읽어보자. 대기 상태에서 요청 시작(FETCH) 사건이 오면 로딩으로 간다. 배송 준비 중인 택배가 발송되면 배송 중으로 넘어가는 것과 같다. 로딩 상태에서 응답 도착(RESOLVE)이 오면 성공으로, 실패(REJECT)가 오면 에러로 간다. 배송 중인 택배가 도착 스캔이면 완료, 반송 스캔이면 반송으로 가는 것과 똑같다. 에러 상태에서 다시 시도(RETRY)가 오면 로딩으로 돌아간다.
중요한 건 표에 적지 않은 이동은 일어나지 않는다는 점이다. 성공 상태는 어떤 사건이 와도 그대로 머문다. 배송 완료된 택배가 스캔이 또 찍혀도 상태가 안 바뀌는 것과 같다. 대기가 아닌 상태에서 요청 시작 사건이 뒤늦게 들어와도 그냥 무시된다. 앞에서 문제였던 스피너와 에러가 같이 뜨는 상황은, 이제 그런 전이를 표에 아예 안 적었으니 도달할 방법이 없다. 어느 상태에서 무슨 사건을 만나면 어디로 가는지가 표 한 장으로 눈에 들어오고, 그 표에 없는 이동은 애초에 불가능하다.
이 사고방식을 시각 형식으로 밀어붙인 게 statechart다. David Harel이 1987년 논문 “Statecharts: A Visual Formalism for Complex Systems"에서 제안한 형식으로, 평범한 상태 기계에 계층과 병렬성, 통신을 얹어 확장한 것이다. 프론트엔드에서 이걸 라이브러리로 구현한 대표 주자가 David Khourshid의 XState다. 다만 여기서 강조하고 싶은 건 특정 도구가 아니라 규율이다. XState를 쓰지 않아도, 방금처럼 리듀서 switch 문만으로 같은 원리를 얻는다. 핵심은 라이브러리가 아니라 상태를 열거하고 전이를 명시한다는 태도다.
상태 다이어그램이 곧 디자인 스펙이다
이 지점에서 디자인과 구현이 만난다. 그리고 이게 상태 기계 이야기가 개발자만의 것이 아닌 이유다.
디자이너가 컴포넌트를 그릴 때 흔히 기본 상태와 호버 상태만 그린다. 버튼이면 default, hover, 잘하면 pressed까지. 그런데 로딩, 비활성, 에러, 빈 상태는 시안에서 자주 누락된다. 개발자가 구현하다가 데이터가 없을 때를 물어보면 그제서야 빈 상태를 급하게 그린다. 상태를 빠뜨린 명세는 그 빠진 자리를 개발자가 즉흥으로 메우게 만들고, 그렇게 메운 화면은 디자이너가 의도한 적 없는 화면이 된다.
상태 기계는 정확히 이 습관을 교정하는 규율이다. 규칙은 두 줄이다. 이 컴포넌트가 가질 수 있는 모든 상태를 먼저 열거하라. 그다음 어느 상태에서 어느 상태로 갈 수 있는지 선을 그어라. 실무 대화로 옮기면 이런 장면이다.
디자이너: “목록 컴포넌트 시안이야. 기본이랑 호버 상태 그렸어.” 개발자: “데이터 불러오는 동안엔 뭐가 보여요? 실패하면요? 목록이 비어 있으면요?” 디자이너: “아, 로딩이랑 빈 상태는 아직 안 그렸네.”
이 세 질문이 곧 상태 열거다. 그리고 이렇게 열거한 상태 다이어그램은 그 자체로 디자인 스펙이자 구현 스펙이 된다. 디자이너에게는 그려야 할 화면의 목록이고, 개발자에게는 리듀서에 그대로 옮겨 담을 상태와 전이표다. 하나의 그림이 두 역할을 겸한다.
그런데 여기서 시니어라면 한 겹 더 볼 게 있다. 디자이너는 대개 상태 목록은 주지만 전이는 주지 않는다. 시안은 다섯 장의 화면으로 넘어오는데, 그중 어느 화면에서 어느 화면으로 갈 수 있는지, 어느 전이는 금지인지는 명세에 없다. 하지만 버그는 화면 안이 아니라 화면 사이, 곧 전이에서 산다. 다섯 개 상태를 나열한 명세는 절반짜리 명세다. 나머지 절반은 상태들을 잇는 화살표다.
전이를 명세에 넣는 순간 지각의 문제도 딸려 온다. 로딩이 너무 빨리 끝나면 스피너가 깜빡하고 사라져서 오히려 화면이 덜컹거리는 것처럼 느껴진다. 그래서 로딩 표시를 최소 몇백 밀리초는 유지하는 규칙을 흔히 넣는데, 실무에서 도는 값은 대략 300에서 500밀리초 사이다. 어느 하나가 표준이라기보다 깜빡임이 사라지는 선에서 팀이 고르는 값에 가깝다. 그리고 이 규칙은 순수한 네 상태 기계로는 안 잡힌다. 로딩 시작 후 최소 시간이 지났는가라는 시간 축이 전이 조건으로 끼어들기 때문이다. Statechart가 시간과 병렬 상태까지 형식에 포함시킨 이유가 여기 있다. 사람의 지각이 상태 모델을 더 정교한 쪽으로 밀어올린다.
어디까지 상태 기계로 갈까
모든 UI에 상태 기계를 씌우는 건 과하다. 버튼 하나에 statechart를 그리는 건 오버엔지니어링이다. hover와 pressed는 CSS 의사 클래스로 충분하고, 굳이 이벤트와 전이표를 따로 그릴 이유가 없다. 상태가 둘뿐이고 전이 규칙이랄 것도 없는 데서 상태 기계는 배보다 배꼽이다.
제값을 하는 자리는 따로 있다. 비동기 데이터 페칭, 여러 단계를 오가는 폼(위저드), 결제 플로우, 미디어 플레이어. 공통점은 상태가 여럿이고, 상태 사이 전이에 지켜야 할 규칙이 있고, 잘못된 전이가 실제 사고로 이어진다는 것이다. 결제 플로우에서 결제 완료 상태에 두 번 진입하면 이중 결제다. 이런 데서는 전이를 명시해서 얻는 게 그 비용을 크게 넘어선다. 반대로 사고로 이어질 잘못된 전이가 없다면, 상태 기계는 굳이 꺼낼 도구가 아니다.
상태 기계를 쓰기로 했다면 이번엔 상태 기계 자체가 부풀 걱정을 해야 한다. 미디어 플레이어를 보자. 재생인지 일시정지인지가 한 축이고, 음소거인지 아닌지가 또 다른 축이다. 이 둘을 하나의 평면 상태로 곱하면 재생-음소거, 재생-음소거해제, 일시정지-음소거, 일시정지-음소거해제로 조합이 다시 폭발한다. 여기서 병렬 상태(parallel state)를 쓴다. 두 축을 따로 돌리고 곱하지 않는다. 또 로그인 여부처럼 여러 하위 상태를 통째로 감싸는 큰 상태가 있으면 계층 상태(hierarchical state)로 묶는다. 로그아웃 상태 안에는 세부 화면이 없고, 로그인 상태 안에만 목록과 상세와 편집이 들어가는 식이다.
여기서 앞과 모순처럼 보이는 대목이 있다. 처음엔 직교하는 불린 여러 개를 죽이자고 했는데, 병렬 상태는 축을 오히려 둘로 나눈다. 모순이 아니다. 재생과 음소거는 실제로 서로 독립이다. 어느 조합이든 유효하다. 반면 isLoading과 isError는 독립이 아니다. 둘이 동시에 참인 조합은 유효하지 않다. 상태 모델링의 기술은 무엇이 진짜 직교하고 무엇이 서로 배타적인지를 정확히 가려내는 데 있다. 배타적인 걸 직교하는 불린으로 두면 불가능한 상태가 열리고, 직교하는 걸 하나로 곱하면 상태가 폭발한다. 양쪽을 가르는 게 설계다.
불가능한 상태를 불가능하게 만든다는 말은 결국 하나의 태도다. 잘못될 수 있는 걸 나중에 방어 코드로 막지 말고, 애초에 표현할 수 없게 설계하라. 불린 네 개는 표현할 수 있는 게 너무 많아서, 그 대부분이 버그다. 상태 하나와 거기에 묶인 데이터, 그리고 명시된 전이는 표현 가능한 것을 딱 유효한 것만으로 줄인다. 그리고 이건 코드만의 이야기가 아니다. 디자이너가 컴포넌트의 모든 상태를 열거하고 그 사이 전이를 그리는 순간, 시안과 구현이 같은 언어로 만난다. 상태 다이어그램은 디자인이 개발에게 넘기는 명세이자, 개발이 그대로 옮겨 담는 뼈대다. 좋은 상태 모델은 버그를 막는 동시에 팀이 같은 그림을 보게 만든다.
이 글, 어떠셨어요?