TypeScript의 판별된 union을 이용한 state와 API response 처리

1. 개요

많은 UI 버그는 일어날 수 없어야 하는 state에서 발생합니다. 예를 들어 isLoading이 true인데 error도 값을 가지고 있거나, 요청이 성공했다고 표시되었는데 data가 undefined인 경우입니다.

이 글에서는 TypeScript의 discriminated union을 사용하여 데이터 로딩 state와 API response를 모델링하는 방법을 안내합니다. 이를 통해 다음을 배우게 됩니다:

  • 판별 필드를 가진 union 정의하기,
  • 컴파일러가 자동으로 타입 좁히기(narrowing)를 수행하도록 하기,
  • exhaustiveness check로 모든 분기를 반드시 처리하도록 강제하기,
  • Zod를 사용한 런타임 response 검증,
  • state 변환에 대한 테스트 작성.

2. “선택적 필드 모음”의 문제점

일반적인 방법은 모든 것을 선택적 필드가 있는 하나의 interface로 묶는 것입니다:

interface OrderState {
  isLoading: boolean;
  error?: string;
  order?: { id: string; total: number };
}

const state: OrderState = {
  isLoading: true,
  error: "Timeout",
  order: { id: "A1", total: 120 },
};

위의 코드는 정상적으로 컴파일됩니다. 하지만 이는 무의미한 state를 설명합니다: 동시에 로딩 중이면서, 에러가 있으면서, 데이터도 있다는 뜻입니다.

세 개의 필드가 있으면 2 × 2 × 2 = 8가지 조합이 가능합니다. 실제로는 약 4가지 조합만 유효합니다. State를 읽을 때도 state.order?.total 또는 state.order!을 곳곳에 써야 합니다. 에러가 발생하면 컴파일러가 도와줄 수 없습니다.

3. Discriminated union이란

Discriminated union은 여러 object 타입의 union입니다. 각 타입은 literal type을 가진 공통 필드를 하나씩 갖습니다. 이 필드를 discriminant 또는 “tag”라고 부릅니다:

type Order = { id: string; total: number };

type OrderState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; order: Order }
  | { status: "error"; message: string };

여기서 status가 discriminant입니다. 각 분기는 자신에게만 의미가 있는 필드를 선언합니다. success 분기는 order를 가지고, error 분기는 message를 가집니다. “로딩 중이면서 에러도 있는” 상태는 더 이상 표현할 수 없습니다:

const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.

3.1. discriminant에 따른 타입 좁히기

discriminant의 값을 확인하면 TypeScript이 해당 분기에서 자동으로 타입을 좁혀줍니다:

function describe(state: OrderState): string {
  if (state.status === "success") {
    return `주문 ${state.order.id}: ${state.order.total}원`;
  }
  if (state.status === "error") {
    return `에러: ${state.message}`;
  }
  return state.status === "loading" ? "로딩 중..." : "시작하지 않음";
}

console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
주문 A1: 120원
에러: Timeout

if (state.status === "success") 분기 안에서 state의 타입은 { status: "success"; order: Order }입니다. 따라서 state.order.id에 바로 접근할 수 있고, ?.나 !를 쓸 필요가 없습니다. 이 분기에서 state.message에 접근하려 하면 컴파일러가 에러를 냅니다.

4. Exhaustiveness check: 상태를 빠트리지 않기

Union의 진정한 가치는 새 분기를 추가했을 때 컴파일러가 아직 처리되지 않은 곳을 모두 지적할 수 있다는 것입니다. 흔한 방법은 switch와 never 타입을 함께 사용하는 것입니다:

function assertNever(value: never): never {
  throw new Error(`처리되지 않은 상태: ${JSON.stringify(value)}`);
}

function render(state: OrderState): string {
  switch (state.status) {
    case "idle":
      return "<button>주문 로드</button>";
    case "loading":
      return "<spinner />";
    case "success":
      return `<p>총액: ${state.order.total}원</p>`;
    case "error":
      return `<p class="error">${state.message}</p>`;
    default:
      return assertNever(state);
  }
}

네 개의 case를 모두 처리한 후 default 분기에서 state의 타입은 never입니다. 따라서 assertNever(state) 호출은 유효합니다.

나중에 OrderState에 { status: "cancelled" } 분기를 추가했지만 render를 업데이트하지 않았다고 가정하면:

error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.

빌드 시점에 에러가 나타나므로 사용자가 빈 화면을 보게 되는 일을 막을 수 있습니다. 런타임에 신뢰할 수 없는 API 같은 곳에서 예상 못 한 데이터가 들어오면, assertNever이 명확한 에러를 던져줍니다.

주의: "strict": true를 설정하고 명시적 반환 타입(: string)을 선언했다면 하나의 case를 빠뜨려도 “Function lacks ending return statement” 에러가 발생합니다. 하지만 assertNever을 사용하는 것이 좋습니다. 더 명확한 에러 메시지를 제공하고 void를 반환하는 함수에서도 동작합니다.

5. API response 모델링

API는 보통 두 가지 형태로 응답합니다: 성공 또는 실패. 여러 곳에서 재사용할 수 있는 Result 타입을 정의할 수 있습니다:

type Result<T, E = ApiError> =
  | { ok: true; data: T }
  | { ok: false; error: E };

type ApiError =
  | { kind: "network"; cause: string }
  | { kind: "http"; status: number }
  | { kind: "invalid"; issues: string[] };

discriminant가 반드시 문자열일 필요는 없습니다. ok: true와 ok: false는 boolean의 서로 다른 literal type이므로 타입 좁히기가 여전히 작동합니다. ApiError도 내부의 discriminated union으로, kind를 통해 구분됩니다.

5.1. Zod를 사용한 런타임 response 검증

TypeScript 타입은 컴파일 후 사라집니다. (await res.json()) as Order로 쓰면 컴파일러에 “약속”하는 것일 뿐, 실제로는 아무것도 검증하지 않습니다. 서버에서 온 데이터가 올바른 형태인지 확인하려면 Zod로 검증하세요:

npm install zod

서버가 type이라는 discriminant를 가진 payload를 반환한다고 가정하면:

import { z } from "zod";

const OrderSchema = z.object({ id: z.string(), total: z.number() });

const OrderResponseSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("found"), order: OrderSchema }),
  z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);

type OrderResponse = z.infer<typeof OrderResponseSchema>;

z.discriminatedUnion("type", [...]) 는 type 필드를 기반으로 한 schema를 생성합니다. Zod는 먼저 type 값을 읽고 그에 맞는 분기로 검증하므로 z.union보다 더 정확한 에러 메시지를 제공합니다. z.infer는 해당하는 TypeScript 타입을 생성하므로 타입을 두 번 정의할 필요가 없습니다.

다음은 API를 호출하는 함수이며, 모든 에러를 Result로 감싸줍니다:

async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
  let res: Response;
  try {
    res = await fetch(`/api/orders/${id}`);
  } catch (e) {
    return { ok: false, error: { kind: "network", cause: String(e) } };
  }

  if (!res.ok) {
    return { ok: false, error: { kind: "http", status: res.status } };
  }

  const parsed = OrderResponseSchema.safeParse(await res.json());
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
    return { ok: false, error: { kind: "invalid", issues } };
  }
  return { ok: true, data: parsed.data };
}

이 함수는 절대 에러를 던지지 않습니다. 모든 상황이 반환 타입으로 표현됩니다. safeParse 자체도 discriminant가 success인 discriminated union을 반환합니다. 이것이 바로 당신이 배우는 패턴입니다.

함수를 호출하는 쪽에서 결과를 처리합니다:

async function showOrder(id: string): Promise<string> {
  const result = await fetchOrder(id);
  if (!result.ok) {
    switch (result.error.kind) {
      case "network": return "연결 끊김, 나중에 다시 시도하세요.";
      case "http":    return `서버가 ${result.error.status}를 반환했습니다.`;
      case "invalid": return "데이터가 유효하지 않습니다.";
      default:        return assertNever(result.error);
    }
  }
  return result.data.type === "found"
    ? `총액: ${result.data.order.total}원`
    : `주문 ${result.data.orderId}를 찾을 수 없습니다`;
}

if (!result.ok) { ... } 분기 다음에서 TypeScript는 result가 확실히 ok: true 분기라는 것을 알고 있습니다. 따라서 result.data에 바로 접근할 수 있습니다.

6. Reducer를 통한 state 변환

Discriminated union은 reducer와 매우 잘 맞습니다. React의 useReducer든 직접 작성한 store든 state와 action 모두 union입니다:

type OrderAction =
  | { type: "FETCH" }
  | { type: "RESOLVE"; order: Order }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  switch (action.type) {
    case "FETCH":
      return state.status === "loading" ? state : { status: "loading" };
    case "RESOLVE":
      return state.status === "loading"
        ? { status: "success", order: action.order }
        : state;
    case "REJECT":
      return state.status === "loading"
        ? { status: "error", message: action.message }
        : state;
    case "RESET":
      return { status: "idle" };
    default:
      return assertNever(action);
  }
}

이 reducer는 state 변환 규칙까지 인코딩합니다. RESOLVE는 loading 상태일 때만 효과가 있습니다. 덕분에 사용자가 RESET한 후에 늦게 도착한 response는 무시됩니다. 추가 flag가 필요 없습니다.

7. 핵심 동작을 테스트하기

이 예제는 Vitest를 사용합니다. 이 라이브러리의 expect 문법은 AssertJ나 Jest와 유사합니다:

npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";

describe("orderReducer", () => {
  const order = { id: "A1", total: 120 };

  it("RESOLVE일 때 loading에서 success로 변환", () => {
    const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "success", order });
  });

  it("idle 상태에서 늦은 RESOLVE 무시", () => {
    const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "idle" });
  });

  it("render가 에러 메시지 표시", () => {
    expect(render({ status: "error", message: "Timeout" }))
      .toBe('<p class="error">Timeout</p>');
  });
});
npx vitest run
 ✓ order.test.ts (3 tests)
   ✓ orderReducer > RESOLVE일 때 loading에서 success로 변환
   ✓ orderReducer > idle 상태에서 늦은 RESOLVE 무시
   ✓ orderReducer > render가 에러 메시지 표시

 Test Files  1 passed (1)
      Tests  3 passed (3)

두 번째 테스트가 가장 중요합니다. 이 테스트는 race condition에 대한 방어 동작을 고정합니다. 이는 “선택적 필드 모음” 모델에서 매우 흔히 발생하는 버그입니다.

8. 흔한 실수와 사용하면 안 되는 경우

discriminant가 string으로 “확대”되기. 중간 변수에서 object를 만들면 TypeScript가 literal 대신 string 타입을 추론할 수 있습니다:

const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.

세 가지 방법으로 고칠 수 있습니다: 처음부터 타입을 선언하거나 (const s: OrderState = ...), as const를 쓰거나, satisfies OrderState를 사용하세요.

조기 구조분해가 연결을 끊기. let { status, ...rest } = state라고 쓰면 TypeScript는 status를 확인할 때 rest가 어느 분기에 속하는지 알 수 없습니다. 구조분해를 통한 타입 좁히기는 변수가 const로 선언되고 재할당되지 않을 때만 작동합니다.

discriminant 대신 in 연산자 사용. "order" in state 확인도 타입 좁히기를 합니다. 하지만 두 분기가 같은 이름의 필드를 가지면 깨집니다. 항상 discriminant를 통해 확인하세요.

discriminant가 선택적이거나 변경 가능. status?: ...로 선언하지 마세요. 또한 기존 object에서 state.status = "error"를 할당하지도 마세요. 대신 각 state마다 새 object를 생성하세요. 필드를 readonly로 표시하면 컴파일러가 이 규칙을 지키도록 강제할 수 있습니다.

검증 대신 타입 강제. 네트워크에서 온 데이터에 as OrderResponse를 쓰면 위의 모든 이점을 무효화합니다. 시스템의 경계에서는 항상 런타임에 데이터를 검증하세요.

주의: 필드들이 실제로 독립적일 때는 discriminated union을 사용하지 마세요. 예를 들어 form의 email?, phone?, note?는 어떤 조합으로든 채워질 수 있습니다. 이 경우 선택적 필드가 올바른 모델입니다. 이들을 union으로 강제하면 분기 수만 늘어나고 추가 안전성은 없습니다.

9. 결론

Discriminated union은 유효하지 않은 state를 컴파일 에러로 만들어줍니다. 다음을 배웠습니다:

  • literal discriminant를 가진 union 정의,
  • TypeScript가 discriminant에 따라 자동 타입 좁히기,
  • assertNever로 모든 분기 처리 강제,
  • Result와 z.discriminatedUnion으로 API response 모델링 및 데이터 검증,
  • reducer와 테스트에 같은 패턴 적용.

핵심 규칙은: 필드들이 항상 함께 의미가 있다면 union의 같은 분기에 넣으세요. 다음번에 isLoading을 error?와 data? 옆에 쓰려고 하면, 대신 status 필드로 바꾸는 것을 고려해보세요.

typescriptdiscriminated uniontype safetystate managementapi