TypeScript 컴파일러와 타입 시스템: parse·bind·check·emit, 구조적 타이핑, 추론, 실무 패턴
이 글의 핵심
TypeScript는 “타입이 있는 JavaScript”를 넘어, 컴파일러가 소스를 파싱·바인딩·타입 검사·출력까지 어떻게 처리하는지, 구조적 타이핑과 추론이 어떤 규칙으로 동작하는지 이해하면 설계와 디버깅이 한 단계 빨라집니다. 이 글은 내부 관점과 TypeScript 5 기능, 실무에서 통하는 패턴을 한데 묶습니다.
TypeScript의 에러 메시지가 이해되지 않을 때는 대개 컴파일러가 그 코드를 어떤 순서로 처리하고, 타입을 어떤 규칙으로 정하는지를 모르기 때문입니다. 이 글은 tsc와 에디터의 언어 서비스가 공유하는 컴파일 파이프라인, 할당 가능성(assignability)과 좁히기, 구조적 타이핑, 타입 추론 규칙을 차례로 보고, TypeScript 5의 대표 기능(표준 데코레이터, satisfies, const 타입 매개변수)과 프로덕션에서 반복되는 패턴으로 이어 갑니다.
컴파일 파이프라인: parse → bind → check → emit
tsc(또는 에디터의 언어 서비스)는 한 번에 “에러만” 내는 도구가 아니라, 소스 텍스트를 여러 단계로 변형·분석하는 파이프라인입니다. 대표적인 네 단계는 다음과 같습니다.
Parse(구문 분석)
소스 문자열을 구문 트리(AST) 로 바꿉니다. 여기서는 타입 정보가 없으며, 문법이 ECMAScript·TypeScript 문법에 맞는지만 판별합니다. 예를 들어 function f(x: number)에서 x: number는 타입 주석이 AST에 붙은 형태로 남습니다.
Bind(바인딩·심볼 테이블)
AST를 순회하며 선언(declaration) 과 이름을 연결하며, 스코프를 만듭니다. 각 식별자는 심볼(symbol) 에 매달리고, 같은 이름이 여러 스코프에 있을 때 어떤 선언을 가리키는지가 결정됩니다. 이 단계에서 “이 User는 타입인가 값인가?” 같은 이름 해석의 뼈대가 잡힙니다.
Check(타입 검사)
타입 검사기(type checker) 가 AST와 심볼 정보를 사용해 타입을 할당하며, 표현식마다 타입 관계(할당 가능성, 서브타입, 공변·반공변 등)를 검증합니다. 제네릭 추론, 조건부 타입의 계산, .d.ts에 대한 검사도 이 단계에서 이루어집니다. 프로젝트가 커질수록 CPU 시간의 대부분이 여기서 소비되는 경우가 많습니다.
Emit(출력)
자바스크립트로 내보낼 때는 변환기(transformer)와 프린터가 개입합니다. 타입 주석은 지워지고, enum, namespace, import x = require()처럼 런타임 코드가 필요한 TypeScript 전용 문법은 실제 JS로 바뀌며, target보다 새로운 JS 문법은 낮춰집니다. noEmit: true면 이 단계를 건너뛰고 타입 검사만 합니다.
esbuild, SWC, Babel, Node.js의 타입 제거(type stripping)처럼 TypeScript를 “타입만 지우고” 실행하는 도구는 파일 하나씩 변환하므로, 다른 파일의 타입 정보가 있어야 출력이 정해지는 문법(const enum 등)을 제대로 처리하지 못합니다. isolatedModules, 그리고 TypeScript 5.8의 erasableSyntaxOnly 옵션을 켜면 이런 문법을 쓸 때 tsc가 미리 에러를 내 줍니다.
flowchart LR S[소스 텍스트] --> P[Parse → AST] P --> B[Bind → 심볼·스코프] B --> C[Check → 타입 할당·에러] C --> E[Emit → JS 출력]
타입 시스템 아키텍처(개념적 모델)
코드에 적는 타입 표기(string | number, Partial<User>)는 검사기 안에서 타입 객체로 바뀌고, 이후의 모든 비교는 이 객체들 사이에서 일어납니다. 개발자가 알아 두면 좋은 축은 세 가지입니다.
구조와 조합
객체, 함수, 클래스 인스턴스 타입은 대부분 멤버의 구조로 비교됩니다. 유니온, 인터섹션, 조건부 타입, 매핑된 타입은 다른 타입을 조합해 만드는 타입이라, 검사기가 비교할 때마다 펼치고 계산해야 합니다. “타입이 너무 복잡하다”는 느낌이 들 때는, 유니온이 불필요하게 커지지 않았는지, 조건부 타입이 재귀에 빠지지 않았는지부터 점검하는 것이 좋습니다.
할당 가능성과 서브타입
표현식 a를 위치 T에 넣을 수 있는지는 할당 가능성 규칙으로 결정됩니다. strictNullChecks가 켜지면 null/undefined가 대부분의 타입에 자동으로 포함되지 않습니다. strictFunctionTypes가 켜지면 함수 타입의 매개변수를 반공변(contravariant)으로 검사합니다. (x: Dog) => void를 (x: Animal) => void 자리에 넣으면, 그 함수가 Cat을 받을 수도 있으므로 에러가 납니다. 다만 이 규칙은 함수 타입 표기(onClick: (e: Event) => void)에만 적용되고, 메서드 표기(onClick(e: Event): void)는 하위 호환을 위해 여전히 양방향(bivariant)으로 검사됩니다. 같은 콜백인데 선언 방식에 따라 에러가 나고 안 나는 이유가 이것입니다.
제어 흐름 기반 좁히기
if, switch, return, throw, typeof·instanceof·in 검사로 변수가 가질 수 있는 타입이 줄어드는 것을 좁히기(narrowing)라고 합니다. 바인딩 단계에서 코드의 제어 흐름 그래프가 만들어지고, 검사기는 변수가 쓰인 지점에서 이 그래프를 거슬러 올라가며 그 지점에서의 타입을 계산합니다. 그래서 같은 변수라도 위치마다 타입이 다릅니다. 반대로 콜백 안에서는 바깥에서 좁힌 let 변수의 타입이 유지되지 않는데, 콜백이 언제 실행될지 알 수 없어 그 사이에 값이 바뀌었을 수 있기 때문입니다.
구조적 타이핑과 명목적(nominal) 요소
구조적 타이핑이란
이름이 같아도 멤버 구조가 호환되면 할당이 허용되는 방식을 구조적(structural) 타이핑이라 합니다. 예를 들어 { id: number }를 요구하는 위치에 { id: number; name: string }을 넘기는 것은 일반적으로 허용됩니다(과잉 프로퍼티 검사는 별도 규칙).
명목에 가까운 예외
구조적 규칙에도 예외가 있습니다. private이나 protected 멤버가 있는 클래스는 구조가 같아도, 그 멤버가 같은 클래스 선언에서 온 경우에만 호환됩니다. enum도 명목적으로 동작해서, 값이 같아도 서로 다른 enum끼리는 대입할 수 없고 문자열 enum에는 같은 문자열 리터럴도 대입할 수 없습니다. 이런 이유로 문자열 enum 대신 'a' | 'b' 같은 리터럴 유니온을 쓰는 팀이 많습니다.
브랜딩(phantom brand) 패턴
구조적 타이핑만으로는 “같은 원시값이지만 의미가 다른 타입”(예: UserId와 OrderId 둘 다 string)을 구분하기 어렵습니다. 그럴 때 unique symbol이나 교차 타입으로 표식을 붙인 타입을 만들면, 실무에서 명목적 타이핑에 가까운 안전장치를 얻을 수 있습니다.
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: typeof UserIdBrand };
function idOf(s: string): UserId {
return s as UserId;
}
function fetchUser(id: UserId) {
// ...
}
fetchUser(idOf("abc")); // OK
// fetchUser("abc"); // 에러: string에는 브랜드 프로퍼티가 없음
브랜드는 타입에만 존재하고 런타임 값은 그냥 문자열이라 비용이 없습니다. 대신 idOf 같은 생성 함수 안에서 형식을 검증하도록 만들어야 “브랜드가 붙은 값은 검증을 통과한 값”이라는 의미가 생깁니다.
타입 추론 알고리즘(실무 관점)
양방향 검사(bidirectional typing)
타입 정보는 두 방향으로 흐릅니다. 기본은 표현식에서 바깥으로 흐르는 방향입니다. const n = 1 + 2에서는 1 + 2의 타입이 먼저 정해지고 그것이 n의 타입이 됩니다. 반대로 문맥이 있는 위치에서는 바깥의 기대 타입이 안쪽 표현식으로 전달됩니다. 이를 문맥적 타입(contextual typing)이라 하며, arr.map(x => x.id)의 x가 주석 없이도 배열 원소 타입이 되는 것, 타입이 지정된 변수에 객체 리터럴을 넣을 때 프로퍼티가 그 타입에 맞춰 검사되는 것이 모두 이 경우입니다. “왜 여기서는 리터럴 타입이 유지되고 저기서는 string으로 넓혀지나?”는 대개 문맥적 타입이 있느냐로 설명됩니다.
공통 타입(best common type)
배열 리터럴처럼 여러 표현식이 한 타입으로 묶일 때는 원소 타입들의 유니온을 만들고, 변경 가능한 위치이므로 리터럴은 넓혀집니다. [1, 'a']는 (string | number)[]가 되고, [1, 2]는 [1, 2] 튜플이 아니라 number[]가 됩니다. 튜플이나 리터럴 타입을 유지하고 싶다면 as const, satisfies, const 타입 매개변수로 의도를 알려 줘야 합니다.
제네릭 추론
함수 호출 f(value)에서 T를 명시하지 않으면 인수 타입에서 T를 추론합니다. 같은 T에 여러 인수가 후보를 내면 하나로 맞추려 하고, 맞출 수 없으면 에러를 냅니다. function same<T>(a: T, b: T)에 same(1, 'a')를 넘기면 T가 number로 정해진 뒤 'a'가 맞지 않는다는 에러가 나는 식입니다.
TypeScript 5.0의 const 타입 매개변수는 인수를 as const를 붙인 것처럼 가장 좁은 타입으로 추론하라는 표시입니다. 배열을 튜플로 받고 싶다면 제약을 readonly 배열로 둬야 합니다. 변경 가능한 배열 제약(T extends unknown[])에는 읽기 전용 튜플이 맞지 않아 const가 효과를 내지 못하기 때문입니다.
function makeTuple<const T extends readonly unknown[]>(items: T): T {
return items;
}
const t = makeTuple([1, 2, 3]);
// t: readonly [1, 2, 3] (const가 없으면 number[])
과잉 프로퍼티 검사(excess property check)
객체 리터럴을 타입이 정해진 변수에 바로 넣거나 인수로 바로 넘길 때, 타입에 없는 프로퍼티가 있으면 에러가 납니다. 구조적 타이핑에서는 원래 프로퍼티가 더 많아도 할당이 되지만, 방금 쓴 리터럴(fresh object literal)에 없는 프로퍼티가 있다면 오타일 가능성이 높다고 보고 따로 검사하는 규칙입니다. 같은 객체를 먼저 변수에 담았다가 넘기면 더 이상 “방금 쓴 리터럴”이 아니므로 이 검사가 적용되지 않습니다. “왜 여기서만 에러가 나지?” 싶을 때는 리터럴을 바로 넘겼는지, 변수를 거쳤는지를 먼저 확인합니다.
추론 소스(inference sources)와 우선순위(요약)
제네릭 호출에서 T를 채울 때 컴파일러는 인수, 호출 결과가 들어갈 자리의 문맥적 타입, 타입 매개변수의 기본값과 제약을 함께 봅니다. 인수 중에서도 문맥이 필요 없는 인수에서 먼저 후보를 모으고, 매개변수 타입을 문맥에서 받아야 하는 콜백은 그다음에 처리합니다. 그래서 T를 정할 단서가 콜백의 매개변수뿐이라면 추론할 재료가 없어 T가 unknown으로 떨어지는 일이 생깁니다. 추론 결과가 의도와 다르면 명시적 타입 인수, const 타입 매개변수, satisfies로 원하는 쪽을 알려 주는 것이 해법입니다.
TypeScript 5 핵심 기능(실전 요약)
표준 Decorators
TypeScript 5.0은 TC39 제안(Stage 3)을 따르는 표준 데코레이터를 지원합니다. 데코레이터는 대상과 함께 context 객체를 받습니다. 기존 experimentalDecorators 방식과는 인자와 동작이 달라 서로 호환되지 않고, 표준 데코레이터에는 매개변수 데코레이터와 emitDecoratorMetadata가 없습니다. 그래서 생성자 매개변수 데코레이터와 메타데이터에 기대는 NestJS, TypeORM, Angular 같은 프레임워크는 여전히 experimentalDecorators를 씁니다.
function logged<T extends new (...args: any[]) => object>(
value: T,
context: ClassDecoratorContext<T>
) {
const name = String(context.name);
return class extends value {
constructor(...args: any[]) {
super(...args);
console.log(`[${name}] constructed`);
}
};
}
@logged
class Service {
constructor() {}
}
satisfies
타입 검사는 하되, 추론된 구체 타입은 유지합니다. 설정 객체·테마 맵처럼 “정해진 키 집합 안에서 리터럴 타입을 살리고 싶을 때” 특히 유용합니다.
type Color = 'red' | 'green' | [number, number, number];
const palette = {
primary: 'red',
secondary: [0, 255, 0],
} satisfies Record<string, Color>;
palette.primary.toUpperCase(); // primary: "red" (문자열 메서드 사용 가능)
palette.secondary[0]; // secondary: [number, number, number]
const palette: Record<string, Color> = ...로 타입을 지정했다면 palette.primary의 타입은 Color 전체가 되어 toUpperCase()를 바로 호출할 수 없고, 오타가 난 키(palette.primay)도 에러 없이 통과합니다. satisfies는 값이 Record<string, Color>에 맞는지는 검사하면서, 변수의 타입은 실제 값에서 추론한 구체 타입으로 남겨 둡니다.
성능과 moduleResolution: "bundler"
TypeScript 5.0에서 추가된 moduleResolution: "bundler"는 Vite, esbuild 같은 번들러의 해석 방식을 따릅니다. package.json의 exports를 읽으면서도 상대 경로 import에 확장자를 요구하지 않으므로, 번들러가 최종 빌드를 맡고 tsc는 타입 검사만 하는 프로젝트에 맞습니다. Node.js가 직접 실행할 코드라면 "module": "nodenext"와 그에 맞는 해석을 써야 런타임과 어긋나지 않습니다.
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
}
}
프로덕션 TypeScript 패턴
strict 계열 한 번에 켜기
새 프로젝트라면 strict를 켜고 시작합니다. noUncheckedIndexedAccess는 arr[i]와 record[key]의 타입에 undefined를 더해 인덱스 접근 실수를 잡아 주고, exactOptionalPropertyTypes는 선택적 프로퍼티에 명시적으로 undefined를 넣는 것과 프로퍼티가 없는 것을 구분합니다. 둘 다 strict에 포함되지 않으므로 따로 켜야 하며, 기존 코드에 켜면 에러가 많이 나므로 팀이 감당할 수 있는지 보고 결정합니다. 기존 코드에 점진적으로 도입한다면 외부 데이터가 들어오는 경계(API 응답 파싱, 설정 로드)부터 강화하는 편이 효과가 큽니다.
런타임 검증과의 분업
TypeScript 타입은 컴파일 타임에만 존재합니다. 외부 경계(HTTP, DB, 파일)에서는 Zod·Valibot·io-ts 등으로 스키마 검증을 두며, 타입은 z.infer<typeof schema>로 연결하는 패턴이 널리 사용됩니다.
API 경계에서의 Result 패턴
예외 대신 ok/err 형태로 반환하면 호출부가 실패 경로를 명시적으로 처리하기 쉽습니다. 타입만으로는 “반드시 처리”를 강제할 수 없으므로, 린트 규칙이나 팀 규약과 함께 가는 것이 좋습니다.
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
function parseId(s: string): Result<number, 'not-a-number'> {
const n = Number(s);
return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, error: 'not-a-number' };
}
모듈 공개 표면 최소화
package.json의 exports와 types, 내부 경로 별칭을 정리해 공개 API만 .d.ts로 노출되게 하면, 구조적 타이핑으로 인한 의도치 않은 호환을 줄일 수 있습니다.
버전 고정과 대형 저장소
TypeScript는 마이너 버전에서도 타입 검사가 엄격해져 새 에러가 생길 수 있으므로, package.json과 lockfile로 버전을 고정하고 CI에서 tsc --noEmit을 실행해 업그레이드를 별도 작업으로 다룹니다. 번들러가 타입 검사를 하지 않는 구성(Vite, esbuild)에서는 이 CI 단계가 유일한 타입 검사이므로 빠뜨리면 안 됩니다.
저장소가 커지면 프로젝트 레퍼런스(references와 composite)로 패키지를 나눠, 바뀐 프로젝트만 다시 검사하게 합니다. skipLibCheck는 node_modules의 .d.ts 검사를 건너뛰어 시간을 크게 줄이지만, 선언 파일끼리 충돌하는 문제를 놓칠 수 있다는 점을 알고 켭니다.
같이 보면 좋은 글
- TypeScript 고급 시리즈
- JavaScript 동작 원리: 실행 컨텍스트, 프로토타입 체인, 클로저, 이벤트 루프
- Next.js 15 정리: App Router 렌더링 결정, 캐싱 레이어, Server Actions, Turbopack, PPR
자주 묻는 질문 (FAQ)
Q. as와 satisfies 중 무엇을 써야 하나요?
A. as는 검사를 우회하는 단언이고, satisfies는 검사는 하되 추론을 유지합니다. 설정 값·맵 객체에는 satisfies가 더 안전한 경우가 많습니다.
Q. 타입 검사가 느릴 때 무엇부터 줄이나요?
A. 거대한 유니온·깊은 조건부 타입·과도한 재귀 타입, 불필요하게 넓은 include를 점검합니다. skipLibCheck, 레퍼런스 분할, incremental도 함께 검토합니다.
Q. 구조적 타이핑 때문에 생기는 버그를 어떻게 막나요?
A. 브랜드 타입, 작은 래퍼 클래스, 공개 API 축소, 경계에서의 런타임 검증을 조합합니다. 타입만으로 모든 불변을 강제할 수는 없습니다.