Discriminated Union으로 상태 관리 설계하기
리터럴 타입 유니온으로 속성 간 상관관계를 타입 레벨에서 강제하고, 런타임 에러를 컴파일 타임에 잡아내는 패턴
읽는 데 90분
- #typescript
- #discriminated-union
- #type-narrowing
- #type-system
- #patterns
이 문서의 목차
적용 환경: TypeScript 4.5+
프로젝트에서 API 응답 타입을 보면 이런 구조가 자주 보입니다.
interface ApiResponse {
status: string;
data?: UserProfile;
error?: string;
retryAfter?: number;
}의도는 명확합니다. status가 "success"이면 data가 있고, "error"이면 error와 retryAfter가 있는 구조입니다. 문제는 이 타입이 그 의도를 전혀 반영하지 못한다는 데 있습니다. data와 error가 동시에 존재하든, 둘 다 없든 타입 검사를 그대로 통과합니다.
// 타입 에러 없이 통과하지만, 논리적으로 말이 안 되는 상태
const nonsense: ApiResponse = {
status: "success",
error: "실패했습니다", // 성공인데 에러?
retryAfter: 3000, // 성공인데 재시도?
};근본 원인은 optional 필드로 상태 간 상관관계를 표현하려 했기 때문입니다. status가 "success"일 때 data는 반드시 존재하고 error는 절대 없다는 규칙이 타입 어디에도 인코딩되어 있지 않습니다. 결과적으로 if (response.data) 같은 방어 코드가 코드베이스 전체에 퍼지게 되고, 하나라도 빠뜨리는 순간 런타임 에러로 이어집니다.
Discriminated Union은 이 문제를 구조적으로 잡아내는 패턴으로, 하나의 속성 값이 결정되면 나머지 속성의 존재 여부와 타입까지 자동으로 확정됩니다.
Discriminated Union의 핵심 메커니즘은 리터럴 타입에 있습니다. TypeScript에서 string은 모든 문자열을 허용하는 넓은 타입이지만, "오리"는 정확히 그 문자열만 허용하는 좁은 타입입니다. 이처럼 좁은 타입을 유니온의 판별자(discriminant)로 사용하면, 분기 조건 하나만으로 나머지 속성까지 전부 추론됩니다.
type Animal =
| { name: "오리"; sound: "꽥꽥"; legs: 2; habitat: "연못" }
| { name: "병아리"; sound: "삐약"; legs: 2; habitat: "농장" }
| { name: "강아지"; sound: "멍멍"; legs: 4; habitat: "집" };name이 "오리"로 확정되는 순간, sound는 "꽥꽥", habitat는 "연못"이 됩니다. TypeScript의 타입 내로잉(type narrowing)이 조건 분기를 분석하여 해당 블록에서 가능한 타입을 좁혀주기 때문입니다.
function describe(animal: Animal) {
if (animal.name === "오리") {
// 이 블록 안에서 TypeScript는 animal의 타입을 아래로 좁힙니다:
// { name: "오리"; sound: "꽥꽥"; legs: 2; habitat: "연못" }
console.log(animal.sound); // 타입: "꽥꽥"
console.log(animal.habitat); // 타입: "연못"
}
}if문, switch문, 삼항 연산자 모두 이 내로잉을 트리거합니다. 타입 단언(as)이나 별도의 타입 가드 함수를 작성할 필요가 없다는 점이 실무에서 체감되는 가장 큰 이점입니다.
모든 속성이 판별자가 될 수 있는 것은 아닙니다. 내로잉이 동작하려면 판별자 속성이 다음 세 가지를 만족해야 합니다.
| 조건 | 설명 | 예시 |
|---|---|---|
| 리터럴 타입 | 문자열, 숫자, 불리언 리터럴이어야 함 | "success", 42, true |
| 모든 멤버에 존재 | 유니온의 모든 멤버가 해당 속성을 가져야 함 | 모든 멤버에 status 존재 |
| 멤버 간 고유 | 각 멤버에서 서로 다른 값을 가져야 함 | "loading", "success", "error" |
실무에서 가장 흔한 실수는 판별자를 string처럼 넓은 타입으로 선언하는 것입니다. 이렇게 하면 TypeScript가 멤버를 구분할 수 없어서 내로잉이 동작하지 않습니다.
// ✅ 올바른 판별자
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number };
// ❌ 작동하지 않는 판별자: string은 리터럴이 아님
type BadShape =
| { kind: string; radius: number }
| { kind: string; width: number };판별자로 가장 흔히 쓰이는 속성 이름은 type, kind, status, tag 등이지만, 이름 자체가 중요한 것은 아닙니다. 앞서 본 Animal 예시처럼 name도 판별자가 될 수 있으며, 핵심은 리터럴 타입으로 각 멤버를 고유하게 구분할 수 있느냐에 있습니다.
서버 응답은 성공, 실패, 로딩이 완전히 다른 데이터 구조를 가집니다. 이 차이를 Discriminated Union으로 타입에 그대로 반영하면, API 타입 하나를 바꾸는 것만으로 프론트엔드 전체에서 누락된 에러 핸들링이 전부 컴파일 에러로 드러납니다.
type ApiResponse<T> =
| { status: "success"; data: T; timestamp: number }
| { status: "error"; code: number; message: string }
| { status: "loading" };
function handleResponse(response: ApiResponse<UserProfile>) {
switch (response.status) {
case "success":
// response.data: UserProfile (확정)
// response.timestamp: number (확정)
renderProfile(response.data);
break;
case "error":
// response.code: number (확정)
// response.message: string (확정)
showError(response.code, response.message);
break;
case "loading":
// 이 멤버에는 data도 code도 없음
showSpinner();
break;
}
}들어가며의 ApiResponse와 비교하면 차이가 확연합니다. optional 필드를 하나도 쓰지 않았는데도 모든 상태를 빠짐없이 표현할 수 있고, "success" 분기에서 response.data에 접근할 때 undefined 체크가 필요 없습니다. "error" 분기에서 response.data에 접근하면 컴파일 에러가 발생하므로, 잘못된 접근 자체가 불가능합니다.
하나의 선택에 따라 이후 필드가 달라지는 폼은 실무에서 매우 흔합니다. 결제 수단을 고르면 카드 번호 입력이 나타나고, 계좌 이체를 고르면 은행 선택이 나타나는 식입니다. 이 구조를 optional 필드로 관리하면 결제 수단이 추가될 때마다 타입의 의미가 점점 흐려지지만, Discriminated Union은 수단이 몇 개로 늘어나든 각 조합이 명확하게 유지됩니다.
type PaymentMethod =
| {
type: "card";
cardNumber: string;
expiryDate: string;
cvc: string;
}
| {
type: "bank-transfer";
bankCode: string;
accountNumber: string;
accountHolder: string;
}
| {
type: "phone";
carrier: "SKT" | "KT" | "LGU+";
phoneNumber: string;
};컴포넌트에서 사용하면, switch문 하나로 조건부 렌더링이 깔끔하게 처리됩니다.
function PaymentFields({ method }: { method: PaymentMethod }) {
switch (method.type) {
case "card":
return (
<>
<Input label="카드 번호" value={method.cardNumber} />
<Input label="만료일" value={method.expiryDate} />
<Input label="CVC" value={method.cvc} />
</>
);
case "bank-transfer":
return (
<>
<Select label="은행" value={method.bankCode} />
<Input label="계좌번호" value={method.accountNumber} />
<Input label="예금주" value={method.accountHolder} />
</>
);
case "phone":
return (
<>
<Select label="통신사" value={method.carrier} />
<Input label="전화번호" value={method.phoneNumber} />
</>
);
}
}각 case 블록 안에서 해당 수단에 필요한 필드만 정확히 존재하므로, cardNumber가 undefined일 가능성을 고려하거나 non-null assertion을 쓸 필요가 없습니다.
useReducer의 액션 설계에서 Discriminated Union은 특히 효과적입니다. 액션이 10개, 20개로 늘어날수록 각 액션이 어떤 payload를 요구하는지 타입으로 강제하지 않으면 dispatch 호출의 안전성을 보장할 수 없기 때문입니다.
type TodoAction =
| { type: "ADD"; payload: { title: string; priority: "high" | "medium" | "low" } }
| { type: "TOGGLE"; payload: { id: string } }
| { type: "DELETE"; payload: { id: string } }
| { type: "EDIT"; payload: { id: string; title: string } }
| { type: "REORDER"; payload: { fromIndex: number; toIndex: number } };
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "ADD":
// action.payload: { title: string; priority: "high" | "medium" | "low" }
return {
...state,
items: [...state.items, createTodo(action.payload.title, action.payload.priority)],
};
case "TOGGLE":
// action.payload: { id: string }
return {
...state,
items: state.items.map((item) =>
item.id === action.payload.id ? { ...item, done: !item.done } : item
),
};
case "REORDER":
// action.payload: { fromIndex: number; toIndex: number }
return {
...state,
items: reorder(state.items, action.payload.fromIndex, action.payload.toIndex),
};
// ...나머지 케이스
}
}dispatch({ type: "ADD" })를 호출하면서 payload에 title을 빠뜨리면, 런타임이 아니라 에디터에서 바로 빨간 줄이 뜹니다. 액션이 많아질수록 이 구조의 가치는 올라갑니다.
하나의 컴포넌트가 모드에 따라 완전히 다른 Props 세트를 받아야 할 때도 같은 패턴이 적용됩니다. 핵심은 사용하는 측에서 잘못된 조합을 아예 넘길 수 없게 만든다는 점입니다.
type ModalProps =
| {
variant: "alert";
title: string;
message: string;
onConfirm: () => void;
}
| {
variant: "confirm";
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
| {
variant: "custom";
title: string;
children: React.ReactNode;
};
function Modal(props: ModalProps) {
switch (props.variant) {
case "alert":
return (
<DialogBase title={props.title}>
<p>{props.message}</p>
<Button onClick={props.onConfirm}>확인</Button>
</DialogBase>
);
case "confirm":
return (
<DialogBase title={props.title}>
<p>{props.message}</p>
<Button variant="secondary" onClick={props.onCancel}>취소</Button>
<Button onClick={props.onConfirm}>확인</Button>
</DialogBase>
);
case "custom":
return (
<DialogBase title={props.title}>
{props.children}
</DialogBase>
);
}
}variant="alert"인 Modal에 onCancel을 전달하면 컴파일 에러가 발생합니다. Props의 잘못된 조합이 런타임까지 가지 않고 에디터 단계에서 바로 잡힙니다.
// ❌ 컴파일 에러: "alert" 멤버에 onCancel이 없음
<Modal variant="alert" title="알림" message="완료" onConfirm={fn} onCancel={fn} />
// ✅ 올바른 사용
<Modal variant="confirm" title="확인" message="삭제할까요?" onConfirm={fn} onCancel={fn} />Discriminated Union의 진짜 가치는 유지보수 시점에 드러납니다. 유니온에 새 멤버를 추가했을 때, 그 유니온을 switch로 처리하는 모든 곳에서 누락된 케이스를 컴파일 타임에 잡아내는 구조를 만들 수 있습니다. 이를 가능하게 하는 것이 TypeScript의 never 타입입니다.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default: {
const _exhaustive: never = shape;
return _exhaustive;
}
}
}default 블록에서 shape을 never에 할당하고 있습니다. 모든 케이스를 처리했다면 shape의 타입은 자동으로 never가 되므로 할당이 성공합니다. 그런데 누군가 "pentagon"이라는 새 멤버를 추가하면서 case "pentagon"을 빠뜨리면, shape이 never가 아닌 { kind: "pentagon"; ... }으로 남아 컴파일 에러가 발생합니다. 수정해야 할 지점을 TypeScript가 직접 알려주는 셈입니다.
이 패턴을 유틸리티 함수로 추출하면 프로젝트 전체에서 일관되게 적용할 수 있습니다.
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
assertNever(shape, `지원하지 않는 도형: ${(shape as Shape).kind}`);
}
}유니온 확장에 강한 구조
Exhaustive check가 적용된 코드베이스에서는 유니온에 새 멤버를 추가하는 순간, 해당 유니온을 switch로 처리하는 모든 위치에서 컴파일 에러가 발생합니다. 수정이 필요한 지점을 TypeScript가 직접 찾아주는 구조입니다.
유니온 멤버가 많아지면 전체 유니온을 그대로 쓰기보다 특정 멤버만 뽑아내거나 제외해야 하는 상황이 반드시 생깁니다. TypeScript의 내장 유틸리티 타입 Extract와 Exclude가 이 역할을 합니다.
type HttpEvent =
| { type: "request"; url: string; method: "GET" | "POST" }
| { type: "response"; status: number; body: unknown }
| { type: "error"; code: string; message: string }
| { type: "timeout"; duration: number };
// 특정 멤버 추출
type ErrorEvent = Extract<HttpEvent, { type: "error" }>;
// 결과: { type: "error"; code: string; message: string }
// 특정 멤버 제외
type NonErrorEvent = Exclude<HttpEvent, { type: "error" }>;
// 결과: request | response | timeout 유니온에러 전용 핸들러와 정상 이벤트 전용 핸들러를 분리할 때 특히 효과적입니다. 각 함수가 받는 이벤트 타입이 정확히 제한되므로, 함수 내부에서 불필요한 분기를 작성할 필요가 없습니다.
function handleError(event: Extract<HttpEvent, { type: "error" }>) {
// event.code와 event.message가 확정되어 있음
reportToSentry(event.code, event.message);
}
function logNormalEvent(event: Exclude<HttpEvent, { type: "error" }>) {
// error 이벤트가 아닌 것만 받음
analytics.track(event.type);
}유니온의 판별자 값들만 모아서 별도의 타입으로 만들면, 이벤트 타입별 핸들러 맵 같은 구조를 타입 안전하게 설계할 수 있습니다.
type EventType = HttpEvent["type"];
// 결과: "request" | "response" | "error" | "timeout"
// 이 타입으로 이벤트 타입별 핸들러 맵을 정의할 수 있음
type EventHandlers = {
[K in EventType]: (event: Extract<HttpEvent, { type: K }>) => void;
};
const handlers: EventHandlers = {
request: (e) => console.log(e.url), // e: request 멤버
response: (e) => console.log(e.status), // e: response 멤버
error: (e) => console.log(e.message), // e: error 멤버
timeout: (e) => console.log(e.duration), // e: timeout 멤버
};EventHandlers 타입은 각 키에 대응하는 핸들러가 정확한 이벤트 멤버 타입을 받도록 강제합니다. handlers.request의 콜백 매개변수 e는 자동으로 { type: "request"; url: string; method: "GET" | "POST" }로 추론되며, 새 이벤트 타입을 추가하면 handlers 객체에서 해당 핸들러가 누락되었다는 에러가 발생합니다. 앞서 다룬 exhaustive check와 동일한 효과입니다.
프로젝트 전체에서 "성공이면 데이터, 실패면 에러"라는 동일한 유니온 구조가 반복된다면, 제네릭으로 추상화하면 생산성이 올라갑니다. 가장 대표적인 예가 Result 타입입니다.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseJSON<T>(raw: string): Result<T, SyntaxError> {
try {
return { ok: true, value: JSON.parse(raw) as T };
} catch (e) {
return { ok: false, error: e as SyntaxError };
}
}
const result = parseJSON<UserProfile>('{"name": "홍길동"}');
if (result.ok) {
// result.value: UserProfile
console.log(result.value.name);
} else {
// result.error: SyntaxError
console.error(result.error.message);
}TypeScript에서 catch 블록의 에러는 unknown으로 추론되기 때문에, 에러 타입을 추적하려면 매번 타입 단언이 필요합니다. Result 타입은 성공과 실패 양쪽의 타입을 제네릭으로 명시하므로 이 문제를 깔끔하게 해결합니다.
제네릭을 한 단계 더 활용하면 이벤트 시스템의 구조 자체를 추상화할 수도 있습니다.
type DomainEvent<K extends string, P = void> =
P extends void
? { type: K; timestamp: number }
: { type: K; payload: P; timestamp: number };
type AppEvent =
| DomainEvent<"user:login", { userId: string; method: "email" | "oauth" }>
| DomainEvent<"user:logout">
| DomainEvent<"cart:add", { productId: string; quantity: number }>
| DomainEvent<"cart:clear">;DomainEvent 제네릭이 payload 유무에 따라 타입 구조를 자동으로 결정합니다. "user:logout" 이벤트에는 payload 속성 자체가 존재하지 않고, "cart:add" 이벤트에는 productId와 quantity를 포함한 payload가 반드시 존재합니다. 이벤트를 추가할 때 DomainEvent<"order:complete", { orderId: string }> 한 줄이면 되므로, 유니온 멤버마다 timestamp를 반복 선언할 필요가 없습니다.
같은 요구사항을 optional 필드로 구현한 코드와 Discriminated Union으로 구현한 코드를 나란히 놓으면, 타입 안전성의 차이가 선명하게 드러납니다.
interface Notification {
type: string;
title: string;
message?: string;
actionUrl?: string;
actionLabel?: string;
progress?: number;
total?: number;
imageUrl?: string;
}
function renderNotification(n: Notification) {
// type이 무엇인지와 관계없이 모든 필드에 접근 가능
// 어떤 필드가 실제로 존재하는지 타입이 보장하지 않음
if (n.type === "action" && n.actionUrl) {
// actionLabel도 있어야 하는데, 깜빡하고 체크 안 할 수 있음
}
if (n.type === "progress") {
// progress와 total 중 하나만 있으면? 런타임 에러
const percent = n.progress! / n.total! * 100; // non-null assertion 남발
}
}type Notification =
| { type: "simple"; title: string; message: string }
| { type: "action"; title: string; message: string; actionUrl: string; actionLabel: string }
| { type: "progress"; title: string; progress: number; total: number }
| { type: "image"; title: string; message: string; imageUrl: string };
function renderNotification(n: Notification) {
switch (n.type) {
case "action":
// actionUrl과 actionLabel 모두 확정 — 누락 불가능
return <a href={n.actionUrl}>{n.actionLabel}</a>;
case "progress":
// progress와 total 모두 확정 — non-null assertion 불필요
const percent = n.progress / n.total * 100;
return <ProgressBar value={percent} />;
case "image":
// imageUrl 확정
return <img src={n.imageUrl} alt={n.title} />;
case "simple":
return <p>{n.message}</p>;
}
}optional 필드 방식의 근본적인 문제는 불가능해야 하는 상태를 허용한다는 것입니다. type이 "simple"인데 progress가 존재하거나, type이 "progress"인데 progress가 undefined인 객체가 타입 레벨에서 통과됩니다.
리팩토링 시점 판단 기준
optional 필드가 3개 이상이고 그 존재 여부가 서로 연관되어 있다면, Discriminated Union 도입을 고려할 시점입니다. 판단 기준은 단순합니다: 어떤 필드가 있으면 다른 필드도 반드시 있어야 한다면, 그 필드들은 같은 유니온 멤버에 속해야 합니다.
판별자가 반드시 문자열일 필요는 없으며, boolean 리터럴도 판별자로 동작합니다. 상태가 두 가지뿐인 경우 문자열보다 간결한 선택지가 됩니다.
type AuthState =
| { isLoggedIn: true; user: UserProfile; token: string }
| { isLoggedIn: false; loginUrl: string };
function Header({ auth }: { auth: AuthState }) {
if (auth.isLoggedIn) {
// auth.user: UserProfile, auth.token: string
return <Avatar src={auth.user.avatar} />;
}
// auth.loginUrl: string
return <a href={auth.loginUrl}>로그인</a>;
}다만 상태가 세 가지 이상으로 확장될 가능성이 있다면 처음부터 문자열 리터럴을 쓰는 편이 낫습니다. boolean은 true/false 두 값뿐이므로 확장성에 한계가 있기 때문입니다.
핵심 정리
-
리터럴 타입 판별자를 통해 하나의 속성이 결정되면 나머지 속성의 타입이 자동으로 확정됩니다. optional 필드 없이도 조건부 구조를 안전하게 표현할 수 있습니다.
-
Exhaustive check(
never할당)를 적용하면 유니온에 새 멤버를 추가할 때 처리가 누락된 모든switch문에서 컴파일 에러가 발생합니다. -
Extract/Exclude로 유니온을 부분 추출·제거할 수 있고, 인덱스 접근(
Union["key"])으로 판별자 값만 모아 활용할 수 있습니다. -
불가능한 상태를 타입으로 제거하는 것이 핵심 철학입니다. optional 필드 간의 상관관계가 코드 주석이나 런타임 검증에 의존하고 있다면, Discriminated Union으로 그 관계를 타입 시스템에 옮겨야 합니다.
관련 문서
글쓴이 mirunamu00



