
isLoading, isError, isSuccess 변수 3개 쓰다가 지옥을 봤습니다
API 요청 상태를 관리할 때 불리언 플래그 여러 개를 쓰시나요? 'impossible state(불가능한 상태)'를 방지하고, if 문 도배를 없애는 Discriminated Unions 패턴.

API 요청 상태를 관리할 때 불리언 플래그 여러 개를 쓰시나요? 'impossible state(불가능한 상태)'를 방지하고, if 문 도배를 없애는 Discriminated Unions 패턴.
페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

백엔드: 'API 다 만들었어요.' 프론트엔드: '어떻게 써요?' 이 지겨운 대화를 끝내주는 Swagger(OpenAPI)의 마법.

전역 상태 관리를 위해 Redux 대신 Context API를 선택했습니다. 하지만 `UserContext`에 모든 정보를 담자마자 앱 전체가 리렌더링되기 시작했습니다. Context 분리(Splitting) 전략.

true이면서 동시에 false라고?"API 요청 상태를 관리하기 위해 useState를 이렇게 짰습니다.
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
직관적이고 좋아 보였습니다. 그런데 코드가 복잡해지면서 버그가 생겼습니다.
어느 순간 isLoading: true이면서 isError: true이고 data도 있는 기이한 상태가 된 겁니다.
로딩 스피너랑 에러 메시지가 동시에 뜨고, 뒤에는 옛날 데이터가 깔려있는 혼종 UI가 탄생했습니다.
"아니, 로딩 중이면 에러가 아니어야지! 변수 4개를 따로 관리하니까 싱크가 안 맞잖아!"
저는 "상태를 쪼개면 관리가 쉽다"고 생각했습니다. 하지만 변수가 독립적이라는 건, 조합(Combination)의 수가 폭발한다는 뜻입니다.
isLoading (T/F)isError (T/F)data (있음/없음)이 조합만 해도 2^3 = 8가지입니다. 하지만 실제 유효한 상태는 (로딩 중), (성공), (실패), (대기) 딱 4가지뿐입니다. 나머지 4가지는 "있어서는 안 될 상태(Impossible State)"인데, 제 코드는 그걸 허용하고 있었습니다.
이걸 "신호등 예제"에 비유하니 이해가 됐습니다.
Green, Red, Yellow 중 하나만 선택하게 합니다. 물리적으로 2개가 동시에 켜질 수 없습니다.상태 관리는 신호등처럼 "한 번에 하나의 상태만" 가져야 합니다.
TypeScript의 가장 강력한 기능인 Discriminated Unions를 쓰면 이 문제를 완벽하게 해결할 수 있습니다.
핵심은 모든 상태에 공통된 필드(주로 status나 type)를 두는 것입니다.
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T } // 성공일 때만 data가 있음!
| { status: 'error'; error: string }; // 실패일 때만 error가 있음!
이제 status가 'loading'이면 data에 접근조차 할 수 없습니다. 컴파일러가 막습니다.
"로딩 중에 데이터를 보여줘" 같은 말도 안 되는 코드를 짤 수가 없게 되는 거죠.
const [state, setState] = useState<ApiState<User>>({ status: 'idle' });
// 데이터 로딩 시
setState({ status: 'loading' });
// 성공 시 (이전 변수 초기화 깜빡할 걱정 없음. 그냥 통째로 교체니까!)
setState({ status: 'success', data: fetchedUser });
// 실패 시
setState({ status: 'error', error: 'Network Error' });
switch (state.status) {
case 'idle':
return <button onClick={fetch}>Load</button>;
case 'loading':
return <Spinner />;
case 'error':
return <ErrorMessage msg={state.error} />; // 여기서만 error 접근 가능
case 'success':
return <UserProfile user={state.data} />; // 여기서만 data 접근 가능
}
if-else 도배할 필요 없이 깔끔하게 떨어집니다.
모든 상태를 처리하지 않으면 TypeScript가 "너 error 케이스 빼먹었어!"라고 알려주기까지 합니다 (Exhaustiveness Checking).
이 패턴은 useReducer나 Redux와 함께 쓸 때 빛을 발합니다.
액션(type)에 따라 상태를 어떻게 바꿀지 정의할 때도 Discriminated Union을 씁니다.
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User }
| { type: 'FETCH_FAIL'; error: string };
function reducer(state: ApiState<User>, action: Action): ApiState<User> {
switch (action.type) {
case 'FETCH_START': return { status: 'loading' };
case 'FETCH_SUCCESS': return { status: 'success', data: action.payload };
case 'FETCH_FAIL': return { status: 'error', error: action.error };
}
}
상태 전이 로직이 한곳에 모이고, 타입 안전성까지 챙길 수 있습니다.
사실 우리가 직접 짤 필요도 없이, TanStack Query (React Query)가 내부적으로 이 패턴을 쓰고 있습니다.
const result = useQuery(...)
if (result.status === 'success') {
// result.data가 확정적으로 존재함
}
좋은 라이브러리들은 이미 "불가능한 상태를 불가능하게(Make Impossible States Impossible)" 만들고 있습니다.
switch 문을 쓸 때, 실수로 case 'error':를 빼먹으면 어떻게 될까요?
TypeScript는 기본적으로 불평하지 않습니다. 그냥 넘어갑니다.
하지만 우리는 "모든 케이스를 처리하지 않으면 에러를 뱉게" 만들고 싶습니다.
이럴 때 never 타입을 활용한 assertNever 헬퍼 함수를 씁니다.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
// ...
switch (state.status) {
case 'idle': return ...;
case 'loading': return ...;
case 'success': return ...;
// 'error' 케이스를 빼먹었다고 칩시다.
default:
// 여기서 빨간 줄이 쫙! 그어집니다.
// "Argument of type 'ApiState<User>' is not assignable to parameter of type 'never'."
return assertNever(state);
}
default 블록에 도달했다는 건, state 변수에 아직 처리되지 않은 타입(여기선 error 상태)이 남아있다는 뜻입니다.
하지만 assertNever는 인자로 never(절대 발생할 수 없는 값)만 받습니다.
남아있는 타입이 있으니 never가 아니게 되고, 컴파일 에러가 발생합니다.
이 패턴을 쓰면, 나중에 상태(status: 'refetching')를 하나 추가했을 때
컴파일러가 "야, 저기 switch 문에서 이거 처리 안 했어!"라고 모든 곳을 찾아내서 알려줍니다.
리팩토링의 신(God)이 도우시는 거죠.
단순 API 호출 말고, 상태가 5~6개 넘어가는 결제 시스템에서 이 패턴은 생명줄입니다.
쇼핑몰 결제 페이지입니다. 상태가 미쳐 날뜁니다.
이걸 isValidating, isProcessing, isWaiting3DS... 불리언으로 관리하면?
"검증 중이면서(True) 카드사 앱 대기 중(True)"인 말도 안 되는 상태가 나옵니다.
type PaymentState =
| { status: 'idle' }
| { status: 'validating'; couponId?: string }
| { status: 'processing'; orderId: string }
| { status: 'waiting_3ds'; redirectUrl: string; transactionId: string } // 3DS일 때만 URL 필요!
| { status: 'success'; receiptUrl: string }
| { status: 'fail'; reason: string; canRetry: boolean };
각 상태마다 필요한 데이터가 완전히 다릅니다.
waiting_3ds 상태일 때만 redirectUrl이 존재해야 합니다.
status로 구분하지 않으면 redirectUrl은 string | undefined가 되어버리고,
"이게 검증 단계에서 있는 건가? 완료 단계에서 있는 건가?" 헷갈리게 됩니다.
유니온 타입을 쓰면 UI 코드는 이렇게 명확해집니다.
if (state.status === 'waiting_3ds') {
// TypeScript가 state.redirectUrl이 확실히 있다고 보장해줌
window.location.href = state.redirectUrl;
}
Q: status라는 필드 이름은 고정인가요?
A: 아니요. type, kind, tag 등 아무거나 상관없습니다. 다만 팀 내에서 컨벤션을 정하세요. 보통 type이나 status를 많이 씁니다.
Q: 상태가 2개(성공/실패) 뿐이어도 써야 하나요?
A: 단순 토글(열림/닫힘)이라면 isOpen 불리언이 낫습니다. 하지만 "데이터가 같이 다니는 경우"(성공 시 데이터, 실패 시 에러 메시지)라면 2개라도 유니온이 낫습니다. 데이터 접근의 안전성을 보장해주니까요.
Q: isLoading 변수 하나만 따로 빼면 안 되나요?
A: "새로고침(Refetching)" 구현할 때 흔히 data도 있고 isLoading도 true인 상태를 만듭니다. 이 경우엔 status: 'success' 안에 isRefetching 속성을 넣거나, 별도의 status: 'reloading' 상태를 정의하는 게 낫습니다. 핵심은 "의도치 않은 조합"을 막는 것입니다.