이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과.
Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.
범위: Effective TypeScript 2판 Item 28–34
오늘의 질문: “타입이 맞는데도 UI 상태 조합이 이상해지는 이유는 뭘까?”
이 글도 기존 Day 6을 다시 쓴 개정판입니다. 이전 글은 핵심을 잡고 있었지만, Item 28–34의 폭에 비해 너무 짧았습니다. 이번 범위는 추론 위치, 유효 상태 모델링, API 입출력 타입 분리, null 경계, union 설계를 함께 봐야 품질이 납니다.
이번 글에서 잃으면 안 되는 포인트는 이겁니다.
null, undefined, empty value는 도메인 의미가 다르다.type RequestState = {
loading: boolean;
error?: string;
data?: User;
};
많이 보는 형태입니다. 하지만 이 타입은 이상한 조합을 허용합니다.
const state: RequestState = {
loading: true,
error: "권한이 없습니다",
data: { id: "1", name: "Jing" },
};
타입은 맞지만 상태 의미는 이상합니다. 로딩 중이면서 에러이고, 동시에 데이터도 있습니다. 렌더링 코드는 이제 우선순위를 정해야 합니다. 로딩을 보여줄지, 에러를 보여줄지, 데이터를 보여줄지 매번 조건문으로 싸워야 합니다.
더 나은 모델은 불가능한 조합을 처음부터 표현하지 않습니다.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: User };
이 범위는 문법 팁 모음처럼 보이지만, 실제로는 하나의 흐름입니다.
| 범위 축 | 코드 리뷰 질문 | 나쁜 냄새 |
|---|---|---|
| 추론 위치 | 타입을 어디서 좁히거나 넓히고 있나? | 외부 데이터를 as const나 단언으로 믿는다 |
| 유효 상태 | 이 타입이 말이 안 되는 조합을 허용하나? | loading, error, data가 동시에 존재한다 |
| API 입출력 | request/response/internal model이 같은 보장을 갖나? | User 하나로 form, API, UI를 모두 처리한다 |
| 없음의 의미 | null, undefined, 빈 값이 같은 뜻인가? | 로딩 전/빈 결과/삭제됨을 한 값으로 합친다 |
| union 설계 | 판별 필드가 branch를 안전하게 좁히나? | type: string + optional 필드 여러 개로 버틴다 |
즉 Day 6의 중심은 “타입을 더 화려하게 쓰기”가 아닙니다. 경계에서 불확실성을 정리하고, UI 내부에서는 가능한 상태만 다루게 만들기입니다.
TypeScript는 선언 위치의 단서를 보고 타입을 추론합니다.
const tabs = ["home", "settings"];
// string[]
const fixedTabs = ["home", "settings"] as const;
// readonly ["home", "settings"]
둘 다 맞는 추론입니다. 첫 번째는 나중에 다른 문자열이 들어갈 수 있는 배열입니다. 두 번째는 정확히 두 값으로 고정된 tuple입니다. 문제는 어떤 추론이 필요한지 코드가 말하지 않을 때 생깁니다.
옵션 목록이라면 리터럴을 보존하는 편이 좋습니다.
const tabs = ["home", "settings"] as const;
type Tab = (typeof tabs)[number];
반대로 서버에서 온 값이라면 as const로 믿게 만드는 건 위험합니다. 외부 값은 좁히는 게 아니라 확인해야 합니다.
function isTab(value: unknown): value is Tab {
return value === "home" || value === "settings";
}
리뷰 질문은 “타입이 좁은가?”가 아닙니다.
이 값은 코드가 만든 고정 목록인가, 외부에서 들어온 불확실한 값인가?
리더가 헷갈리는 지점은 여기입니다. as const는 값이 좁아지는 도구지만, 항상 안전을 높이는 도구는 아닙니다. 코드 안에서 직접 만든 목록에는 좋습니다. API에서 받은 값을 검증 없이 좁힐 때는 오히려 거짓 확신을 만듭니다.
const serverTab = await fetchCurrentTab();
// 위험: 외부 값을 "home" | "settings"라고 믿게 만들 뿐이다.
const tab = serverTab as Tab;
이 코드는 TypeScript에게만 안심하라고 말합니다. 런타임 값이 "billing"이면 여전히 들어옵니다. Item 28–34를 리뷰할 때는 “컴파일러가 조용한가?”보다 “그 조용함이 검증에서 온 건가, 단언에서 온 건가?”를 먼저 봐야 합니다.
상태 모델은 UI 복잡도를 직접 바꿉니다.
function UserPanel({ state }: { state: RequestState }) {
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorMessage message={state.message} />;
if (state.status === "success") return <Profile user={state.data} />;
return <EmptyGuide />;
}
이 코드는 각 branch가 명확합니다. success에서만 data가 있고, error에서만 message가 있습니다. Day 5의 narrowing이 여기서 힘을 얻습니다. 잘 설계된 union은 컴파일러가 자연스럽게 좁힐 수 있는 구조를 제공합니다.
상태가 더 많아지면 empty도 분리할 수 있습니다.
type UserListState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "empty" }
| { status: "success"; users: User[] };
success인데 users.length === 0을 허용할지, 빈 결과를 별도 상태로 볼지는 도메인 판단입니다. 중요한 건 그 판단이 타입에 드러나야 한다는 점입니다.
프론트엔드 코드에서 흔한 실수는 form input, API request, API response, UI model을 한 타입으로 합치는 것입니다.
type User = {
id: string;
name: string;
email: string;
};
async function createUser(user: User) {
await fetch("/api/users", { method: "POST", body: JSON.stringify(user) });
}
생성 요청에는 id가 없을 수 있습니다. 서버 응답에는 createdAt이 붙을 수 있습니다. form에서는 email이 아직 빈 문자열일 수 있습니다. 같은 User 타입으로 모두 처리하면 어느 경계에서 어떤 값이 보장되는지 흐려집니다.
type CreateUserInput = {
name: string;
email: string;
};
type UserResponse = {
id: string;
name: string;
email: string;
createdAt: string;
};
type User = {
id: string;
name: string;
email: string;
createdAt: Date;
};
이렇게 나누면 귀찮아 보이지만, 경계가 선명해집니다. API response의 createdAt 문자열을 앱 내부 Date로 바꾸는 변환 함수도 자연스럽게 생깁니다.
function toUser(response: UserResponse): User {
return { ...response, createdAt: new Date(response.createdAt) };
}
리뷰에서는 “타입이 중복된다”보다 “경계가 다르다”를 먼저 봐야 합니다. 중복 제거를 서두르다가 input/output/internal model을 한 타입으로 합치면 나중에 더 큰 비용을 냅니다.
상태 모델링에서 자주 보이는 중간 형태가 있습니다.
type SaveState = {
status: "idle" | "saving" | "saved" | "failed";
savedAt?: Date;
errorMessage?: string;
};
처음에는 좋아 보입니다. status도 있고 optional 필드도 있습니다. 하지만 이 타입은 아래 값을 막지 못합니다.
const state: SaveState = {
status: "saved",
errorMessage: "네트워크 오류",
};
saved인데 에러 메시지가 있습니다. 반대로 failed인데 errorMessage가 빠져도 타입상 가능할 수 있습니다. 판별 필드를 넣었는데도 나머지 필드가 status와 연결되지 않았기 때문입니다.
더 안전한 형태는 상태별 interface를 union으로 묶는 것입니다.
type SaveState =
| { status: "idle" }
| { status: "saving" }
| { status: "saved"; savedAt: Date }
| { status: "failed"; errorMessage: string };
이제 status === "saved" branch에서 savedAt은 필수이고, failed branch에서 errorMessage도 필수입니다. 이 차이는 작은 문법 차이처럼 보이지만 리뷰 비용을 크게 줄입니다.
function SaveBanner({ state }: { state: SaveState }) {
switch (state.status) {
case "idle":
return null;
case "saving":
return <span>저장 중...</span>;
case "saved":
return <span>{state.savedAt.toLocaleTimeString()}에 저장됨</span>;
case "failed":
return <span role="alert">{state.errorMessage}</span>;
}
}
이런 코드에서는 branch마다 접근 가능한 필드가 자연스럽게 정해집니다. UI 리뷰어는 “saved인데 error도 있나?” 같은 조합을 따로 머릿속에서 추적하지 않아도 됩니다.
null, undefined, 빈 문자열, 빈 배열은 모두 “없음”처럼 보일 수 있지만 의미가 다릅니다.
type FormState = {
nickname?: string;
};
이 타입에서 nickname이 빠진 것은 아직 입력하지 않았다는 뜻일까요? 사용자가 비워두기로 했다는 뜻일까요? 서버가 값을 안 보냈다는 뜻일까요?
도메인 의미가 다르면 타입도 다르게 표현하는 편이 좋습니다.
type NicknameState =
| { kind: "not-loaded" }
| { kind: "empty" }
| { kind: "present"; value: string };
물론 모든 optional 값을 union으로 바꾸라는 뜻은 아닙니다. 중요한 것은 의미 차이가 UI branch나 저장 로직에 영향을 주는지입니다.
예를 들어 리스트는 경계에서 정규화할 수 있습니다.
function normalizeUsers(users: User[] | null | undefined): User[] {
return users ?? [];
}
하지만 로딩 중과 빈 결과는 합치면 안 됩니다. 로딩 중은 아직 모르는 상태이고, 빈 결과는 이미 확인한 상태입니다.
좋은 타입 설계는 컴포넌트 안 조건문을 늘리는 방향이 아닙니다. 오히려 경계에서 한 번 정리해서 내부를 단순하게 만듭니다.
type UserApiResponse = {
status: "ok" | "error";
user?: {
id?: string;
name?: string | null;
};
message?: string;
};
type UserViewState =
| { status: "ready"; user: { id: string; name: string } }
| { status: "empty" }
| { status: "failed"; message: string };
function toUserViewState(response: UserApiResponse): UserViewState {
if (response.status === "error") {
return {
status: "failed",
message: response.message ?? "사용자를 불러오지 못했습니다",
};
}
if (!response.user?.id || !response.user.name) {
return { status: "empty" };
}
return {
status: "ready",
user: { id: response.user.id, name: response.user.name },
};
}
UserApiResponse는 지저분합니다. 외부 경계이기 때문입니다. 대신 UserViewState는 단순합니다. 앱 내부 컴포넌트는 이미 검증된 상태만 받습니다. 이 분리를 하지 않으면 모든 컴포넌트가 user?.id, name ?? "", message ?? ... 같은 방어 코드를 반복하게 됩니다.
AI가 만든 프론트엔드 코드에서도 이 문제가 자주 보입니다. API wrapper 없이 컴포넌트 안에서 응답을 바로 렌더링하고, optional chaining으로 모든 오류를 눌러버립니다. 조용해 보이지만 실제로는 “어디에서 보장되는 값인지”가 사라진 코드입니다.
좋은 union은 단순히 TypeScript다운 코드가 아닙니다. 리뷰어가 확인해야 할 조합을 줄입니다.
| 약한 모델 | 문제 | 더 나은 모델 |
|---|---|---|
loading: boolean; data?: T; error?: string | 모순 조합 가능 | status 판별 유니온 |
value: string | null | undefined | 없음의 의미 섞임 | not-loaded / empty / present |
type: string | 모든 문자열 허용 | literal union |
| request/response/internal 단일 타입 | 경계별 보장 혼합 | Input / Response / Model 분리 |
이런 설계는 코드량을 조금 늘릴 수 있습니다. 하지만 렌더링 branch, 테스트 케이스, 에러 처리, AI 코드 리뷰 기준을 훨씬 선명하게 만듭니다.
테스트 관점에서도 차이가 납니다. 약한 모델은 조합 수가 너무 많아서 테스트가 항상 빠집니다.
type WeakState = {
loading: boolean;
data?: User[];
error?: string;
};
이 타입은 최소한 아래 조합을 모두 생각해야 합니다.
loading: true, data 있음, error 없음loading: true, data 없음, error 있음loading: false, data 있음, error 있음loading: false, data 없음, error 없음반면 판별 유니온은 테스트 케이스가 도메인 상태와 거의 1:1로 맞습니다. loading, empty, success, error를 각각 테스트하면 됩니다. 타입 설계가 테스트 설계까지 밀어주는 셈입니다.
AI가 생성한 TypeScript 코드는 대체로 컴파일을 맞추는 데 능합니다. 하지만 Item 28–34 관점으로 보면 아래 패턴을 자주 놓칩니다.
as SomeType으로 API 응답을 바로 믿는다.type: string을 두고 실제로는 몇 가지 값만 쓰는 상태를 만든다.loading, isError, isSuccess boolean을 여러 개 둔다.User 타입으로 합친다.null과 undefined를 전부 ?? ""로 없애서 도메인 의미를 지운다.이런 코드는 “타입스크립트가 붙어 있다”는 이유만으로 안전해 보입니다. 리뷰에서는 컴파일 통과보다 상태 모델을 먼저 봐야 합니다. 불가능한 상태를 허용하는 타입은 런타임 조건문과 테스트에 빚을 넘깁니다.
as const가 코드가 만든 고정 목록에 쓰였는가, 외부 데이터를 믿는 데 쓰였는가?null/undefined/empty value의 의미가 UI branch에 영향을 주는가?as)이 실제 검증을 대신하고 있지 않은가?Day 6의 결론은 간단합니다. 타입 설계는 필드를 많이 적는 일이 아니라, 리뷰해야 할 상태 공간을 줄이는 일입니다. 좋은 타입은 이상한 조합을 막고, 경계에서 불확실성을 정리하고, UI 내부가 다룰 값을 작게 만듭니다. “타입이 맞다”에서 멈추지 말고 “이 타입이 도메인에서 가능한 상태만 표현하나?”까지 봐야 합니다.
Post Q&A
이펙티브 타입스크립트 2판 Day 6: 유효한 상태만 표현하는 타입 설계 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.
Item 79–83 범위를 바탕으로 JS 현대화, @ts-check와 JSDoc, allowJs, 모듈별 마이그레이션, noImplicitAny를 코드 리뷰 관점에서 정리합니다.
Item 67–73 범위를 바탕으로 public API 타입, TSDoc, this callback, module augmentation, TypeScript 기능 선택, source map을 코드 리뷰 관점에서 정리합니다.
Item 60–66 범위를 바탕으로 Object iteration, Record, tuple/rest, XOR, brand, @types 버전 경계를 코드 리뷰에서 다루는 방법을 정리합니다.
Item 55–59 범위를 바탕으로 type test, 타입 표시, 꼬리 재귀 한계, 코드 생성, never exhaustiveness를 코드 리뷰에서 다루는 방법을 정리합니다.
Item 49–54 범위를 바탕으로 type coverage, 제네릭, 조건부 타입, 템플릿 리터럴 타입을 코드 리뷰에서 어떻게 판단할지 정리합니다.
Item 42–48 범위를 바탕으로 any, unknown, 타입 단언, monkey patching, soundness 함정을 외부 입력 경계와 코드 리뷰 관점에서 정리합니다.
Item 35–41 범위를 바탕으로 string 남용, optional 필드, 특수 값, 도메인 이름 설계를 코드 리뷰 관점에서 정리합니다.
Item 28–34 범위를 바탕으로 추론 위치, 유효 상태 모델링, API 입출력, null 경계, union 설계를 프론트엔드 코드 리뷰 관점에서 확장 정리합니다.
Item 22–27 범위를 바탕으로 narrowing, alias, context inference, evolving type, async/type flow를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.
Effective TypeScript 2판의 Item 16–21을 바탕으로 index signature, 타입 추론 기본, 변수/객체 생성 패턴을 프론트엔드 코드 리뷰 관점에서 정리합니다.
Effective TypeScript 2판의 Item 11–15를 바탕으로 excess property check, 함수식 타입, type vs interface, readonly, 타입 반복 제거를 프론트엔드 코드 리뷰 관점에서 정리합니다.
Effective TypeScript 2판의 Item 6–10을 바탕으로 타입 시스템을 탐색하는 법, 타입을 값의 집합으로 보는 관점, type/value space, 타입 단언의 경계를 코드 리뷰 관점에서 정리합니다.
Effective TypeScript 2판의 Item 1–5를 바탕으로 TypeScript와 JavaScript의 관계, tsconfig, 타입 제거, 구조적 타이핑, any의 위험을 프론트엔드 코드 리뷰 관점에서 정리합니다.