TypeScript는 왜 필요한가 – 언어가 아니라 컴파일 전략이다
TypeScript를 런타임 변화가 아닌 컴파일 안전 전략으로 쓰는 핵심 가치와 경계면 검증 방법을 정리
Posted
“TS를 쓰면 안전하다”의 함정은 대부분
By okorion
TypeScript는 왜 필요한가 – 언어가 아니라 컴파일 전략이다
TypeScript의 핵심 가치는 “런타임 버그를 컴파일타임으로 끌어올리는 것”이다
JavaScript의 가장 큰 비용은 “문제가 실행된 뒤에야 드러난다”는 점이다. TypeScript는 코드를 실행하기 전에 타입(형태/제약)을 검사해서, 깨질 가능성이 높은 코드 경로를 미리 막는다. 여기서 중요한 포인트는 “타입이 런타임을 바꾸는 게 아니라”, 컴파일(빌드) 단계에서 위험을 줄이는 전략이라는 것이다.
TypeScript 타입 시스템은 “할 수 있는 일”과 “못 하는 일”이 명확하다
TypeScript는 컴파일타임 검사기다. 즉, 타입 시스템은 코드가 실행되기 전에만 작동한다.
타입 시스템이 하는 일: 코드의 “형태”와 “계약”을 검증한다
- 객체에 존재하지 않는 프로퍼티 접근 차단
- 함수 인자/반환의 형태 불일치 차단
- 널/언디파인드 가능성(설정에 따라) 노출
- 리팩토링 시 영향 범위를 컴파일 에러로 표면화
타입 시스템이 못 하는 일: 런타임 데이터의 진실을 보장하지 못한다
- 네트워크 응답(JSON)이 실제로 타입대로 왔는지 보장 불가
- localStorage/URL query/user input 같은 외부 입력은 런타임에서 깨질 수 있음
any, 타입 단언(as), 잘못된 선언 파일(.d.ts)은 거짓 안정감을 만들 수 있음
결론: TS는 “정적(compile-time) 안전”을 주고, “동적(runtime) 안전”은 별도(런타임 검증)로 챙겨야 한다.
문제 사례 1: undefined 접근은 JS에서 조용히 터지고, TS에선 빨리 드러난다
결론: “값이 없을 수도 있음”을 타입으로 표현하면 접근 자체가 강제 제어된다
JS (런타임에서 터짐)
1
2
3
4
// (JS라고 가정) 런타임에서 user가 null이면 터진다.
function printUserName(user) {
console.log(user.name.toUpperCase());
}
TS (컴파일 단계에서 차단)
1
2
3
4
5
6
7
8
9
type User = { name: string };
function printUserName(user: User | null) {
// user가 null일 수도 있으니 접근 불가
// console.log(user.name.toUpperCase()); // ❌ 컴파일 에러
if (!user) return;
console.log(user.name.toUpperCase()); // ✅ 안전
}
디버깅 포인트(흔한 실수):
- “어차피 항상 들어와”라고 생각하고
user as User로 덮어버림 → 런타임에서 그대로 터짐 - strictNullChecks를 끄고 “편해졌다” 착각 → 나중에 장애 비용이 커짐
문제 사례 2: API 응답 형태 변동은 JS에서 늦게 터지고, TS에선 경계면을 강제한다
결론: 외부 데이터는 “타입을 믿지 말고, 검증 후 좁혀라”
JS (응답 구조가 바뀌면 조용히 실패)
1
2
3
4
5
async function getTitle() {
const res = await fetch("/api/post");
const data = await res.json();
return data.title.toUpperCase(); // title이 없거나 null이면 런타임 에러
}
TS (경계면에서 unknown으로 받고 좁히기)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function isPost(x: unknown): x is { title: string } {
if (typeof x !== "object" || x === null) return false;
return "title" in x && typeof (x as any).title === "string";
}
async function getTitle() {
const res = await fetch("/api/post");
const data: unknown = await res.json(); // ✅ 외부 입력은 unknown으로 받는 게 정석
if (!isPost(data)) {
throw new Error("Invalid API response shape");
}
return data.title.toUpperCase(); // ✅ 타입이 좁혀져 안전
}
핵심: unknown은 “아무것도 모른다”를 의미해서 그대로 쓰는 걸 금지한다. 그래서 경계면(네트워크/저장소/유저 입력)에서 가장 유용하다.
타입 추론(implicit)과 명시적 타입(explicit)의 경계는 “설계 안정성”으로 결정한다
결론: 추론은 기본, “경계면/공용 API/의도 고정”에는 명시가 이득이다
추론에 맡겨도 되는 경우(대부분의 로컬 변수)
1
2
const count = 1; // number로 추론
const tags = ["ts", "js"]; // string[]로 추론
명시하는 게 이득인 경우(의도 고정 / API 계약 / 리팩토링 안전성)
1
2
3
4
5
type Role = "admin" | "user";
function setRole(role: Role) {
// role 계약이 명확해지고 호출부가 강제된다
}
실무 판단 기준:
- 추론 유지: “코드를 읽으면 타입이 자명한 로컬 범위”
- 명시 필요: “다른 파일/팀이 호출하는 함수 시그니처”, “외부 입력을 받는 경계”, “리턴 타입이 복잡해지는 비동기/헬퍼”
“TS를 쓰면 안전하다”의 함정은 대부분 any와 타입 단언에서 시작한다
결론: any/단언은 빚이며, 최소화하지 않으면 TS ROI가 급락한다
1) any는 타입 시스템을 해제한다
1
2
let x: any = 123;
x.toUpperCase(); // 컴파일 통과, 런타임에서 터질 수 있음
2) 타입 단언(as)은 “검증”이 아니라 “우기기”다
1
2
3
4
type User = { name: string };
const u = JSON.parse('{"name":123}') as User; // ✅ 컴파일은 통과
// 런타임에서 u.name은 number라서 이후 코드가 깨질 수 있음
원칙:
- 단언은 “이미 검증된 값”을 타입에 반영할 때만 사용
- 외부 데이터는
unknown+ 타입가드(또는 런타임 스키마 검증)로 처리
never는 “도달 불가능”을 강제해서, 누락된 분기를 컴파일 에러로 만든다
결론: 유니언 상태가 커질수록 never 기반 exhaustive check가 유지보수 비용을 줄인다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number };
function assertNever(x: never): never {
throw new Error("Unhandled case: " + JSON.stringify(x));
}
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius * s.radius;
case "square":
return s.size * s.size;
default:
return assertNever(s); // ✅ 새로운 kind가 추가되면 여기서 컴파일 에러로 잡힘
}
}
왜 중요한가:
- 타입이 늘어날 때 “처리 누락”이 런타임 버그로 가기 전에 컴파일 단계에서 막는다.
- 상태 머신(UI 상태/비즈니스 상태) 설계에서 특히 강력하다.
tsc의 동작 흐름은 “타입체크 → JS 방출”이며, 설정에 따라 방출 정책이 바뀐다
결론: TS는 최종적으로 JS를 만든다. 에러가 있어도 JS가 나갈 수 있다는 점을 통제해야 한다
기본 파이프라인
- TS 소스 입력
- 타입 체크
- (설정에 따라) JS emit(출력)
실무에서 중요한 옵션 개념
- 에러가 있어도 JS를 방출하면: “빌드는 되는데 런타임에서 터지는” 상황이 생길 수 있음
- 방출을 막으면: “타입 안정성이 깨진 상태로 배포되는” 리스크를 줄일 수 있음
실무 포인트: 팀이 TS를 “문서 수준”으로 쓸지, “게이트키퍼”로 쓸지(tsc 에러를 빌드 실패로) 정책을 정해야 ROI가 올라간다.
실무 판단 기준: 언제 TypeScript ROI가 높은가/낮은가
결론: “변경이 잦고 협업이 크며 경계면이 많은 코드”일수록 TS ROI가 폭증한다
ROI가 높은 경우
- API/데이터 모델이 복잡하고 자주 바뀜
- 팀 규모가 커서 리팩토링 비용이 큼
- 상태/분기(유니언)가 많아 누락 버그가 자주 발생
- 라이브러리/모듈 재사용이 많음(공용 함수/컴포넌트)
ROI가 낮아지는 경우
any남발, 타입 단언으로 덮기, strict 옵션 꺼둠- 단기 스크립트/일회성 코드(단, 재사용되기 시작하면 즉시 바뀜)
- 런타임 검증이 필요한 경계면을 타입만으로 해결하려는 경우
체크리스트
결론: 아래 6개만 지키면 “TS 도입했는데도 불안한 코드”를 대부분 제거한다
- 외부 입력(fetch/json/storage)은
unknown으로 받고 타입가드로 좁힌다 any는 금지하고 필요하면 범위를 최소화한다- 타입 단언(
as)은 “검증 이후”에만 사용한다 - 유니언 상태는
kind(discriminant)로 모델링한다 - switch에는
neverexhaustive check를 넣는다 - 팀 정책으로 “타입 에러 시 빌드 실패”를 기본값으로 둔다
요약 5줄
- TypeScript의 핵심은 런타임 버그를 컴파일타임으로 당기는 전략이다.
- TS는 컴파일타임 검사기이며 런타임 데이터의 진실은 보장하지 못한다.
- 추론은 기본이고, 경계면/공용 API/의도 고정 지점은 명시 타입이 이득이다.
unknown은 외부 입력을 안전하게 처리하게 만들고,never는 누락 분기를 컴파일 에러로 만든다.any와 타입 단언 남발은 TS ROI를 박살내며, 정책(빌드 게이트) 없으면 효과가 반감된다.
자기점검 질문 5개
- 내 프로젝트에서 “외부 입력 경계면”은 어디이며, 현재 타입을 어떻게 받고 있는가?
as를 쓰는 지점은 “검증 이후”인가, “우기기”인가?any가 들어간 파일/모듈은 왜 any가 필요했는가(대안은)?- 상태/분기가 많은 로직에 discriminated union + never 체크가 들어가 있는가?
- 타입 에러가 나도 빌드/배포가 되는 파이프라인인가, 막히는 파이프라인인가?
실전 미션 3개 (각 30분 내)
- 코드베이스에서
as검색 후 5개만 골라 “unknown + 타입가드”로 바꿔라(외부 입력 우선). - 유니언 상태를 쓰는 switch 1곳에
assertNever를 추가하고, 일부러 케이스 하나를 빼서 컴파일 에러가 나는지 확인하라. any가 있는 모듈 1개를 골라unknown/제네릭/유니언 중 하나로 치환하고, 타입이 실제로 더 좁혀졌는지 확인하라.
This post is licensed under CC BY 4.0 by the author.
