TypeScript를 쓰면서 any를 남발하거나, 유니온 타입 대신 옵셔널 프로퍼티를 쓰거나, 타입을 대충 짜는 경우가 많다. "동작하니까 괜찮지"라고 넘어간다. 나도 그랬다.
근데 프로덕션에서 터진 버그 4개를 분석해보니, 전부 타입을 더 정교하게 짰으면 컴파일 타임에 잡을 수 있었던 것들이었다. 런타임에서 발견된 버그를 컴파일 타임으로 끌어올린다는 게 TypeScript의 진짜 가치인데, 타입을 대충 짜면 그 가치를 날리는 거다.
Matt Pocock이라는 TypeScript 교육자가 "프론트엔드의 복잡성 대부분은 앱이 처할 수 있는 다양한 상태를 처리하는 데서 온다"고 한 적이 있다. 정확히 맞는 말이다. 그 상태들을 타입으로 표현할 수 있느냐 없느냐가 런타임 에러의 수를 결정한다.
버그 1: 불가능한 상태가 가능했다
API 호출 결과를 보여주는 컴포넌트가 있었다. 로딩, 성공, 에러 세 가지 상태를 처리해야 한다.
처음에 이렇게 타입을 짰다.
interface ApiState {
status: 'loading' | 'success' | 'error';
data?: UserProfile;
error?: string;
}겉보기엔 괜찮다. 로딩 중이면 data와 error가 없고, 성공이면 data가 있고, 에러면 error가 있다. 근데 이 타입은 "성공인데 data가 없는" 상태를 허용한다. "로딩 중인데 error가 있는" 상태도 가능하다.
// TypeScript가 통과시키지만 논리적으로 불가능한 상태
const broken: ApiState = {
status: 'success',
// data가 없다! undefined!
};이게 실제로 버그를 만들었다. 데이터를 렌더링하는 코드에서 state.data.name에 접근했는데, status가 success여도 data가 undefined일 수 있었다. 옵셔널 체이닝으로 state.data?.name을 쓰면 에러는 안 나지만, 이름이 표시되지 않는 UI 버그가 생긴다.
Matt Pocock이 설명한 Discriminated Union 패턴이 정확히 이 문제를 해결한다. 그가 이 패턴을 "옵셔널 프로퍼티의 가방(bag of optionals)"이라고 부른 안티패턴의 해법으로 제시했는데, 핵심은 각 상태를 별도의 타입으로 분리하는 거다.
type ApiState =
| { status: 'loading' }
| { status: 'success'; data: UserProfile }
| { status: 'error'; error: string };이제 status가 success면 data는 반드시 존재한다. 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 라벨과 색상을 매핑하는 객체가 있었다.
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' },
};한동안 잘 동작했다. 그러다 기획에서 "환불" 상태가 추가됐다.
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';OrderStatus에 'refunded'를 추가했다. 근데 STATUS_CONFIG에는 refunded를 넣는 걸 깜빡했다. TypeScript는 아무 경고도 하지 않았다. 왜? 인덱스 시그니처가 [key: string]이니까. 어떤 문자열이든 키가 될 수 있으므로 refunded가 없어도 타입 에러가 아니다.
프로덕션에서 "환불" 상태의 주문이 생겼을 때, STATUS_CONFIG['refunded']가 undefined를 반환했고, undefined.label에서 런타임 에러가 터졌다.
해결은 Record를 쓰는 거다.
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를 빠뜨리면 즉시 빨간 줄이 뜬다.
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 응답 타입을 이렇게 정의했다.
interface ApiResponse {
code: number;
message: string;
data: any;
}data: any. 모든 API 응답에 이 하나의 타입을 쓰고 있었다. "타입 지정하기 귀찮으니까 any로 하고 나중에 고치자." 그 "나중"은 오지 않았다.
문제가 터진 건 프로필 수정 API였다. 응답의 data에 사용자 정보가 오는데, 백엔드에서 필드명을 변경했다. userName이 displayName으로 바뀐 거다. 백엔드 팀이 슬랙에 공지를 했는데, 프론트 쪽에서 놓쳤다.
data: any이니까 TypeScript는 response.data.userName에 접근해도 아무 경고를 하지 않았다. 빌드도 됐고, 테스트도 통과했다(테스트에서도 mock 데이터가 옛날 필드명을 쓰고 있었으니까). 프로덕션에 배포된 후에 "프로필 이름이 안 나온다"는 CS가 들어와서야 발견했다.
해결: 제네릭 응답 타입.
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가 달랐다.
interface ModalProps {
title: string;
variant: 'confirm' | 'alert' | 'form';
onConfirm?: () => void;
onCancel?: () => void;
children?: React.ReactNode;
submitLabel?: string;
}variant가 confirm이면 onConfirm과 onCancel이 필요하고, alert이면 onConfirm만 필요하고, form이면 children과 submitLabel이 필요하다. 근데 전부 옵셔널이니까 TypeScript가 강제하지 않는다.
실제로 터진 케이스: 누군가가 variant="confirm"인데 onConfirm을 빼먹었다. 확인 버튼을 누르면 onConfirm이 undefined이므로 아무 일도 안 일어났다. 사용자는 "버튼이 안 먹어요"라고 CS를 보냈다.
이것도 Discriminated Union으로 해결된다.
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"을 넣으면 onConfirm과 onCancel을 반드시 넘겨야 한다. 안 넘기면 컴파일 에러.
// 컴파일 에러! onCancel이 없다.
<Modal variant="confirm" title="삭제하시겠습니까?" onConfirm={handleDelete} />
// OK
<Modal
variant="confirm"
title="삭제하시겠습니까?"
onConfirm={handleDelete}
onCancel={handleCancel}
/>이 패턴의 부가 효과가 있다. 컴포넌트 내부에서도 variant로 분기하면 해당 브랜치에서 어떤 props가 존재하는지 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시간짜리 디버깅을 하나 줄일 수 있다.
