정의
상태 설계 사다리는 "이 상태를 union으로 모델링해야 하나?"를 판단하는 3단 순서예요. 위에서부터 확인하고, 앞 단에서 끝나면 아래로 내려가지 않아요.
- 파생 가능하면 저장하지 않아요. 원본에서 계산해요.
- 라이브러리가 이미 union을 소유하면 그대로 소비해요. 복사하지 않아요.
- 그래도 남는 진짜 client 상태만 union + 의도 함수로 만들어요.
핵심은 "좋은 union을 어떻게 쓰나"가 아니라 "union을 만들 필요가 있나" 를 먼저 묻는 거예요.
왜 필요한가
상태 모델링을 배우면 대부분 3단부터 시작해요. type State = { status: 'idle' } | { status: 'loading' } | ... 를 만들고 뿌듯해하죠. 그런데 실제로 버그가 나는 자리는 대개 1단과 2단이에요.
1단을 건너뛰면 이런 코드가 나와요.
const [items, setItems] = useState<Item[]>([]);
const [itemCount, setItemCount] = useState(0); // items.length와 같은 사실
itemCount는 items에서 계산할 수 있는 값이에요. 두 곳이 같은 사실을 소유하니, 언젠가 한쪽만 갱신돼요. 타입을 아무리 정교하게 짜도 이 어긋남은 막지 못해요.
2단을 건너뛰면 이런 코드가 나와요.
const { data, isLoading, isError } = useQuery(...);
const [state, setState] = useState<'idle' | 'loading' | 'done' | 'failed'>('idle');
useEffect(() => {
if (isLoading) setState('loading');
else if (isError) setState('failed');
else if (data) setState('done');
}, [isLoading, isError, data]);
쿼리 라이브러리는 이미 status union을 갖고 있어요. 그것도 최신 호출 기준으로 시간축 처리까지 포함해서요. 그걸 useState로 다시 만들면, 응답 순서가 뒤집혔을 때 라이브러리는 알아도 내 복사본은 몰라요. 한 렌더 늦게 따라오는 두 번째 진실이 생겨요.
동작 원리
1단 — 파생 가능하면 저장하지 않아요
판정 질문은 하나예요. "이 값을 다른 값에서 계산할 수 있나요?"
// ❌ 두 곳이 같은 사실을 소유해요
const [items, setItems] = useState<Item[]>([]);
const [hasItems, setHasItems] = useState(false);
// ✅ 하나만 소유해요
const [items, setItems] = useState<Item[]>([]);
const hasItems = items.length > 0;
계산 비용이 걱정되면 그때 useMemo를 쓰면 돼요. 저장은 성능 최적화의 수단이 아니라 마지막 수단이에요.
2단 — 라이브러리가 소유한 union은 그대로 써요
서버에서 오는 데이터의 lifecycle은 대부분 이미 모델링돼 있어요. TanStack Query의 status·fetchStatus, mutation의 isPending·isSuccess·isError가 그래요.
// ❌ 같은 상태를 새 union으로 재포장
type NextPageState = 'can-load' | 'loading' | 'exhausted';
// ✅ 라이브러리 union을 직접 소비
const { status, data, fetchNextPage, hasNextPage } = useInfiniteQuery(...);
여기에 딸린 규칙이 두 개 있어요.
로딩·실패의 기본은 컴포넌트 분기가 아니라 경계예요. 무조건 실행되는 첫 조회라면 useSuspenseQuery + 국소 <Suspense> + Error Boundary로 올려서, 컴포넌트 본문에서 분기를 아예 없애요. 조건부 query나 취소 제약처럼 경계로 못 올리는 나머지에만 분기를 남겨요.
공통 UI가 lifecycle 전체를 알 필요는 없어요. "다음 페이지를 더 불러올 수 있나"만 필요하다면, 상태 union을 내려보내는 대신 callback의 존재 자체를 능력으로 삼아요.
// ❌ 공통 컴포넌트가 남의 lifecycle을 알아야 해요
type Props = { pageState: NextPageState };
// ✅ 함수가 있으면 할 수 있고, 없으면 못 해요
type Props = { onLoadMore?: () => void };
3단 — 남는 client 상태만 union으로
여기까지 왔으면 이제 진짜 union을 만들어요. 다만 두 가지를 지켜요.
raw setter를 밖으로 내보내지 않아요. hook은 setState가 아니라 도메인 의도를 표현하는 함수를 반환해요.
// ❌ 아무나 아무 상태로 바꿀 수 있어요
function useCheckout() {
const [state, setState] = useState<CheckoutState>(...);
return { state, setState };
}
// ✅ 허용된 전이만 노출해요
function useCheckout() {
const [state, setState] = useState<CheckoutState>(...);
return { state, submit, reset, goBack };
}
태그 객체 union은 멤버 둘 이상이 자기만의 필드를 가질 때만이에요.
// ✅ 태그 객체 — shipping에만 fieldErrors, review에만 quote가 있어요
type CheckoutState =
| { status: "cart"; items: CartItem[] }
| { status: "shipping"; address: Address; fieldErrors: FieldErrors }
| { status: "review"; quote: Quote; agreed: boolean };
// ✅ 리터럴 union — 딸린 데이터가 없어요. 감싸도 새로 막히는 게 없어요
type PaymentBadge = "unpaid" | "paid" | "refunded";
라벨 맵이 필요하다는 건 태그 객체를 만들 근거가 아니에요. satisfies Record<PaymentBadge, string> 는 리터럴 union에도 그대로 걸려요.
실무 적용
상태는 데이터, action은 형제
이게 3단에서 가장 자주 틀리는 지점이에요. state union 안에 함수를 넣지 않아요.
// ❌ 금지 — stale closure와 가짜 retry가 동시에 생겨요
type DetailState =
| { status: "loading" }
| { status: "failure"; retry: () => void };
// ✅ 상태는 데이터, action은 형제로
type DetailState =
| { status: "loading" }
| { status: "failure"; reason: LoadFailure };
function useDetail(id: DetailId): { state: DetailState; retry: () => void };
이유가 두 가지예요.
첫째, 저장한 함수는 자기를 만든 렌더의 클로저에 고정돼요. 이후 props나 파라미터가 바뀌어도 낡은 값을 계속 캡처해요. 상태는 갱신되는데 그 안의 함수는 과거를 보는 거예요.
둘째, 쓸 수 없는 상태에 no-op action을 채우게 돼요. 타입을 맞추려고 retry: () => undefined 를 넣으면, UI는 "재시도할 수 있다"는 거짓 정보를 받아요. 버튼이 보이는데 눌러도 아무 일도 안 일어나요.
상태 기계는 기본값이 아니에요
reducer, 전이표, XState는 순서 위반 자체가 도메인 오류인 흐름에만 써요. 결제, 다단계 제출, 낙관적 롤백 같은 것들이요.
단순 조회의 로딩·성공·실패는 2단에서 끝나요. 3단으로 내려가더라도 필요한 건 union 하나와 의도 함수 몇 개예요. "상태 모델 문서가 있다"는 사실만으로 Event union과 전이 함수를 만들면, 아무것도 막지 못하는 기계만 늘어나요.
잘못된 상태를 언제 나눌지
파싱 실패한 ID, 없는 라우트 파라미터 같은 것들이요. 판정 기준은 "화면과 복구 경로가 실제로 다른가" 예요.
같으면 기존 실패 상태에 합쳐요. 다르면 나누되, 그 상태만의 필드와 action을 각각 채워요. 요구사항에 구분이 없으면 발명하지 말고 물어봐요.
트레이드오프
앞 단으로 갈수록 코드는 줄지만 표현력은 떨어져요. 파생 계산은 매 렌더 다시 돌고, 라이브러리 union은 내가 원하는 이름이 아닐 수 있어요. 그래서 "그냥 내 union 만들면 편한데"라는 유혹이 계속 있어요.
그 유혹을 이기는 기준이 소유권이 하나인가예요. 편하려고 만든 복사본은 반드시 원본과 어긋나는 순간이 오고, 그때 디버깅 비용이 처음 아낀 시간보다 훨씬 커요.
반대로 사다리를 교조적으로 지키다 보면 라이브러리 API에 억지로 끼워 맞추게 될 때가 있어요. 조건부 query, placeholder, 취소 제약처럼 실제 실격 사유가 있으면 아래 단으로 내려가는 게 맞아요. 다만 그 사유를 적어 두세요. 적지 못하면 대개 실격 사유가 아니라 익숙함이에요.
사용하면 안 되는 경우
- 프로토타입 탐색 단계. 무엇을 만들지 자체가 미정이면 상태 구조를 고정하는 게 방해가 돼요.
- 상태가 하나뿐인 컴포넌트. boolean 하나로 끝나는 걸 union으로 감싸면 읽기만 어려워져요. 판정 기준은 "멤버마다 딸린 데이터가 다른가"예요.
- 라이브러리 없이 만드는 순수 폼. 필드 값과 에러만 있는 폼에 3단 union을 씌우면 과설계예요.
흔한 실수
- 파생 가능한 값을 상태로 저장해요. 가장 흔하고 가장 조용한 실패예요. 판별법은 "이 값을 지워도 다른 값에서 계산되나요?"
- 쿼리 상태를 로컬 기계로 복사해요. 한 렌더 늦게 따라오는 두 번째 진실이 생겨요.
- state union에 action을 넣어요. stale closure와 가짜 action이 동시에 생겨요.
- boolean 여러 개로 한 흐름을 표현해요.
isLoading+isError조합은 "로딩 중이면서 에러"라는 불가능한 상태를 타입상 허용해요.status리터럴 하나로 바꿔요. - 자기 필드가 없는데 태그 객체로 감싸요.
{ kind: 'paid' }로 감싸면 호출부마다.kind를 벗기는 비용만 늘고, 새로 막히는 잘못된 코드는 없어요. - 첫 조회의 로딩·에러를 컴포넌트 안에서 분기해요. 경계로 올릴 수 있는데 안 올린 거라면, 같은 분기가 컴포넌트마다 복제돼요.
관련 개념
- exhaustiveness-enforcement — 만든 union의 모든 경우를 처리했는지 컴파일러가 확인하게 만들기
- false-type-contracts — 타입이 런타임보다 강하게 약속하는 것을 피하기
- react-query-invalidate-vs-staletime — 2단에서 소비하는 쿼리 상태의 갱신 정책
- typescript-environment-contract — 이 규칙들이 실제로 강제되려면 필요한 컴파일러 설정