뒤로가기
TypeScript 타입을 잘 짜면 런타임 에러가 사라진다

April 19, 2025

typescriptfrontend

TypeScript를 쓰면서 any를 남발하거나, 유니온 타입 대신 옵셔널 프로퍼티를 쓰거나, 타입을 대충 짜는 경우가 많다. "동작하니까 괜찮지"라고 넘어간다. 나도 그랬다.

근데 프로덕션에서 터진 버그 4개를 분석해보니, 전부 타입을 더 정교하게 짰으면 컴파일 타임에 잡을 수 있었던 것들이었다. 런타임에서 발견된 버그를 컴파일 타임으로 끌어올린다는 게 TypeScript의 진짜 가치인데, 타입을 대충 짜면 그 가치를 날리는 거다.

Matt Pocock이라는 TypeScript 교육자가 "프론트엔드의 복잡성 대부분은 앱이 처할 수 있는 다양한 상태를 처리하는 데서 온다"고 한 적이 있다. 정확히 맞는 말이다. 그 상태들을 타입으로 표현할 수 있느냐 없느냐가 런타임 에러의 수를 결정한다.

버그 1: 불가능한 상태가 가능했다#

API 호출 결과를 보여주는 컴포넌트가 있었다. 로딩, 성공, 에러 세 가지 상태를 처리해야 한다.

처음에 이렇게 타입을 짰다.

typescript
interface ApiState {
  status: 'loading' | 'success' | 'error';
  data?: UserProfile;
  error?: string;
}

겉보기엔 괜찮다. 로딩 중이면 dataerror가 없고, 성공이면 data가 있고, 에러면 error가 있다. 근데 이 타입은 "성공인데 data가 없는" 상태를 허용한다. "로딩 중인데 error가 있는" 상태도 가능하다.

typescript
// TypeScript가 통과시키지만 논리적으로 불가능한 상태
const broken: ApiState = {
  status: 'success',
  // data가 없다! undefined!
};

이게 실제로 버그를 만들었다. 데이터를 렌더링하는 코드에서 state.data.name에 접근했는데, statussuccess여도 dataundefined일 수 있었다. 옵셔널 체이닝으로 state.data?.name을 쓰면 에러는 안 나지만, 이름이 표시되지 않는 UI 버그가 생긴다.

Matt Pocock이 설명한 Discriminated Union 패턴이 정확히 이 문제를 해결한다. 그가 이 패턴을 "옵셔널 프로퍼티의 가방(bag of optionals)"이라고 부른 안티패턴의 해법으로 제시했는데, 핵심은 각 상태를 별도의 타입으로 분리하는 거다.

typescript
type ApiState =
  | { status: 'loading' }
  | { status: 'success'; data: UserProfile }
  | { status: 'error'; error: string };

이제 statussuccessdata는 반드시 존재한다. TypeScript가 보장한다.

typescript
// 컴파일 에러! data가 없으면 success가 될 수 없다.
const broken: ApiState = {
  status: 'success',
};

// 타입 좁히기(narrowing)로 안전하게 접근
function renderState(state: ApiState) {
  if (state.status === 'success') {
    // 이 블록 안에서 state.data는 반드시 UserProfile
    return <div>{state.data.name}</div>;
  }
  if (state.status === 'error') {
    // 이 블록 안에서 state.error는 반드시 string
    return <div>{state.error}</div>;
  }
  return <Spinner />;
}

status로 분기하면 TypeScript가 자동으로 해당 브랜치에서 어떤 프로퍼티가 존재하는지 알아낸다. 구조 분해 할당을 바로 하면 에러가 나는 것도 의도된 동작이다. 먼저 좁힌 후에 접근하라는 강제다.

이 패턴을 적용한 후로 "data가 undefined일 때" 같은 방어 코드가 대폭 줄었다. 타입 자체가 불가능한 상태를 차단하니까, 런타임에서 그 상태가 발생할 일이 없다.

버그 2: Record를 안 써서 새 상태를 빠뜨렸다#

주문 상태별로 UI 라벨과 색상을 매핑하는 객체가 있었다.

typescript
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';

const STATUS_CONFIG: { [key: string]: { label: string; color: string } } = {
  pending: { label: '주문 접수', color: 'yellow' },
  confirmed: { label: '확인됨', color: 'blue' },
  shipped: { label: '배송 중', color: 'indigo' },
  delivered: { label: '배송 완료', color: 'green' },
  cancelled: { label: '취소됨', color: 'red' },
};

한동안 잘 동작했다. 그러다 기획에서 "환불" 상태가 추가됐다.

typescript
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';

OrderStatus'refunded'를 추가했다. 근데 STATUS_CONFIG에는 refunded를 넣는 걸 깜빡했다. TypeScript는 아무 경고도 하지 않았다. 왜? 인덱스 시그니처가 [key: string]이니까. 어떤 문자열이든 키가 될 수 있으므로 refunded가 없어도 타입 에러가 아니다.

프로덕션에서 "환불" 상태의 주문이 생겼을 때, STATUS_CONFIG['refunded']undefined를 반환했고, undefined.label에서 런타임 에러가 터졌다.

해결은 Record를 쓰는 거다.

typescript
const STATUS_CONFIG: Record<OrderStatus, { label: string; color: string }> = {
  pending: { label: '주문 접수', color: 'yellow' },
  confirmed: { label: '확인됨', color: 'blue' },
  shipped: { label: '배송 중', color: 'indigo' },
  delivered: { label: '배송 완료', color: 'green' },
  cancelled: { label: '취소됨', color: 'red' },
  // refunded가 없으면 컴파일 에러!
};

Record<OrderStatus, ...>OrderStatus의 모든 멤버가 키로 존재해야 한다는 걸 강제한다. refunded를 빠뜨리면 즉시 빨간 줄이 뜬다.

text
Property 'refunded' is missing in type '...' but required in type 'Record<OrderStatus, { label: string; color: string; }>'

이게 Record의 진짜 가치다. 유니온 타입에 새 멤버를 추가했을 때, 그 값을 사용하는 모든 곳에서 "여기도 처리해야 해"라고 알려준다. switch 문에서 default 분기에 never 타입을 넣는 것과 같은 원리인데, Record가 더 간결하다.

이 버그 이후로 열거형 값의 매핑에는 무조건 Record를 쓴다. { [key: string]: ... } 인덱스 시그니처는 사실상 타입 체크를 포기하는 것과 같다.

버그 3: API 응답 타입을 너무 느슨하게 잡았다#

백엔드에서 내려주는 API 응답 타입을 이렇게 정의했다.

typescript
interface ApiResponse {
  code: number;
  message: string;
  data: any;
}

data: any. 모든 API 응답에 이 하나의 타입을 쓰고 있었다. "타입 지정하기 귀찮으니까 any로 하고 나중에 고치자." 그 "나중"은 오지 않았다.

문제가 터진 건 프로필 수정 API였다. 응답의 data에 사용자 정보가 오는데, 백엔드에서 필드명을 변경했다. userNamedisplayName으로 바뀐 거다. 백엔드 팀이 슬랙에 공지를 했는데, 프론트 쪽에서 놓쳤다.

data: any이니까 TypeScript는 response.data.userName에 접근해도 아무 경고를 하지 않았다. 빌드도 됐고, 테스트도 통과했다(테스트에서도 mock 데이터가 옛날 필드명을 쓰고 있었으니까). 프로덕션에 배포된 후에 "프로필 이름이 안 나온다"는 CS가 들어와서야 발견했다.

해결: 제네릭 응답 타입.

typescript
interface ApiResponse<T> {
  code: number;
  message: string;
  data: T;
}

interface UserProfile {
  id: string;
  displayName: string;
  email: string;
  avatar: string;
}

async function fetchProfile(): Promise<ApiResponse<UserProfile>> {
  const response = await fetch('/api/profile');
  return response.json();
}

// 이제 response.data.userName 접근하면 컴파일 에러
const profile = await fetchProfile();
console.log(profile.data.displayName); // OK
console.log(profile.data.userName);    // Error!

더 나아가서, 함수의 반환 타입을 명시적으로 선언하면 도움이 된다. Matt Pocock이 "함수에 반환 타입을 명시하라"고 강조하는 이유가 이거다. TypeScript의 타입 추론은 강력하지만, 추론에만 의존하면 any가 전파되는 걸 놓칠 수 있다. response.json()any를 반환하는데, 반환 타입을 명시하지 않으면 호출하는 쪽에서도 any가 된다.

API 응답마다 타입을 정의하는 게 귀찮은 건 사실이다. 근데 그 귀찮음이 "프로덕션에서 필드명이 바뀌었을 때 어디가 깨지는지 모르는" 상황보다는 낫다.

버그 4: 컴포넌트 props의 조합 폭발#

모달 컴포넌트를 만들었는데, 변형(variant)에 따라 필요한 props가 달랐다.

typescript
interface ModalProps {
  title: string;
  variant: 'confirm' | 'alert' | 'form';
  onConfirm?: () => void;
  onCancel?: () => void;
  children?: React.ReactNode;
  submitLabel?: string;
}

variantconfirm이면 onConfirmonCancel이 필요하고, alert이면 onConfirm만 필요하고, form이면 childrensubmitLabel이 필요하다. 근데 전부 옵셔널이니까 TypeScript가 강제하지 않는다.

실제로 터진 케이스: 누군가가 variant="confirm"인데 onConfirm을 빼먹었다. 확인 버튼을 누르면 onConfirmundefined이므로 아무 일도 안 일어났다. 사용자는 "버튼이 안 먹어요"라고 CS를 보냈다.

이것도 Discriminated Union으로 해결된다.

typescript
type ModalProps =
  | {
      variant: 'confirm';
      title: string;
      onConfirm: () => void;
      onCancel: () => void;
    }
  | {
      variant: 'alert';
      title: string;
      onConfirm: () => void;
    }
  | {
      variant: 'form';
      title: string;
      children: React.ReactNode;
      submitLabel: string;
      onSubmit: () => void;
    };

이제 variant="confirm"을 넣으면 onConfirmonCancel을 반드시 넘겨야 한다. 안 넘기면 컴파일 에러.

tsx
// 컴파일 에러! onCancel이 없다.
<Modal variant="confirm" title="삭제하시겠습니까?" onConfirm={handleDelete} />

// OK
<Modal
  variant="confirm"
  title="삭제하시겠습니까?"
  onConfirm={handleDelete}
  onCancel={handleCancel}
/>

이 패턴의 부가 효과가 있다. 컴포넌트 내부에서도 variant로 분기하면 해당 브랜치에서 어떤 props가 존재하는지 TypeScript가 알려준다.

typescript
function Modal(props: ModalProps) {
  if (props.variant === 'form') {
    // 이 블록에서 props.children, props.submitLabel, props.onSubmit 접근 가능
    return (
      <dialog>
        <h2>{props.title}</h2>
        {props.children}
        <button onClick={props.onSubmit}>{props.submitLabel}</button>
      </dialog>
    );
  }
  // ...
}

네 가지 버그의 공통 패턴#

정리하면 이렇다.

버그원인해결 패턴
불가능한 상태 허용옵셔널 프로퍼티 남용Discriminated Union
새 열거값 빠뜨림느슨한 인덱스 시그니처Record
필드명 변경 감지 실패any 타입제네릭 + 명시적 반환 타입
props 조합 실수전부 옵셔널Discriminated Union

네 버그 중 세 개가 "옵셔널을 너무 많이 쓴 것"에서 왔다. ?를 붙이는 건 쉽다. "이 필드가 없을 수도 있어"라고 선언하는 거니까. 근데 그 편의성이 타입의 표현력을 죽인다. "이 상태일 때 이 필드는 반드시 존재한다"는 비즈니스 규칙을 타입으로 표현할 수 없게 되니까.

TypeScript의 진짜 힘은 string이나 number 같은 기본 타입이 아니라, 비즈니스 로직의 제약 조건을 타입 시스템으로 표현하는 데 있다. "성공 상태면 데이터가 있다", "confirm 모달이면 onConfirm이 필수다", "모든 주문 상태에 라벨이 있어야 한다". 이런 규칙을 타입으로 옮기면, 규칙을 어기는 코드는 컴파일 자체가 안 된다.

타입을 "짜증나는 부가 작업"이 아니라 "런타임 에러를 컴파일 타임에 잡는 도구"로 보면, 타입에 시간을 쓰는 게 아까워지지 않는다. 오히려 그 시간이 프로덕션 장애 대응 시간을 줄여준다. 내 경험으로는, 타입을 정교하게 짜는 데 30분을 더 쓰면 나중에 3시간짜리 디버깅을 하나 줄일 수 있다.