본문으로 건너뛰기

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

질문​

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 선언을 하나의 심볼로 연결하고, 이후 체커는 이 심볼을 통해 User가 name과 age를 모두 가진 타입이라는 것을 알 수 있습니다.

즉 바인더는 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 같은 옵션이 왜 필요한지도 자연스럽게 이해할 수 있습니다."