불가능한 상태의 함정
API를 호출하고 결과를 화면에 보여주는 기능을 만들고 있었다. 처음엔 간단했다. isLoading, isError, data 세 개의 상태만 있으면 될 것 같았다.
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState<User | null>(null);
// API 호출
setIsLoading(true);
try {
const result = await fetchUser();
setData(result);
} catch (error) {
setIsError(true);
} finally {
setIsLoading(false);
}
그런데 뭔가 이상했다. 컴포넌트를 렌더링하는 로직을 짜다 보니, isLoading이 true인데 동시에 data도 있는 상황이 생길 수 있었다. isError가 true인데 data도 존재하는 경우도 마찬가지였다. 이건 말이 안 되는 상태 조합이다. 로딩 중이면 데이터가 없어야 하고, 에러가 났으면 성공한 데이터가 있으면 안 된다.
더 큰 문제는 이런 불가능한 상태를 타입 시스템이 전혀 막아주지 못한다는 거였다. 세 개의 독립적인 상태를 관리하다 보니, 실제로는 존재할 수 없는 상태 조합이 타입상으로는 완벽하게 합법적이었다.
신호등을 생각해보자. 빨강, 노랑, 초록 세 개의 불이 있다고 해서 이걸 isRed, isYellow, isGreen 세 개의 boolean으로 관리하면 어떻게 될까? 빨강과 초록이 동시에 켜지는 상황이 코드상으로는 가능해진다. 실제 신호등은 한 번에 하나의 상태만 가질 수 있는데 말이다.
Discriminated Union의 발견
결국 이건 상태를 표현하는 방식 자체가 잘못됐다는 걸 깨달았다. API 요청의 상태는 여러 개의 boolean 조합이 아니라, 명확히 구분되는 하나의 상태여야 한다. 그게 바로 Discriminated Union이었다.
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
const [state, setState] = useState<ApiState<User>>({ status: 'idle' });
이 순간 모든 게 달라졌다. 이제 상태는 네 가지 중 정확히 하나다. idle(아직 요청 안 함), loading(로딩 중), success(성공, 데이터 있음), error(실패, 에러 메시지 있음). 불가능한 조합은 타입상으로 아예 표현할 수 없게 됐다.
status라는 속성이 핵심이다. 이걸 discriminant(판별자)라고 부른다. TypeScript는 이 속성을 보고 현재 어떤 상태인지 정확히 판별하고, 그에 따라 타입을 좁혀준다(type narrowing).
function renderUser(state: ApiState<User>) {
switch (state.status) {
case 'idle':
return <div>Press button to load</div>;
case 'loading':
return <div>Loading...</div>;
case 'success':
// 여기서는 state.data가 User 타입으로 자동 추론됨
return <div>Hello, {state.data.name}</div>;
case 'error':
// 여기서는 state.error가 string 타입으로 자동 추론됨
return <div>Error: {state.error}</div>;
}
}
switch 문 안에서 state.status를 체크하는 순간, TypeScript는 각 case 블록 안에서 정확한 타입을 알려준다. success 케이스에서는 state.data에 접근할 수 있고, error 케이스에서는 state.error에 접근할 수 있다. 다른 케이스에서 이런 속성들에 접근하려고 하면? 컴파일 에러가 난다.
이게 바로 "Make Illegal States Unrepresentable"(불가능한 상태를 표현 불가능하게 만들기)라는 원칙이다. 타입 시스템을 활용해서 애초에 말이 안 되는 상태를 만들 수 없게 설계하는 것이다.
실제로의 활용 패턴
1. 폼 스텝 관리
여러 단계로 이루어진 회원가입 폼을 만들 때도 Discriminated Union이 빛을 발한다.
type SignupStep =
| { step: 'email'; email: string }
| { step: 'password'; email: string; password: string }
| { step: 'profile'; email: string; password: string; name: string }
| { step: 'complete'; userId: string };
function SignupForm({ currentStep }: { currentStep: SignupStep }) {
switch (currentStep.step) {
case 'email':
// currentStep.email만 접근 가능
return <EmailInput defaultValue={currentStep.email} />;
case 'password':
// currentStep.email과 currentStep.password 접근 가능
return <PasswordInput email={currentStep.email} />;
case 'profile':
// currentStep.name까지 접근 가능
return <ProfileForm name={currentStep.name} />;
case 'complete':
// currentStep.userId만 접근 가능
return <WelcomeMessage userId={currentStep.userId} />;
}
}
각 스텝이 어떤 데이터를 가지고 있는지 타입으로 명확하게 표현된다. email 스텝에서 password에 접근하려고 하면? 컴파일 에러다.
2. 인증 상태 관리
로그인 상태도 마찬가지다. isLoggedIn boolean 하나로 관리하다가 낭패를 본 적이 있다.
type AuthState =
| { kind: 'anonymous' }
| { kind: 'authenticating' }
| { kind: 'authenticated'; user: User; token: string }
| { kind: 'authFailed'; reason: string };
function getAuthHeader(auth: AuthState): string | null {
if (auth.kind === 'authenticated') {
// auth.token이 확실히 존재함을 TypeScript가 보장
return `Bearer ${auth.token}`;
}
return null;
}
if 문으로도 type narrowing이 작동한다. auth.kind === 'authenticated' 체크를 하면, 그 블록 안에서는 auth가 { kind: 'authenticated'; user: User; token: string } 타입으로 좁혀진다.
3. Redux/useReducer 액션
Redux 액션을 Discriminated Union으로 정의하면, reducer에서 완벽한 타입 안전성을 얻을 수 있다.
type TodoAction =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'DELETE_TODO'; id: number }
| { type: 'SET_FILTER'; filter: 'all' | 'active' | 'completed' };
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
// action.text가 string 타입으로 자동 추론
return { ...state, todos: [...state.todos, { text: action.text, completed: false }] };
case 'TOGGLE_TODO':
// action.id가 number 타입으로 자동 추론
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case 'DELETE_TODO':
return { ...state, todos: state.todos.filter(todo => todo.id !== action.id) };
case 'SET_FILTER':
// action.filter가 'all' | 'active' | 'completed' 타입으로 추론
return { ...state, filter: action.filter };
}
}
각 액션 타입마다 필요한 payload가 달라지는데, Discriminated Union으로 정의하면 type에 따라 어떤 속성에 접근할 수 있는지 TypeScript가 정확히 알려준다.
4. Exhaustiveness Checking (빠짐없이 처리하기)
Discriminated Union의 또 다른 강력한 기능은 모든 케이스를 빠짐없이 처리했는지 검사할 수 있다는 것이다.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'rectangle'; width: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.size ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// shape은 여기서 never 타입
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
나중에 누군가 triangle 타입을 추가하면? default 케이스에서 타입 에러가 발생한다. shape이 더 이상 never 타입이 아니기 때문이다. 이렇게 하면 새로운 케이스를 추가했을 때 빠뜨린 곳이 있으면 컴파일 타임에 즉시 발견할 수 있다.
5. Enum vs Discriminated Union
TypeScript의 enum을 쓸 수도 있지만, 나는 Discriminated Union을 더 선호한다.
// Enum 방식
enum Status {
Idle,
Loading,
Success,
Error,
}
interface ApiStateWithEnum<T> {
status: Status;
data?: T;
error?: string;
}
// Discriminated Union 방식
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
Enum 방식은 결국 data와 error가 optional이라서, 다시 불가능한 상태 문제로 돌아간다. status가 Success인데 data가 undefined일 수도 있는 거다. Discriminated Union은 각 상태에 필요한 데이터가 정확히 타입에 표현된다.
게다가 Discriminated Union은 string literal을 쓰기 때문에 런타임에서도 디버깅이 쉽다. Enum은 숫자로 컴파일되면 로그를 봐도 무슨 의미인지 알기 어렵다.
6. Boolean Flags에서 마이그레이션하기
기존 코드를 Discriminated Union으로 바꾸는 건 단계적으로 할 수 있다.
// Before: 여러 개의 boolean
interface UserProfileBefore {
isLoading: boolean;
isError: boolean;
isSaving: boolean;
profile: UserProfile | null;
error: string | null;
}
// After: Discriminated Union
type UserProfileState =
| { status: 'loading' }
| { status: 'loaded'; profile: UserProfile }
| { status: 'saving'; profile: UserProfile }
| { status: 'error'; error: string };
// 마이그레이션을 위한 어댑터 함수
function adaptLegacyState(legacy: UserProfileBefore): UserProfileState {
if (legacy.isLoading) return { status: 'loading' };
if (legacy.isError) return { status: 'error', error: legacy.error! };
if (legacy.isSaving) return { status: 'saving', profile: legacy.profile! };
return { status: 'loaded', profile: legacy.profile! };
}
기존 코드를 한 번에 다 바꾸기 어려우면, 어댑터 함수를 만들어서 점진적으로 마이그레이션할 수 있다.
상태는 하나의 온전한 형태로
Discriminated Union을 쓰기 시작하면서, 상태를 설계하는 방식 자체가 바뀌었다. 여러 개의 flag를 조합해서 상태를 표현하는 게 아니라, 상태 자체를 하나의 완전한 값으로 모델링하게 됐다.
신호등은 빨강, 노랑, 초록이라는 세 개의 boolean이 아니라, "현재 신호등 상태"라는 하나의 값이다. API 요청도 마찬가지다. isLoading과 data의 조합이 아니라, "현재 요청 상태"라는 하나의 값으로 표현해야 한다.
타입 시스템은 단순히 버그를 잡아주는 도구가 아니다. 우리가 다루는 도메인을 어떻게 모델링할지 생각하게 만드는 도구다. Discriminated Union은 그 강력한 예시였다. 불가능한 상태를 타입으로 표현 불가능하게 만들면, 런타임에서 그런 상황을 방어하는 코드를 짤 필요가 없어진다. 컴파일러가 대신 지켜주니까.
이제는 복잡한 상태를 마주할 때마다 먼저 생각한다. "이 상태의 가능한 모든 형태를 나열할 수 있을까?" 나열할 수 있다면, 그게 바로 Discriminated Union으로 표현할 타이밍이다.