들어가며
tsc 한 줄이면 타입스크립트 코드가 자바스크립트로 바뀐다. 그런데 언젠가부터 이 명령어가 점점 느려지기 시작했다. 처음엔 5초, 나중엔 40초 넘게 걸리는 빌드를 보면서 "타입스크립트는 원래 느린 언어인가 보다" 하고 그냥 넘어갔었다.
그러다 CI 파이프라인에서 빌드 시간이 병목이 되면서 제대로 파고들 수밖에 없었다. 알고 보니 문제는 언어 자체가 아니라, 타입 체킹과 코드 변환(트랜스파일)을 하나의 프로세스가 전부 떠맡는 기본 설정 때문이었다. 이 글은 그 과정에서 정리한 TypeScript 빌드 파이프라인의 동작 원리와, 빌드를 빠르게 만드는 방법들을 다룬다.
1. tsc가 하는 두 가지 일 — 타입 체킹과 트랜스파일
tsc를 실행하면 내부적으로는 크게 구분되는 두 가지 작업이 함께 일어난다.
소스 코드(.ts)
↓
[파싱] AST 생성
↓
├─── [타입 체킹] 타입 오류 검사 (Checker)
│
└─── [변환/Emit] 타입 정보를 제거하고 JS 코드 생성 (Emitter)
여기서 핵심을 짚어야 한다. 타입 체킹과 JS 코드 생성은 사실 서로 독립적인 작업이다. 타입이 틀려도 tsc는 (설정에 따라 다르지만 기본적으로는) JS 파일을 만들어낸다. 타입 오류는 "경고"에 가깝고, 실제로 코드를 실행 가능한 형태로 바꾸는 건 별개의 작업이라는 뜻이다.
// 타입 오류가 있는 코드
function add(a: number, b: number): number {
return a + b;
}
add('1', '2'); // 타입 오류: string은 number에 할당할 수 없음
이 코드로 tsc를 돌리면 콘솔에 오류 메시지가 출력되지만, noEmitOnError 옵션을 명시적으로 켜두지 않는 한 add('1', '2')가 포함된 JS 파일이 그대로 생성된다. "빌드가 성공했다"와 "타입 오류가 없다"는 별개의 명제라는 걸, 이 옵션을 모른 채 배포 파이프라인을 짜면 타입 오류가 있는 코드가 그대로 배포되는 사고로 이어질 수 있다.
이 두 작업이 분리되어 있다는 사실은 이후 빌드 속도를 개선하는 전략의 기초가 된다 — 타입 체킹은 느리고 무겁지만, JS로 변환하는 작업(타입 정보를 그냥 지우는 것) 자체는 훨씬 가볍다.
2. 타입 체킹이 느린 이유 — 구조적 타이핑과 전역 추론
2-1. 구조적 타이핑(Structural Typing)의 비용
TypeScript는 이름이 아니라 구조로 타입 호환성을 판단한다. 이게 유연함을 주지만, 동시에 체킹 비용을 키운다.
interface Point {
x: number;
y: number;
}
function printPoint(p: Point) {
console.log(p.x, p.y);
}
// 이름이 Point가 아니어도, 구조만 맞으면 통과된다
printPoint({ x: 1, y: 2, z: 3 }); // 정상 (z는 초과 속성이지만 변수를 거치면 통과)
이름 기반 타이핑(Java의 클래스 상속 체계처럼)이었다면 "이 타입이 저 타입인가"를 판단하는 게 상대적으로 단순하다. 하지만 구조적 타이핑에서는 두 타입이 호환되는지 판단하려면 모든 프로퍼티를 재귀적으로 비교해야 한다. 타입이 복잡해지고 중첩이 깊어질수록, 그리고 제네릭이 얽힐수록 이 비교 비용은 기하급수적으로 늘어난다.
2-2. 타입 추론의 전파
명시적으로 타입을 적지 않은 코드도 TypeScript는 문맥을 따라가며 타입을 추론한다. 문제는 이 추론이 프로젝트 전역에 걸쳐 연쇄적으로 일어난다는 점이다.
// 타입을 명시하지 않아도, TS는 이 함수의 반환 타입을 계속 추론해나간다
function processData(input) {
return input.map((item) => ({
...item,
processed: true,
timestamp: Date.now(),
}));
}
이런 코드가 프로젝트 곳곳에 쌓이면, 타입 체커는 매번 함수 시그니처를 다시 추론해야 하고, 이 함수를 사용하는 다른 곳에서도 추론된 타입을 다시 검증해야 한다. 실무에서 겪었던 경험을 하나 들면, 유틸리티 함수 하나가 제네릭과 조건부 타입을 여러 겹 사용하고 있었는데, 이 함수 하나를 호출하는 곳이 늘어날 때마다 전체 빌드 시간이 눈에 띄게 늘어나는 걸 확인한 적이 있다. tsc --extendedDiagnostics로 확인해보니 이 함수와 관련된 타입 체킹에서만 전체 시간의 상당 부분을 소모하고 있었다. 복잡한 조건부 타입을 단순화하고 일부 구간에 명시적 타입 애노테이션을 추가하는 것만으로 체감 가능한 수준으로 빌드 시간이 줄었다.
3. 증분 빌드(Incremental Build) — 왜 두 번째 빌드부터는 빠른가
3-1. .tsbuildinfo 파일의 역할
tsconfig.json에 incremental: true를 켜면, tsc는 빌드가 끝난 뒤 .tsbuildinfo라는 파일을 생성한다. 이 파일에는 각 소스 파일의 해시값과, 파일 간 의존 관계 그래프가 기록된다.
// tsconfig.json
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./.cache/tsbuildinfo"
}
}
다음 빌드 때 tsc는 이 파일을 읽어서, 이전 빌드 이후 실제로 바뀐 파일과, 그 파일에 의존하는 파일들만 다시 체킹한다. 바뀌지 않은 파일에 대한 타입 체킹 결과는 캐시에서 그대로 재사용한다.
이 원리를 이해하면 "왜 증분 빌드가 가끔 기대만큼 빠르지 않은가"도 설명이 된다. 만약 프로젝트 전체가 의존하는 공통 타입 파일(예: 전역 types.ts)을 자주 수정한다면, 그 파일에 의존하는 모든 파일이 매번 다시 체킹 대상에 포함된다. 자주 바뀌는 파일일수록 의존 그래프의 아래쪽(리프 노드에 가까운 곳)에 두는 게 증분 빌드 효율에 유리하다는 걸 이때 알게 됐다.
3-2. Project References — 모노레포에서의 증분 빌드
여러 패키지로 구성된 모노레포에서는 tsc의 Project References 기능을 쓰면 패키지 단위로 빌드를 분리하고, 바뀌지 않은 패키지는 통째로 건너뛸 수 있다.
// packages/app/tsconfig.json
{
"references": [
{ "path": "../shared" },
{ "path": "../ui-components" }
],
"compilerOptions": {
"composite": true
}
}
composite: true를 설정한 패키지는 각자의 .d.ts 선언 파일과 .tsbuildinfo를 생성하고, 이걸 참조하는 다른 패키지는 원본 소스가 아니라 이미 만들어진 선언 파일을 참조해서 타입 체킹을 한다. tsc --build(줄여서 tsc -b) 명령을 쓰면, 의존 그래프를 따라 변경된 패키지와 그 패키지에 의존하는 패키지만 순서대로 다시 빌드한다.
모노레포로 전환하며 이 설정을 도입했을 때, 패키지 하나만 수정했는데도 전체 레포를 다시 빌드하던 기존 CI 스크립트를 Project References 기반으로 바꾸면서 CI 빌드 시간이 확연히 줄어드는 걸 경험했다. 다만 초기 설정 과정에서 각 패키지의 tsconfig.json을 전부 composite 조건에 맞게 손봐야 했는데(예: 모든 참조 대상 패키지가 declaration: true를 필요로 함), 기존에 느슨하게 관리되던 tsconfig 구조를 이 기회에 전면 정리해야 했다.
4. 실무에서 tsc를 직접 트랜스파일러로 쓰지 않는 이유
여기서 흥미로운 지점이 있다. 실무 프로젝트 대부분은 프로덕션 빌드 시 실제 JS 변환을 tsc가 아니라 Babel, SWC, esbuild 같은 별도 도구에 맡긴다. 왜일까.
4-1. tsc의 트랜스파일은 "파일 단위"가 아니라 "프로그램 단위"
tsc는 타입 체킹을 위해 전체 프로젝트의 타입 그래프를 구성해야 하는 구조적 특성상, 트랜스파일 역시 전체 프로그램을 인지한 상태에서 처리한다. 반면 Babel이나 esbuild는 파일 하나를 다른 파일과 무관하게 독립적으로 변환할 수 있다. 이 차이가 병렬화 가능성과 속도에 큰 영향을 준다 — 파일 단위 변환은 여러 파일을 동시에, 서로 다른 스레드/프로세스에서 처리할 수 있지만, 프로그램 단위 처리는 그러기 어렵다.
4-2. isolatedModules — 파일 단위 변환의 전제 조건
Babel이나 esbuild처럼 파일을 독립적으로 변환하는 도구를 쓰려면, 각 파일이 다른 파일의 타입 정보 없이도 올바르게 JS로 변환될 수 있어야 한다. 이 조건을 강제하는 게 isolatedModules 옵션이다.
// isolatedModules가 켜져 있으면 오류가 나는 패턴
export { SomeType } from './types'; // SomeType이 타입인지 값인지, 이 파일만 봐서는 알 수 없음
// 명시적으로 타입 전용 re-export임을 표시해야 함
export type { SomeType } from './types';
tsc는 프로젝트 전체를 보고 있으니 SomeType이 인터페이스인지 값인지 알 수 있지만, 파일 하나만 보고 변환하는 도구는 이걸 알 방법이 없다. 그래서 타입만 다시 내보내는 경우 export type으로 명시하지 않으면, 파일 단위 변환기는 이걸 실제 값으로 오해해서 존재하지도 않는 걸 import하는 코드를 생성해버릴 수 있다. 이 옵션을 켜두면 이런 위험한 패턴을 컴파일 시점에 미리 걸러준다.
4-3. 그래서 실무에서는 이렇게 나눈다
- 타입 체킹: tsc --noEmit을 별도로, 보통 CI의 독립적인 스텝이나 IDE의 실시간 체킹으로 담당
- 실제 JS 변환: esbuild나 SWC 같은 빠른 파일 단위 트랜스파일러가 담당 (타입은 그냥 벗겨내기만 함)
이렇게 역할을 분리하면, "타입이 틀린 코드가 배포되지 않게 막는 것"(타입 체킹)과 "빌드 속도를 빠르게 유지하는 것"(트랜스파일)을 각각 가장 잘하는 도구에게 맡길 수 있다. tsc 하나로 두 가지를 다 하려고 하면, 결국 가장 느린 작업(타입 체킹)의 속도에 전체 빌드가 발목 잡히게 된다.
5. 선언 파일(.d.ts) 생성 — 라이브러리를 배포할 때 놓치기 쉬운 지점
패키지를 만들어 배포하는 입장이라면 declaration: true로 .d.ts 파일을 함께 생성해야, 그 패키지를 사용하는 쪽에서 타입 자동완성과 체킹 혜택을 받을 수 있다.
{
"compilerOptions": {
"declaration": true,
"declarationMap": true, // .d.ts에서 원본 .ts 소스로 점프 가능하게 해주는 소스맵
"outDir": "./dist"
}
}
여기서 esbuild나 SWC로 JS 변환을 담당하게 했다면, 이 도구들은 .d.ts 파일을 생성하지 않는다는 걸 놓치기 쉽다. 타입 정보를 지우고 JS로 바꾸는 것까지가 이들의 역할이고, 타입 선언 파일을 만드는 건 결국 타입 정보를 온전히 이해하고 있는 tsc(혹은 tsc의 API를 활용하는 별도 도구, 예를 들어 dts-bundle-generator 같은)만 할 수 있는 일이다.
실제로 사이드 프로젝트로 만든 유틸리티 패키지를 배포했을 때, esbuild로만 빌드 파이프라인을 구성했다가 패키지를 설치한 쪽에서 타입 자동완성이 전혀 동작하지 않는다는 이슈를 받은 적이 있다. tsc --emitDeclarationOnly를 빌드 스크립트에 별도 스텝으로 추가하고 나서야 해결됐다. "JS 변환은 esbuild가 빠르게, 타입 체킹과 선언 파일은 tsc가" 이 역할 분담을 명확히 인지하고 있지 않으면 흔히 겪는 실수다.
6. 정리 — 빌드 파이프라인을 설계할 때의 원칙
지금까지 다룬 내용을 실무 설정으로 정리하면 대략 이런 그림이 된다.
[개발 중]
IDE의 tsserver가 실시간으로 타입 체킹 (증분 방식)
[로컬 빌드 / CI]
1. tsc --noEmit → 타입 체킹만, JS 생성 없음
2. esbuild/SWC로 트랜스파일 → 빠른 JS 변환, 병렬 처리
3. tsc --emitDeclarationOnly → .d.ts 파일만 별도 생성 (라이브러리 배포 시)
세 스텝을 병렬로 돌릴 수 있는 부분은 병렬로 돌리고(타입 체킹과 트랜스파일은 서로 독립적이므로 동시 실행 가능), 모노레포라면 Project References로 변경된 패키지만 다시 처리하도록 구성하는 게 지금까지 정리한 원리를 실무에 그대로 반영한 형태다.
마치며
tsc가 느리다고 느껴질 때, 처음엔 "타입스크립트라서 어쩔 수 없다"고 생각했다. 하지만 실제로는 타입 체킹이라는 무거운 작업과 코드 변환이라는 가벼운 작업을 분리하지 않고 한 프로세스에 몰아넣은 설정이 문제였던 경우가 대부분이었다. 두 작업의 성격이 다르다는 걸 이해하고 나니, 어디를 병렬화하고 어디를 캐싱해야 할지가 명확해졌다.
빌드 도구는 결국 "무엇을, 언제, 어떤 단위로 처리하는가"의 문제로 귀결된다는 걸 이번에도 다시 확인했다. TypeScript 빌드도 예외는 아니었다.
참고하면 좋을 자료
- TypeScript 공식 핸드북의 Project References 문서
- TypeScript 공식 문서의 isolatedModules 옵션 설명
- TypeScript 공식 위키의 Performance 가이드 — extendedDiagnostics 등 진단 옵션 활용법
'Frontend' 카테고리의 다른 글
| [Frontend] React key, 그리고 인덱스를 key로 쓰면 생기는 일 (0) | 2026.07.09 |
|---|---|
| [Frontend] LESS 관리하기 (0) | 2026.07.09 |
| [Frontend] Promis.race와 Promise.allSettled 구현하기 (0) | 2026.07.09 |
| [Frontend] React 19, 실제 프로젝트에 적용해본 후기 (0) | 2026.07.09 |
| [Frontend]브라우저는 화면을 어떻게 그리는가 (0) | 2026.07.08 |