번역 키를 컴파일 타임에 잡기 #1 — TypeScript는 언제 무엇을 하는가
t('user.profile.title')의 오타는 왜 컴파일도 테스트도 다 통과하고 런타임에야 조용히 터질까. 그걸 타입만으로 잡고 싶어서, 먼저 TypeScript가 언제 무엇을 하는지 — 컴파일·실행 모델과 타입 소거부터 다시 봤다.
t('user.profile.title')의 오타는 왜 컴파일도 테스트도 다 통과하고, 런타임에야 조용히 터질까. 그걸 타입만으로 잡고 싶어서, 먼저 “TypeScript가 대체 언제 무엇을 하는가”부터 다시 봤다.
이 글은 “번역 키를 컴파일 타임에 잡기” 시리즈의 1편이다. 결론(타입 하나로, 도구 없이 잡는다)으로 가기 전에, 이번 편은 그걸 이해하는 데 필요한 TypeScript의 밑바닥 — 언제 무엇이 컴파일되고, 언제 실행되고, 타입은 어디로 사라지는가 — 를 먼저 다진다.
왜 TypeScript부터 다시 봤나
번역 파일을 쓰다 보면 t('user.profile.title') 같은 코드를 수도 없이 만난다. 그런데 이 키는 결국 그냥 문자열이다. 오타를 내거나(ttitle), 네임스페이스를 빼먹어도 — 컴파일도 통과하고, 테스트도 안 걸리고 — 런타임에 조용히 깨진다. 화면에 원시 키가 그대로 뜨거나, 더 교묘하게는 영어 fallback에 고정된다.
이런 걸 작성하는 순간(컴파일 타임)에 잡고 싶었다. 그러려면 “TypeScript가 대체 언제 무슨 일을 하는가”를 정확히 알아야 했다. 막연히 “타입 붙이면 안전하겠지”로는 안 되고, 어느 단계에서 검사가 일어나고, 그 검사 결과가 런타임까지 어떻게 이어지는지(혹은 안 이어지는지)를 알아야 했다. 이 글은 그 기초를 정리한 것이다.
1. TypeScript는 JavaScript에 타입을 얹은 것
먼저 오해 하나를 풀고 가자. TypeScript는 JavaScript와 “완전히 다른 언어”가 아니다. JavaScript의 상위집합(superset) 이다. 모든 유효한 .js 코드는 그대로 유효한 .ts 코드다. TypeScript는 그 위에 타입 시스템이라는 레이어를 하나 더 얹은 것뿐이다.
그리고 이 타입 레이어는 브라우저나 Node가 이해하지 못한다. 그래서 실행하려면 타입을 걷어내고 순수 JavaScript로 바꿔야 한다. 여기서 “컴파일”이 등장하는데 — 사실 이 지점이 가장 많이 꼬인다. 왜냐하면 우리가 “컴파일”이라 부르는 게 두 개이기 때문이다.
2. “컴파일”이라 불리는 게 사실 두 개다
이 표 하나가 이 글의 절반이다.
| ① TS → JS | ② JS → 기계어 | |
|---|---|---|
| 언제 | 빌드 시점 | 실행 시점(런타임) |
| 누가 | tsc, esbuild, swc … | 엔진(V8 — Node·브라우저) |
| 결과물 | JS 파일(소스 코드) | 메모리 안의 기계어 — 파일 아님 |
| 하는 일 | 타입 검사 + 타입 소거 + 문법 변환 | 인터프리트 + JIT 컴파일 |
두 가지만 기억하면 된다.
- TypeScript는 절대 “실행”되지 않는다. 항상 ① 단계에서 JavaScript로 바뀐 뒤, 실제로 실행되는 것은 언제나 JavaScript다.
- JavaScript는 “기계어 파일”로 변환되지 않는다. 엔진이 런타임에 메모리에서 기계어를 만들 뿐, 산출 파일이 없다.
나도 “TypeScript는 컴파일 언어, JavaScript는 인터프리터 언어”라고 한 줄로 외웠다가 한참 헷갈렸다. 따져보니 이 이분법이 헷갈리는 건 당연했다 — 둘은 애초에 같은 축 위에 있지 않기 때문이다. TS는 빌드 시점에 JS로 바뀌는 것이고, JS는 런타임에 엔진이 실행하는 것이다.
3. 컴파일러와 인터프리터의 차이
②를 이해하려면 이 둘의 근본 차이를 짚어야 한다. 둘 다 “사람이 읽는 코드를 컴퓨터가 처리하게 만든다”는 목적은 같지만, 언제, 어떻게 하느냐가 다르다.
- 컴파일러: 프로그램 전체를 실행 전에 한 번에 다른 형태(보통 기계어)로 번역한다. → 책 한 권을 통째로 번역해 건네주는 것.
- 인터프리터: 번역본을 따로 만들지 않고, 실행하면서 한 줄씩 읽어 그 자리에서 수행한다. → 옆에서 문장마다 통역해주는 것.
나도 여기서 한 번 걸렸는데, 인터프리터는 파일을 만들지 않는다. 컴파일러는 산출물(파일)을 만들고, 인터프리터는 읽어서 즉시 실행한다. 공통점은 “사람 코드를 컴퓨터가 처리하게 한다”까지고, 그다음이 갈린다.
| 시작 속도 | 실행 속도 | 대표 | |
|---|---|---|---|
| 컴파일 | 느림(선번역 비용) | 빠름(기계어) | C, Go, Rust |
| 인터프리트 | 빠름(바로 시작) | 느림(매번 해석) | 전통적 파이썬·JS |
컴파일 시간과 실행 시간은 다르다
나도 “컴파일 언어는 빠르다”는 말에 헷갈렸다. C 코드를 처음 돌려보면 그렇게 빠르게 느껴지지 않는데, 그건 “빌드를 눌러 결과를 볼 때까지”에 컴파일 시간이 끼기 때문이다. 한 번 컴파일된 바이너리를 실행하는 것 자체(=실행 시간)는 빠르다.
즉 컴파일 시간 ≠ 실행 시간이다. 컴파일 시간은 실행 전 번역에 드는 시간이고, 실행 시간은 코드를 정말로 실행하는 시간이다. 같은 수준으로 번역된다는 전제라면, 실행 시간은 컴파일 언어가 인터프리터 언어보다 빠르다 — 미리 다 번역해뒀으니까.
4. JavaScript는 실제로 어떻게 실행되나 — 인터프리터 + JIT
그럼 JavaScript는 순수 인터프리터일까? 아니다. 여기가 핵심이다. 현대 JS 엔진(V8 등)은 인터프리터로 시작하지만, 자주 실행되는 코드는 런타임에 기계어로 컴파일한다. 이걸 JIT(Just-In-Time compilation) 라고 한다.
JIT가 왜 필요한가
순수 인터프리터는 같은 코드를 반복 실행할 때 매번 다시 해석해서 느리다. 그렇다고 JS를 실행 전에 통째로 컴파일하기도 어렵다. JS는 동적 타입이라 실행 전엔 어떤 변수가 숫자인지 문자열인지 알 수 없기 때문이다. 그래서 나온 절충안이 JIT다.
JIT는 어떻게 도나 (단순화)
- 일단 인터프리터로 시작한다(빠른 시작).
- 돌리면서 관찰한다 — “이 함수 자주 불리네”, “이 변수는 항상 숫자네”.
- 자주 도는(뜨거운) 코드만 관찰 정보를 근거로 최적화된 기계어로 컴파일한다. (예: “이 변수는 늘 숫자니 숫자 전용 기계어로 짜자.”)
- 가정이 깨지면(갑자기 문자열이 들어옴) 역최적화(deopt) 하고 인터프리터로 되돌아간다.
JIT는 곧 “인터프리터의 빠른 시작 + 컴파일러의 빠른 실행”을 노린 하이브리드다. 단, 핵심은 관찰할 시간(워밍업)이 있어야 이득이라는 것 — 이 조건이 다음 이야기의 열쇠다.
5. tsc — 두 가지 일을 하지만, 반드시 같이 할 필요는 없다
이제 TypeScript 컴파일러 tsc로 돌아오자. tsc는 두 가지 일을 한다.
- 타입 검사 — 타입이 맞는지 검증하고 에러를 리포트한다.
- 파일 변환(emit) — TS를 JS로 바꾼다. (타입 소거 + 문법 변환)
그리고 이 둘은 반드시 함께 쓸 필요가 없다. 이게 이 시리즈에서 결정적인 사실이다.
- IDE의 실시간 타입 검사(빨간 줄)는
tsserver가 하는데, 검사는 하지만 파일 변환은 안 한다. tsc --noEmit옵션을 주면 검사만 하고 JS를 안 만든다.- 반대로, 파일 변환은 tsc가 아니어도 된다. esbuild·swc·Babel 같은 도구가 타입을 걷어내고 JS로 바꿔준다 — 대신 이들은 타입 검사를 하지 않는다(그래서 빠르다).
그래서 요즘 프로젝트는 보통 이렇게 역할을 나눈다: “변환은 esbuild가 빠르게, 타입 검사는 tsc --noEmit가 따로(IDE·CI에서).” 즉 “검사기만 떼어 쓰는” 건 특별한 발상이 아니라 이미 널리 자리 잡은 구성이다. (그리고 앞으로 보게 되겠지만, 번역 키를 잡는 게이트도 바로 이 tsc --noEmit의 통과/실패다.)
한 가지 주의: 코드를 실제로 실행하려면 결국 변환은 반드시 필요하다. TS 파일 자체는 브라우저나 Node가 못 돌리니까.
6. tsc는 왜 느린가 — 그리고 tsgo
tsc를 써본 사람은 안다. 큰 코드베이스에서 느리다. 그 이유가 4절의 실행 모델과 정확히 맞물린다.
tsc는 TypeScript로 작성돼 Node(JS 엔진) 위에서 도는 프로그램이다. 게다가 그 워크로드가 JS 실행 모델의 약점을 정통으로 찌른다.
- 한 번 켜고 죽는다(short-lived). CI의 타입 체크는 “프로세스 시작 → 파일 읽기 → 검사 → 출력 → 종료”로 몇 초 만에 끝난다. 그런데 JIT는 워밍업할 시간이 있어야 이득이다(4절). tsc는 JIT가 최적화를 끝내기도 전에 프로세스가 끝나버려서, 워밍업 비용만 내고 이득은 못 챙긴다. 매 실행마다 차갑게(cold) 다시 시작한다. (반면
tsc --watch나 IDE의tsserver는 계속 살아 있어 워밍업 이득을 본다 — 그래서 에디터는 처음만 굼뜨고 이후 빠릿하다.) - 객체를 어마어마하게 만든다. 타입 검사라는 작업 자체가 객체 대량 생산이다. 모든 구문마다 AST 노드, 모든 선언마다 심볼, 그리고 타입 객체 — union·intersection 같은 타입은 조합적으로 새 객체를 만들어낸다. 큰 프로젝트는 작은 힙 객체 수백만 개가 되고, GC가 있는 JS 런타임에선 무거운 GC 압력이 된다.
그래서 나온 게 tsgo다. tsc를 Go로 다시 작성한 네이티브 컴파일러다(통칭 “TypeScript 7”). Go는 네이티브 기계어로 컴파일되는 언어라 JIT 워밍업이 없고, 값 타입·연속 메모리로 GC 부담도 작고, 동시성으로 검사 자체를 병렬화한다 — 그래서 훨씬 빠르다. “구현 언어를 바꿨더니 빨라졌다”의 실체는 곧 “런타임 JIT+GC+싱글스레드 모델을, AOT 네이티브+동시성 모델로 바꿨다”는 것이라고 본다. (아직 프리뷰 단계라 일부 기능은 진행 중이다.)
7. 타입 소거 — 타입은 실행에 존재하지 않는다
마지막으로, 이 시리즈 전체의 토대가 되는 개념. TypeScript의 타입은 검사할 때만 존재하고, 변환(emit)되면 흔적도 없이 사라진다. 이걸 타입 소거(type erasure) 라고 한다.
let x: number = 5;
interface User { id: number }
// ↓ 변환된 JS
let x = 5; // 타입 표기 전부 제거, interface 는 아예 사라짐
타입 주석·interface·type·제네릭 <T> — 전부 지워진다. (예외: class·enum처럼 값이기도 한 것은 런타임에 남는다. 순수 “타입 표기”만 사라진다는 뜻이다.)
여기서 두 가지 결론이 나온다.
- 런타임 제로 코스트. TypeScript를 쓰면 소스 코드는 타입 표기만큼 길어진다. 하지만 변환된 JS엔 타입이 하나도 안 남으니, 번들을 키우지도 느리게 하지도 않는다. 그래서 “TypeScript를 쓰면 앱이 느려지나?”라는 질문 자체가 성립하지 않는다 — 돌아가는 건 그냥 JS이고, TS의 비용은 오직 빌드 시점(검사+변환)뿐이다. (“TS 실행시간 vs JS 실행시간” 비교가 애초에 말이 안 되는 이유가 이것이다. TS는 실행되지 않고 JS가 되어 실행된다.)
- 타입 검사는 런타임에 일어나지 않는다. 실행 중엔 타입이 이미 사라지고 없으니, 타입으로 런타임 값을 확인할 수 없다. 서버가 내려준 JSON 같은 외부 데이터는 타입이 지켜주지 못한다 — 그래서 Zod 같은 런타임 검증 라이브러리가 따로 필요하다.
정리 — 그리고 다음 글
이번 글의 뼈대를 한 문장으로 줄이면 이렇다.
TypeScript의 모든 비용과 안전장치는 “빌드 시점”에 있고, “실행 시점”엔 타입이 사라진다.
- “컴파일”은 두 개다: TS→JS(빌드, tsc) / JS→기계어(런타임, 엔진 JIT).
- tsc는 검사와 변환을 하지만 둘은 분리 가능하고, 검사만 떼어낸
tsc --noEmit이 곧 게이트다. - 타입은 소거되어 런타임 비용이 0이며, 그래서 타입 검사는 오직 컴파일 타임의 일이다.
바로 이 “컴파일 타임의 검사”라는 자리에, 번역 키가 실제로 존재하는지를 끼워 넣을 수 있다. 다음 글에서는 번역 키가 왜 그냥 문자열이라 위험한지, 그리고 그걸 이 검사 시점에 잡으려면 어떤 타입 도구들이 필요한지를 다룬다.