반응형

TypeScript never와 exhaustive checking을 추상적으로 표현한 대표 이미지

프론트엔드 코드를 작성하다 보면 상태에 따라 다른 화면이나 문구를 보여줘야 하는 경우가 많습니다. API 요청 상태만 봐도 idle, loading, success, error처럼 여러 갈래로 나뉩니다.

처음에는 네 가지 상태만 있으면 충분해 보이지만, 시간이 지나면서 empty, retrying, unauthorized 같은 상태가 추가될 수 있습니다. 문제는 새 상태가 추가됐는데 기존 switch 문이 그 상태를 빠뜨려도 코드가 조용히 동작할 수 있다는 점입니다.

이때 TypeScript의 never 타입을 활용한 exhaustive checking을 적용하면 분기 누락을 컴파일 단계에서 잡을 수 있습니다.

fallback 문자열은 버그를 숨길 수 있다

분기 처리를 할 때 흔히 다음처럼 default에서 fallback 값을 반환합니다.

default:
  return "알 수 없음";

이 방식은 런타임에서 화면이 깨지는 것을 막아줍니다. 하지만 실무에서는 오히려 문제를 늦게 발견하게 만들 수 있습니다.

예를 들어 요청 상태에 retrying이 새로 추가됐는데, 화면 렌더링 함수에서 이 상태를 처리하지 않았다고 가정해보겠습니다. default가 단순히 "알 수 없음"을 반환하면 사용자는 애매한 문구를 보게 되고, 개발자는 이것이 의도된 기본 처리인지 실제 누락 버그인지 바로 알기 어렵습니다.

exhaustive checking이란?

exhaustive checking은 모든 경우를 빠짐없이 처리했는지 확인하는 방식입니다. TypeScript에서는 판별 유니온과 switch 문을 함께 사용할 때 특히 유용합니다.

핵심은 default 구문에서 “여기까지 오면 안 된다”는 의도를 never 타입으로 표현하는 것입니다.

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string }
  | { status: "empty" };

function renderMessage(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "대기 중";
    case "loading":
      return "불러오는 중";
    case "success":
      return `${state.data.length}개를 불러왔습니다`;
    case "error":
      return `에러: ${state.message}`;
    case "empty":
      return "데이터가 없습니다";
    default: {
      const exhaustiveCheck: never = state;
      return exhaustiveCheck;
    }
  }
}

여기서 중요한 부분은 const exhaustiveCheck: never = state입니다.

왜 state가 never가 될까?

never는 값이 존재할 수 없는 타입입니다. 어떤 값도 들어올 수 없다는 뜻이기 때문에, 이 타입은 “논리적으로 도달할 수 없는 지점”을 표현할 때 유용합니다.

위 예제에서 모든 case가 처리됐다면 default에 도달할 수 있는 상태는 없습니다. 따라서 TypeScript 입장에서 default 안의 state는 남은 타입이 없고, 결과적으로 never가 됩니다.

반대로 RequestState에 새 상태를 추가하고 case를 빠뜨리면 이야기가 달라집니다.

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string }
  | { status: "empty" }
  | { status: "retrying" };

이 상태에서 switch 문에 retrying 처리가 없다면 default 안의 state는 더 이상 never가 아닙니다. TypeScript는 retrying 케이스가 남아 있다고 판단하고 컴파일 에러를 보여줍니다.

UI 상태가 switch 분기를 거쳐 exhaustive checking으로 검증되는 흐름 이미지

실무에서는 어디에 쓸까?

이 패턴은 케이스가 명확하고, 시간이 지나며 상태가 늘어날 수 있는 코드에 잘 맞습니다.

  • 로딩, 성공, 실패, 빈 결과 같은 UI 상태
  • API 응답 상태
  • 사용자 권한 상태
  • 결제 또는 주문 진행 상태
  • 모달, 토스트, 알림 타입
  • 폼 검증 결과

특히 React 같은 프론트엔드 프로젝트에서는 UI 상태를 문자열 하나로 느슨하게 관리하는 경우가 많습니다. 이때 판별 유니온으로 상태를 정의하고 화면 렌더링 함수에서 exhaustive checking을 적용하면, 상태 추가에 따른 누락을 줄일 수 있습니다.

실습으로 확인해보기

다음 순서로 직접 비교해보면 차이가 분명합니다.

  1. RequestState에 { status: "retrying" }을 추가합니다.
  2. switch 문에는 retrying 케이스를 추가하지 않습니다.
  3. const exhaustiveCheck: never = state에서 TypeScript 에러가 나는지 확인합니다.
  4. default를 "알 수 없음" 반환 방식으로 바꿔봅니다.
  5. 같은 누락 상황에서 에러가 사라지는지 비교합니다.

이 실습을 해보면 exhaustive checking이 단순한 타입 기법이 아니라, 유지보수성을 높이는 안전장치라는 점을 바로 느낄 수 있습니다.

마무리

TypeScript의 never는 처음 보면 낯선 타입이지만, 판별 유니온과 함께 사용하면 매우 실용적인 도구가 됩니다.

switch 문에서 모든 상태를 빠짐없이 처리해야 한다면 const exhaustiveCheck: never = state 패턴을 적용해보는 것이 좋습니다. 상태가 늘어나는 순간 컴파일러가 처리 누락을 알려주기 때문에, 리뷰나 QA에서 뒤늦게 발견될 버그를 앞단에서 줄일 수 있습니다.

작은 타입 패턴 하나지만, 나중에 생길 실수를 빨리 드러내는 꽤 든든한 습관입니다.

반응형