이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과.
Item 74–78 범위를 바탕으로 런타임 타입 재구성, DOM과 환경 모델, 단위 테스트 관계, 컴파일러 성능을 코드 리뷰 관점에서 정리합니다.
범위: Effective TypeScript 2판 Item 74–78
오늘의 질문: “타입스크립트가 있으니 런타임 검증과 테스트는 조금 덜 봐도 될까?”
이번 범위는 타입스크립트의 바깥을 봅니다. 타입을 잘 설계해도 브라우저가 실행하는 것은 타입이 지워진 자바스크립트입니다. DOM은 환경마다 다르게 구현되고, 테스트는 타입이 놓치는 런타임 분기를 확인합니다. 타입이 너무 복잡하면 컴파일러도 느려집니다.
이번 글에서 잃으면 안 되는 포인트는 이겁니다.
타입은 런타임에 사라지므로 외부 값은 다시 확인해야 한다. 런타임 타입 정보를 얻으려면 값, 태그, 스키마, 판별자 같은 단서가 필요하다. DOM 타입 선언은 실제 브라우저/테스트 환경의 API 존재를 보장하지 않는다. 단위 테스트와 타입 검사는 서로 대체 관계가 아니라 서로 다른 실패를 잡는다. 타입 수준 프로그래밍은 표현력이 있지만, 컴파일러 성능과 팀 디버깅 비용을 만든다.
type User = {
id: string;
name: string;
};
async function loadUser(): Promise<User> {
const response = await fetch("/api/me");
const data = await response.json();
return data as User;
}이 코드는 타입스크립트 입장에서는 깔끔합니다. loadUser()는 Promise를 반환합니다. 하지만 런타임에서는 response.json()이 어떤 값을 줄지 모릅니다. 서버가 name: null을 보내도, 인증 실패 HTML이 섞여도, 오래된 API가 user_name을 보내도 as User는 아무것도 확인하지 않습니다.
이 범위의 핵심은 “타입을 믿지 말자”가 아닙니다. 타입이 책임지는 구역과 런타임 코드가 책임지는 구역을 나누자는 말입니다.
unknown을 내부 타입으로 바꾸려면 검증 코드가 필요합니다.
type User = {
id: string;
name: string;
};
function isUser(value: unknown): value is User {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return typeof record.id === "string" && typeof record.name === "string";
}
async function loadUser(): Promise<User | null> {
const response = await fetch("/api/me");
const data: unknown = await response.json();
return isUser(data) ? data : null;
}여기서 isUser는 타입과 런타임을 이어주는 다리입니다. value is User라는 반환 타입만 중요한 게 아니라, 함수 본문이 실제 값을 확인한다는 점이 중요합니다.
리뷰할 때는 다음을 봅니다.
unknown이 경계에서 바로 좁혀지는가?
as SomeType가 검증 없이 외부 데이터를 믿게 만들지 않는가?
실패했을 때 null, Result, throw, error state 중 무엇으로 표현하는가?
검증 코드와 타입 정의가 서로 어긋날 가능성은 없는가?
작은 앱에서는 직접 type guard를 써도 됩니다. 데이터가 복잡하거나 API가 많아지면 Zod 같은 스키마 검증 도구를 쓰는 편이 더 일관적일 수 있습니다. 중요한 건 도구 이름이 아니라, 런타임 값이 타입으로 들어오는 관문이 있다는 사실입니다.
DOM 코드는 타입 선언이 있어도 실제 환경에서 깨질 수 있습니다.
const dialog = document.querySelector("dialog") as HTMLDialogElement;
dialog.showModal();브라우저에서는 맞을 수 있습니다. 하지만 테스트 환경이 jsdom이면 showModal이 구현되어 있지 않을 수 있습니다. 오래된 브라우저나 WebView도 마찬가지입니다. 타입 선언은 “이 API가 표준 DOM 모델에 있다”는 정보를 줄 뿐, 지금 실행 환경에 반드시 구현되어 있다는 보장은 아닙니다.
더 안전한 코드는 환경 차이를 드러냅니다.
const dialog = document.querySelector("dialog");
if (!(dialog instanceof HTMLDialogElement)) {
throw new Error("dialog element is missing");
}
if (typeof dialog.showModal !== "function") {
// 테스트 환경이나 구형 환경에서는 fallback 처리
dialog.setAttribute("open", "");
} else {
dialog.showModal();
}프론트엔드 코드 리뷰에서는 DOM 타입을 볼 때 다음 질문이 필요합니다.
selector 결과가 null일 수 있는가?
as HTMLElement가 실제 element 종류를 확인하지 않고 있지 않은가?
브라우저, jsdom, Storybook, SSR 환경에서 같은 API를 기대하는가?
접근성/기본 동작을 타입만 보고 생략하지 않았는가?
Day 12의 browser event 글과도 이어집니다. target은 타입상 EventTarget이고, 실제로는 텍스트 노드나 내부 span일 수 있습니다. 타입을 좁히는 코드가 이벤트 흐름을 제대로 반영해야 합니다.
타입은 많은 실수를 컴파일 전에 막습니다. 하지만 테스트가 봐야 하는 영역은 여전히 남습니다.
type Price = {
amount: number;
currency: "KRW" | "USD";
};
function formatPrice(price: Price) {
if (price.currency === "KRW") return `${price.amount.toLocaleString()}원`;
return `$${price.amount.toFixed(2)}`;
}타입은 currency가 두 값 중 하나라는 사실을 보장합니다. 하지만 다음은 타입만으로 충분하지 않습니다.
KRW가 소수점 없이 표시되는가?
USD가 소수점 두 자리로 표시되는가?
NaN, Infinity, 음수, 0은 어떻게 보이는가?
locale이 바뀌면 결과가 달라지는가?
테스트는 타입이 아니라 동작을 확인합니다.
it("formats KRW without decimal places", () => {
expect(formatPrice({ amount: 12000, currency: "KRW" })).toBe("12,000원");
});
it("formats USD with two decimals", () => {
expect(formatPrice({ amount: 12, currency: "USD" })).toBe("$12.00");
});반대로 테스트가 타입을 대체하지도 않습니다. 타입이 없으면 테스트가 커버하지 않은 호출 조합이 계속 생깁니다. 좋은 방향은 둘을 나누는 겁니다.
| 도구 | 잘 잡는 것 | 못 잡는 것 |
|---|---|---|
| TypeScript | 잘못된 호출 모양, 빠진 필드, 불가능한 union branch | 실제 렌더링 결과, 외부 API 응답, 브라우저 구현 차이 |
| Unit test | 구체적인 입력/출력, 실패 branch, 환경 fallback | 모든 호출 조합, 타입 설계 의도 |
| Runtime validation | 외부 값의 실제 shape | UI 동작, 성능, 접근성 |
코드 리뷰에서는 “타입이 있으니 테스트 생략”과 “테스트가 있으니 타입 대충”을 둘 다 경계해야 합니다.
타입스크립트 타입은 꽤 강력합니다. conditional type, mapped type, template literal type, recursive type을 조합하면 많은 규칙을 타입으로 표현할 수 있습니다.
type DotPath<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? K | `${K}.${DotPath<T[K]>}`
: K;
}[keyof T & string]
: never;이런 타입은 폼 경로, i18n key, route key를 표현할 때 유용할 수 있습니다. 하지만 객체가 커지고 union이 섞이면 컴파일러가 계산해야 할 조합이 급격히 늘어납니다. 에디터 자동완성이 느려지고, tsc --noEmit 시간이 늘고, 팀원이 타입 에러를 읽기 어려워질 수 있습니다.
타입 수준 설계도 리뷰 대상입니다.
이 복잡한 타입이 실제 버그를 줄이는가? 런타임 코드나 codegen으로 푸는 편이 더 단순하지 않은가? public API에 노출되어 사용자 에러 메시지를 어렵게 만들지 않는가? 큰 union/recursive type이 에디터 성능을 망치지 않는가? 타입 테스트가 있어 변경 시 깨지는 지점을 알 수 있는가?
Day 10에서 타입 테스트를 다룬 이유가 여기서 다시 중요해집니다. 복잡한 타입을 쓰려면 “잘 동작하겠지”가 아니라 타입 테스트와 성능 감각이 같이 있어야 합니다.
| 변화 | 확인할 질문 |
|---|---|
as SomeType 추가 | 외부 값을 검증하지 않고 믿게 만드는가? |
| type guard 추가 | 실제 런타임 값의 필드를 확인하는가, 이름만 guard인가? |
| DOM API 사용 | 브라우저/jsdom/SSR 환경 차이를 고려했는가? |
| 테스트 삭제 | 타입이 정말 그 동작을 보장하는가? |
| 테스트 추가 | 타입으로 막을 수 있는 호출 조합까지 테스트로 떠안고 있지 않은가? |
| 복잡한 타입 추가 | 버그 감소 효과가 컴파일/디버깅 비용보다 큰가? |
이번 범위의 결론은 분명합니다. 타입스크립트는 리뷰 범위를 줄여주지만, 런타임을 없애지는 않습니다. 타입이 사라진 뒤에도 데이터, DOM, 테스트, 컴파일러 성능이라는 경계는 남습니다. 좋은 TS 코드는 이 경계를 숨기지 않습니다.
Post Q&A
이펙티브 타입스크립트 2판 Day 13: 타입은 런타임과 테스트를 대신하지 않는다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
Item 79–83 범위를 바탕으로 JS 현대화, @ts-check와 JSDoc, allowJs, 모듈별 마이그레이션, noImplicitAny를 코드 리뷰 관점에서 정리합니다.