TypeScript 14분 읽기

번역 키를 컴파일 타임에 잡기 #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는 “기계어 파일”로 변환되지 않는다. 엔진이 런타임에 메모리에서 기계어를 만들 뿐, 산출 파일이 없다.

두 개의 컴파일 — ① 빌드 시점에 tsc·esbuild가 .ts를 .js 파일로(타입 검사·타입 소거·변환), ② 런타임에 엔진(V8)이 .js를 메모리 속 기계어로(인터프리트+JIT, 파일 아님). TS는 실행되지 않고 항상 JS가 된 뒤 JS로 실행된다

나도 “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는 어떻게 도나 (단순화)

  1. 일단 인터프리터로 시작한다(빠른 시작).
  2. 돌리면서 관찰한다 — “이 함수 자주 불리네”, “이 변수는 항상 숫자네”.
  3. 자주 도는(뜨거운) 코드만 관찰 정보를 근거로 최적화된 기계어로 컴파일한다. (예: “이 변수는 늘 숫자니 숫자 전용 기계어로 짜자.”)
  4. 가정이 깨지면(갑자기 문자열이 들어옴) 역최적화(deopt) 하고 인터프리터로 되돌아간다.

JIT는 곧 “인터프리터의 빠른 시작 + 컴파일러의 빠른 실행”을 노린 하이브리드다. 단, 핵심은 관찰할 시간(워밍업)이 있어야 이득이라는 것 — 이 조건이 다음 이야기의 열쇠다.

JIT 실행 흐름 — ① 인터프리터로 바로 시작, ② 실행하며 관찰(자주 불리나·타입은?), ③ 뜨거운 코드만 관찰 근거로 기계어 JIT 컴파일. 가정이 깨지면 역최적화(deopt)로 인터프리터 복귀. 짧게 돌고 죽는 tsc 1회 실행은 워밍업 전에 끝나 이득을 못 본다

5. tsc — 두 가지 일을 하지만, 반드시 같이 할 필요는 없다

이제 TypeScript 컴파일러 tsc로 돌아오자. tsc는 두 가지 일을 한다.

  1. 타입 검사 — 타입이 맞는지 검증하고 에러를 리포트한다.
  2. 파일 변환(emit) — TS를 JS로 바꾼다. (타입 소거 + 문법 변환)

그리고 이 둘은 반드시 함께 쓸 필요가 없다. 이게 이 시리즈에서 결정적인 사실이다.

  • IDE의 실시간 타입 검사(빨간 줄)는 tsserver가 하는데, 검사는 하지만 파일 변환은 안 한다.
  • tsc --noEmit 옵션을 주면 검사만 하고 JS를 안 만든다.
  • 반대로, 파일 변환은 tsc가 아니어도 된다. esbuild·swc·Babel 같은 도구가 타입을 걷어내고 JS로 바꿔준다 — 대신 이들은 타입 검사를 하지 않는다(그래서 빠르다).

그래서 요즘 프로젝트는 보통 이렇게 역할을 나눈다: “변환은 esbuild가 빠르게, 타입 검사는 tsc --noEmit가 따로(IDE·CI에서).” 즉 “검사기만 떼어 쓰는” 건 특별한 발상이 아니라 이미 널리 자리 잡은 구성이다. (그리고 앞으로 보게 되겠지만, 번역 키를 잡는 게이트도 바로 이 tsc --noEmit의 통과/실패다.)

tsc의 두 기능 분리 — ① 타입 검사(tsc --noEmit, tsserver의 IDE 실시간 빨간 줄)와 ② 파일 변환(esbuild·swc·babel — 타입 검사는 안 함)은 반드시 같이 쓸 필요가 없다. 검사만 떼어낸 tsc --noEmit의 통과/실패가 곧 CI 게이트가 된다

한 가지 주의: 코드를 실제로 실행하려면 결국 변환은 반드시 필요하다. 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처럼 값이기도 한 것은 런타임에 남는다. 순수 “타입 표기”만 사라진다는 뜻이다.)

타입 소거 — 왼쪽 TS 코드의 타입 표기(: number, interface User)가 변환 후 오른쪽 JS에선 전부 사라진다. 결론 둘: (1) 런타임 비용 0 — 번들 안 커지고 돌아가는 건 그냥 JS라 "TS 쓰면 느려지나"가 성립 안 함, (2) 런타임 검증이 아님 — 실행 중엔 타입이 없어 서버 JSON 같은 외부 데이터엔 Zod 같은 검증기가 필요

여기서 두 가지 결론이 나온다.

  • 런타임 제로 코스트. TypeScript를 쓰면 소스 코드는 타입 표기만큼 길어진다. 하지만 변환된 JS엔 타입이 하나도 안 남으니, 번들을 키우지도 느리게 하지도 않는다. 그래서 “TypeScript를 쓰면 앱이 느려지나?”라는 질문 자체가 성립하지 않는다 — 돌아가는 건 그냥 JS이고, TS의 비용은 오직 빌드 시점(검사+변환)뿐이다. (“TS 실행시간 vs JS 실행시간” 비교가 애초에 말이 안 되는 이유가 이것이다. TS는 실행되지 않고 JS가 되어 실행된다.)
  • 타입 검사는 런타임에 일어나지 않는다. 실행 중엔 타입이 이미 사라지고 없으니, 타입으로 런타임 값을 확인할 수 없다. 서버가 내려준 JSON 같은 외부 데이터는 타입이 지켜주지 못한다 — 그래서 Zod 같은 런타임 검증 라이브러리가 따로 필요하다.

정리 — 그리고 다음 글

이번 글의 뼈대를 한 문장으로 줄이면 이렇다.

TypeScript의 모든 비용과 안전장치는 “빌드 시점”에 있고, “실행 시점”엔 타입이 사라진다.

  • “컴파일”은 두 개다: TS→JS(빌드, tsc) / JS→기계어(런타임, 엔진 JIT).
  • tsc는 검사와 변환을 하지만 둘은 분리 가능하고, 검사만 떼어낸 tsc --noEmit이 곧 게이트다.
  • 타입은 소거되어 런타임 비용이 0이며, 그래서 타입 검사는 오직 컴파일 타임의 일이다.

바로 이 “컴파일 타임의 검사”라는 자리에, 번역 키가 실제로 존재하는지를 끼워 넣을 수 있다. 다음 글에서는 번역 키가 왜 그냥 문자열이라 위험한지, 그리고 그걸 이 검사 시점에 잡으려면 어떤 타입 도구들이 필요한지를 다룬다.