본문으로 건너뛰기

타입스크립트의 컴파일 과정

질문

tsc 명령어를 실행했을 때 TypeScript 소스 코드는 내부적으로 어떤 단계를 거쳐 JavaScript로 변환되나요?

답변 초안

TypeScript 컴파일은 한 번에 텍스트를 JavaScript로 바꾸는 단일 작업이 아니라, 여러 단계로 나뉜 파이프라인입니다.

tsc 명령어를 실행하면 프로그램(Program) 객체가 생성되고, 이 객체가 컴파일 전체 과정을 조율합니다.

크게 보면 스캐너 → 파서 → 바인더 → 체커 → 이미터 순서로 진행되며, 각 단계는 앞 단계가 만든 결과물을 입력으로 받아 다음 단계에 넘겨주는 구조입니다.

소스 파일(.ts)
→ [스캐너] 토큰 목록
→ [파서] AST(추상 구문 트리)
→ [바인더] 심볼이 연결된 AST
→ [체커] 타입 검사 완료
→ [이미터] JavaScript 소스 파일(.js)

이 흐름을 이해하면 "왜 타입 에러가 나는데 JS 파일은 생성되는가", "왜 isolatedModules 환경에서는 특정 문법이 제한되는가" 같은 실무 질문에 답할 수 있습니다.

스캐너(Scanner)

스캐너(Lexer라고도 부릅니다)는 소스 파일을 문자 단위로 읽어서 의미를 가진 최소 단위인 토큰(token) 으로 분리하는 역할을 합니다.

공백이나 줄바꿈처럼 의미 없는 문자는 걸러내고, 키워드·식별자·연산자·리터럴처럼 이후 단계에서 다룰 수 있는 단위로 쪼개는 것이 목적입니다.

let age: number = 20;

이 코드는 스캐너를 거치면 대략 다음과 같은 토큰 목록이 됩니다.

[Let] [Identifier: age] [Colon] [Identifier: number] [Equals] [NumericLiteral: 20] [Semicolon]

이 단계에서는 아직 문법적으로 올바른지, 타입이 맞는지는 전혀 신경 쓰지 않습니다.

단순히 문자열을 토큰이라는 단위로 나누는 것이 스캐너의 유일한 역할입니다.

파서와 AST

파서(Parser)는 스캐너가 만든 토큰들을 문법 규칙에 맞게 조합해서 AST(Abstract Syntax Tree, 추상 구문 트리) 를 만듭니다.

토큰은 단순히 나열된 목록일 뿐이지만, AST는 코드의 구조(어떤 선언이 어떤 표현식을 포함하는지)를 트리 형태로 표현합니다.

let age: number = 20;

위 코드는 파서를 거치면 대략 다음과 같은 트리 구조로 표현됩니다.

VariableStatement
└─ VariableDeclaration
├─ name: Identifier "age"
├─ type: NumberKeyword
└─ initializer: NumericLiteral "20"

이 단계에서 문법 오류(예: 괄호를 닫지 않음)가 있으면 파서가 에러를 발생시킵니다.

다만 이 시점까지도 "타입이 맞는지"는 검사하지 않습니다.

AST는 어디까지나 코드의 구조를 표현할 뿐이고, 타입 정보와 연결되는 것은 다음 단계인 바인더부터입니다.

바인더와 심볼

바인더(Binder)는 AST의 각 선언 노드(변수, 함수, 클래스, 인터페이스 등)에 대응하는 심볼(Symbol) 을 생성하고 연결하는 역할을 합니다.

심볼은 "이 이름이 어디서 선언되었고, 어떤 노드와 연결되어 있는가"를 나타내는 정보 단위입니다.

예를 들어 같은 이름의 인터페이스가 여러 파일에서 선언 병합(declaration merging)되는 경우, 바인더는 이 선언들을 하나의 심볼로 묶어서 관리합니다.

interface User {
name: string;
}

interface User {
age: number;
}

바인더는 두 User 선언을 하나의 심볼로 연결하고, 이후 체커는 이 심볼을 통해 Usernameage를 모두 가진 타입이라는 것을 알 수 있습니다.

즉 바인더는 AST 노드와 타입 정보를 이어주는 연결 고리를 만드는 단계입니다.

체커(Checker)

체커(Checker)는 TypeScript 컴파일 과정에서 가장 복잡하고 핵심적인 단계로, AST를 순회하면서 바인더가 만들어 둔 심볼 정보를 활용해 타입 검사를 수행합니다.

변수에 대입되는 값의 타입이 선언된 타입과 호환되는지, 함수 호출 시 인자의 개수와 타입이 맞는지, 존재하지 않는 프로퍼티에 접근하지는 않는지 등을 이 단계에서 판단합니다.

let age: number = 20;

age = 'twenty'; // 체커가 타입 불일치를 에러로 보고한다

체커는 단순히 노드 하나만 보는 것이 아니라, 심볼을 따라가며 선언 위치의 타입 정보까지 함께 참조하기 때문에 타입 추론, 제네릭 추론, 오버로드 해석처럼 복잡한 판단도 이 단계에서 이뤄집니다.

에디터에서 실시간으로 보이는 빨간 밑줄(타입 에러)도 이 체커가 수행한 결과입니다.

이미터(Emitter)

이미터(Emitter)는 체커의 검사 결과를 바탕으로 AST를 실제 JavaScript 소스 코드로 변환하는 단계입니다.

타입 주석(: number, interface 선언 등)은 런타임에 아무 의미가 없으므로 이 단계에서 모두 제거되고, target 설정(예: ES5, ES2020)에 맞춰 문법도 함께 변환됩니다.

// 컴파일 전 (TypeScript)
function greet(name: string): string {
return `Hello, ${name}`;
}
// 컴파일 후 (JavaScript)
function greet(name) {
return `Hello, ${name}`;
}

타입 정보는 오직 컴파일 시점에만 존재하고 이미터 단계에서 결과물에 남지 않기 때문에, 이를 두고 "TypeScript의 타입은 소거(erasure)된다"고 표현합니다.

실무 주의점

  • 기본 설정에서는 타입 에러가 있어도 tsc가 JavaScript 파일을 생성합니다. 컴파일 실패 시 결과물을 만들지 않으려면 noEmitOnError 옵션을 켜야 합니다.
  • 타입 에러는 체커 단계에서 잡히는 컴파일 타임 문제이고, 런타임 에러는 실제 값이 예상과 다를 때 발생하는 실행 시점 문제입니다. as로 타입을 강제하거나 외부 데이터(API 응답, JSON.parse)를 그대로 신뢰하면 체커를 통과해도 런타임에서 터질 수 있습니다.
  • CI 환경에서 타입 검사만 하고 JS 파일은 만들고 싶지 않다면 tsc --noEmit을 사용합니다. 실제 번들링은 Babel, esbuild, SWC 같은 도구가 담당하고, tsc는 타입 검사 전용으로만 쓰는 구성이 흔합니다.
  • Babel, esbuild, SWC처럼 파일 단위로 변환하는 도구(isolatedModules)는 바인더·체커 없이 파일 하나만 보고 변환하기 때문에, 다른 파일의 심볼 정보가 필요한 문법(예: const enum, 타입만 있는 export)을 온전히 처리하지 못할 수 있습니다.
  • 에디터의 타입 에러(빨간 밑줄)는 체커가 만들어낸 결과이지, 코드가 실행되지 않는다는 뜻은 아닙니다. 스캐너와 파서 단계만 통과하면 JavaScript로의 변환 자체는 대부분 가능합니다.

면접 답변

"TypeScript 컴파일은 크게 다섯 단계로 진행됩니다. 먼저 스캐너가 소스 코드를 토큰으로 쪼개고, 파서가 그 토큰들로 AST를 만듭니다. 그다음 바인더가 AST의 선언 노드마다 심볼을 만들어 이름과 타입 정보를 연결하고, 체커가 이 심볼 정보를 활용해서 AST를 순회하며 실제 타입 검사를 수행합니다. 마지막으로 이미터가 타입 정보를 제거하고 AST를 실제 JavaScript 코드로 변환합니다. 이 과정을 알아두면 타입 에러와 런타임 에러가 왜 다른 문제인지, tsc --noEmit이나 isolatedModules 같은 옵션이 왜 필요한지도 자연스럽게 이해할 수 있습니다."