
Discriminated Union: 복잡한 상태를 타입으로 표현하기
isLoading, isError, data를 따로 관리하다가 불가능한 상태 조합이 생겼다. Discriminated Union으로 상태를 완벽하게 표현한 이야기.

isLoading, isError, data를 따로 관리하다가 불가능한 상태 조합이 생겼다. Discriminated Union으로 상태를 완벽하게 표현한 이야기.
전역 상태 관리를 위해 Redux 대신 Context API를 선택했습니다. 하지만 `UserContext`에 모든 정보를 담자마자 앱 전체가 리렌더링되기 시작했습니다. Context 분리(Splitting) 전략.

any를 쓰면 타입스크립트를 쓰는 의미가 없습니다. 제네릭(Generics)을 통해 유연하면서도 타입 안전성(Type Safety)을 모두 챙기는 방법을 정리합니다. infer, keyof 등 고급 기법 포함.

서비스를 MSA로 쪼갰더니 트랜잭션 관리가 지옥이 되었습니다. 주문은 성공했는데 결제는 실패하고, 재고는 이미 차감되었다면? 모놀리식의 ACID가 그리워지는 순간, 분산 환경에서 데이터 일관성을 지키는 Two-Phase Commit(2PC), Saga 패턴(Choreography, Orchestration)을 구체적인 예제와 함께 다뤄봤습니다.

부모에서 전달한 props가 undefined로 나와서 앱이 크래시되는 문제 해결

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으로 관리하면 어떻게 될까? 빨강과 초록이 동시에 켜지는 상황이 코드상으로는 가능해진다. 실제 신호등은 한 번에 하나의 상태만 가질 수 있는데 말이다.
결국 이건 상태를 표현하는 방식 자체가 잘못됐다는 걸 깨달았다. 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"(불가능한 상태를 표현 불가능하게 만들기)라는 원칙이다. 타입 시스템을 활용해서 애초에 말이 안 되는 상태를 만들 수 없게 설계하는 것이다.
여러 단계로 이루어진 회원가입 폼을 만들 때도 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에 접근하려고 하면? 컴파일 에러다.
로그인 상태도 마찬가지다. 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 } 타입으로 좁혀진다.
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가 정확히 알려준다.
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 타입이 아니기 때문이다. 이렇게 하면 새로운 케이스를 추가했을 때 빠뜨린 곳이 있으면 컴파일 타임에 즉시 발견할 수 있다.
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은 숫자로 컴파일되면 로그를 봐도 무슨 의미인지 알기 어렵다.
기존 코드를 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으로 표현할 타이밍이다.