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

API 요청 상태를 관리할 때 불리언 플래그 여러 개를 쓰시나요? 'impossible state(불가능한 상태)'를 방지하고, if 문 도배를 없애는 Discriminated Unions 패턴.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

백엔드: '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' 상태를 정의하는 게 낫습니다. 핵심은 "의도치 않은 조합"을 막는 것입니다.