TypeScript 컴파일 원리
분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-04-09
TypeScript 컴파일 원리
섹션 제목: “TypeScript 컴파일 원리”1. 왜 배우는가 (플랫폼 엔지니어 관점)
섹션 제목: “1. 왜 배우는가 (플랫폼 엔지니어 관점)”NestJS를 쓰다 보면 이상한 경험을 하게 된다. constructor(private readonly userService: UserService)라고 선언만 했는데 NestJS가 알아서 UserService 인스턴스를 주입해준다. 타입 정보는 JavaScript 런타임에 사라지는 것 아닌가? 어떻게 프레임워크가 타입을 읽어서 의존성을 주입할 수 있는가?
답은 TypeScript 컴파일 옵션에 있다. emitDecoratorMetadata: true를 설정하면 컴파일러가 타입 정보를 런타임 메타데이터로 변환해 JS 코드에 심어준다. 이 메커니즘을 모르면:
- NestJS DI 에러가 왜 나는지 이해할 수 없다
nestjs-discovery-module의@SetMetadata,Reflector동작이 마법처럼 보인다tsconfig.json옵션을 맹목적으로 복붙하게 된다
이 문서는 TypeScript 컴파일의 전체 흐름과, 특히 emitDecoratorMetadata가 어떻게 NestJS DI의 기반이 되는지를 코드 레벨에서 설명한다.
2. 핵심 개념 한 줄 요약
섹션 제목: “2. 핵심 개념 한 줄 요약”| 개념 | 한 줄 요약 |
|---|---|
| 타입 소거 (Type Erasure) | TypeScript 타입 정보는 컴파일 시 전부 제거되어 런타임 JS에는 존재하지 않는다 |
| emitDecoratorMetadata | 데코레이터가 붙은 클래스의 타입 정보를 런타임 메타데이터로 변환해 JS에 심는 컴파일 옵션 |
| Reflect.metadata | 메타데이터를 키-값으로 저장/조회하는 런타임 API (reflect-metadata 폴리필로 제공) |
| design:paramtypes | 생성자 파라미터의 타입 배열을 담는 메타데이터 키. NestJS DI의 핵심 |
| AST (Abstract Syntax Tree) | 소스코드를 트리 자료구조로 파싱한 표현. tsc가 타입 검사와 코드 생성에 사용 |
3. 동작 원리
섹션 제목: “3. 동작 원리”3-1. 컴파일 단계
섹션 제목: “3-1. 컴파일 단계”TypeScript 컴파일은 단순히 “타입 제거 + ES5 변환”이 아니다. 다음 5단계를 거친다.
소스 파일 (.ts) │ ▼ [1] 스캐너 (Scanner) 토큰 스트림 (class, {, name, :, string, }, ...) │ ▼ [2] 파서 (Parser) AST (Abstract Syntax Tree) │ ▼ [3] 바인더 (Binder) 심볼 테이블 (각 식별자에 선언 정보 연결) │ ▼ [4] 타입 검사기 (Type Checker) 타입 오류 검출 → 에러 리포트 │ ▼ [5] 에미터 (Emitter) JS 출력 파일 (.js) + 타입 선언 파일 (.d.ts)각 단계의 역할:
- 스캐너: 문자열을
class,{,name,:,string같은 토큰으로 분해한다 - 파서: 토큰 스트림을 문법 규칙에 따라 AST로 조립한다
- 바인더: AST를 순회하며 각 심볼(변수, 함수, 클래스)의 선언 위치를 기록한다
- 타입 검사기: 심볼 테이블과 AST를 이용해 타입 호환성을 검증한다. “string을 number에 할당할 수 없다” 같은 에러가 여기서 나온다
- 에미터: AST를 JavaScript 코드로 변환한다.
emitDecoratorMetadata가 활성화되어 있으면 이 단계에서 메타데이터 코드를 추가로 생성한다
포인트: 타입 검사와 코드 생성은 분리되어 있다.
tsc --noEmit은 타입 검사만 하고 파일을 생성하지 않는다. 반대로babel은 타입 검사를 건너뛰고 에미터 역할만 한다.
3-2. 타입 소거 (Type Erasure)
섹션 제목: “3-2. 타입 소거 (Type Erasure)”TypeScript의 가장 중요한 설계 원칙 중 하나: 타입은 컴파일 결과물에 존재하지 않는다.
TypeScript 입력:
interface User { id: number; name: string;}
type Status = "active" | "inactive";
function greet(user: User): string { return `Hello, ${user.name}`;}
const status: Status = "active";tsc 컴파일 출력 (JavaScript):
function greet(user) { return `Hello, ${user.name}`;}
const status = "active";interface User, type Status, 파라미터 타입 : User, 반환 타입 : string이 모두 사라졌다. 런타임 JavaScript에는 이 타입 정보가 없다.
왜 타입이 소거되는가?
JavaScript 엔진(V8, SpiderMonkey)은 TypeScript를 모른다. JavaScript 스펙에 타입 정보를 저장하는 메커니즘이 없다. TypeScript는 “JavaScript에 타입을 추가한 언어”가 아니라 “타입 검사 후 순수 JavaScript를 생성하는 도구”다.
실무 함정:
// ❌ 런타임에 동작하지 않는 코드function processInput(value: string | number) { if (value instanceof string) { // 에러! string은 타입, 런타임에 존재하지 않음 return value.toUpperCase(); }}
// ✅ 올바른 런타임 타입 검사function processInput(value: string | number) { if (typeof value === "string") { // typeof는 JS 연산자, 런타임에 작동 return value.toUpperCase(); }}// ❌ interface는 런타임 검사에 사용할 수 없다interface Animal { name: string;}
function isAnimal(obj: unknown): obj is Animal { return obj instanceof Animal; // 컴파일 에러: 'Animal' only refers to a type}
// ✅ class는 런타임에도 존재한다class Animal { constructor(public name: string) {}}
function isAnimal(obj: unknown): obj is Animal { return obj instanceof Animal; // 정상 동작}런타임에 살아남는 구문 vs 소거되는 구문:
TypeScript 구문 중 일부는 타입 소거 원칙의 예외다. 컴파일 후 JS 코드가 생성되는 구문이 있고, 완전히 사라지는 구문이 있다.
| 구문 | 런타임 존재 | 생성 형태 | 오용 패턴 |
|---|---|---|---|
interface, type | ❌ 소거 | 없음 | instanceof Interface → 컴파일 에러 |
타입 어노테이션 (: string) | ❌ 소거 | 없음 | typeof param === "string" 대신 타입만 믿는 코드 |
enum | ✅ 생존 | IIFE로 JS 객체 생성 | 트리셰이킹 불가 — 번들에 항상 포함 |
const enum | ❌ 인라인 | 참조 위치에 값 직접 치환 | isolatedModules 환경에서 외부 const enum 참조 시 TS2748 에러 |
namespace (값 포함) | ✅ 생존 | IIFE로 JS 객체 생성 | 모던 코드에서 namespace를 타입 컨테이너로만 오해하고 런타임 의존 |
namespace (타입만) | ❌ 소거 | 없음 | — |
class | ✅ 생존 | JS class 선언 (또는 함수) | 정상 — instanceof 사용 가능 |
| 클래스 파라미터 프로퍼티 | ✅ 생존 | 생성자 내 this.x = x 코드 | erasableSyntaxOnly 환경(Node type-stripping)에서 지원 안 됨 |
TS 5.8
--erasableSyntaxOnly플래그: 활성화하면enum, 값을 포함한namespace, 클래스 파라미터 프로퍼티처럼 “코드 변환이 필요한” 구문을 컴파일 에러로 차단한다. Node.js 22.6+의 내장 타입 스트리핑(타입 제거만 지원, 코드 변환은 미지원)과 함께 사용할 때 필수 설정이다.출처: TypeScript 5.8 —erasableSyntaxOnly — Total TypeScript, TypeScript TSConfig: isolatedModules
3-3. emitDecoratorMetadata
섹션 제목: “3-3. emitDecoratorMetadata”타입 소거가 원칙이라면, NestJS DI는 어떻게 타입 정보를 런타임에 읽는가? 답은 컴파일러가 타입 정보를 메타데이터 형태로 런타임 코드에 삽입해주는 것이다.
tsconfig.json 설정:
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}experimentalDecorators:@Injectable()같은 데코레이터 문법을 활성화한다emitDecoratorMetadata: 데코레이터가 붙은 클래스/메서드의 타입 정보를 런타임 메타데이터로 내보낸다
두 옵션은 함께 사용해야 한다. emitDecoratorMetadata는 experimentalDecorators가 켜져 있을 때만 의미가 있다.
메타데이터 키 3종:
| 키 | 의미 | 예시 |
|---|---|---|
design:type | 프로퍼티 또는 파라미터의 타입 | String, Number, UserService |
design:paramtypes | 생성자/메서드의 파라미터 타입 배열 | [UserService, LogService] |
design:returntype | 메서드의 반환 타입 | Promise, String |
Reflect.getMetadata 동작 원리:
import "reflect-metadata";
function Injectable() { return function (target: any) { // 데코레이터 적용 시점에 메타데이터 읽기 const paramTypes = Reflect.getMetadata("design:paramtypes", target); console.log(paramTypes); // [UserRepository, LogService] };}
@Injectable()class UserService { constructor( private userRepo: UserRepository, private logger: LogService, ) {}}Reflect.getMetadata는 reflect-metadata 폴리필이 Reflect 전역 객체에 추가하는 메서드다. 키-값 저장소처럼 동작하며, 메타데이터를 클래스/프로퍼티에 연결해 보관한다.
NestJS DI가 이를 어떻게 활용하는가:
앱 시작 │ ▼NestJS IoC Container 초기화 │ ▼@Module() 스캔 → providers 목록 수집 │ ▼각 provider 클래스에 대해: Reflect.getMetadata('design:paramtypes', FooService) → [BarService, LogService] │ ▼BarService, LogService를 먼저 인스턴스화 │ ▼FooService 생성자에 주입: new FooService(barServiceInstance, logServiceInstance)3-4. 데코레이터 변환
섹션 제목: “3-4. 데코레이터 변환”@Injectable() 데코레이터가 실제로 어떤 JavaScript 코드로 변환되는지 살펴본다.
TypeScript 입력:
import { Injectable } from "@nestjs/common";
@Injectable()export class UserService { constructor( private readonly userRepository: UserRepository, private readonly logService: LogService, ) {}}tsc 컴파일 출력 (emitDecoratorMetadata: true):
"use strict";var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? (desc = Object.getOwnPropertyDescriptor(target, key)) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if ((d = decorators[i])) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return (c > 3 && r && Object.defineProperty(target, key, r), r); };var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); };
let UserService = class UserService { constructor(userRepository, logService) { this.userRepository = userRepository; this.logService = logService; }};UserService = __decorate( [ Injectable(), __metadata("design:paramtypes", [UserRepository, LogService]), // ← 핵심! ], UserService,);핵심 포인트:
constructor(private readonly userRepository: UserRepository)→constructor(userRepository): 타입 어노테이션과 접근 제어자가 제거됨__metadata("design:paramtypes", [UserRepository, LogService]): 컴파일러가 자동으로 삽입. 원본 TypeScript의 타입 정보(UserRepository,LogService)를 클래스 참조로 저장__decorate([ ... ], UserService): 데코레이터를 클래스에 적용하는 헬퍼.Injectable()이 이 시점에 실행됨
target: ES2021 이상에서의 네이티브 데코레이터 (Stage 3):
TypeScript 5.0부터 TC39 표준 데코레이터(experimentalDecorators: false)를 지원한다. 단, 표준 데코레이터는 emitDecoratorMetadata를 지원하지 않는다. TC39 Stage 3 데코레이터는 design:paramtypes 같은 컴파일러 삽입 메타데이터 메커니즘을 의도적으로 제외했다.
// NestJS 프로젝트 — 반드시 이 설정 유지{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}TC39 표준 데코레이터 전환 로드맵 (2025~):
| 단계 | 설명 | NestJS 기준 |
|---|---|---|
| 현재 (NestJS ~v10) | experimentalDecorators: true + emitDecoratorMetadata: true 필수 | 기존 레거시 모드 |
| NestJS 11+ (opt-in) | 표준 데코레이터 모드 선택 가능. reflect-metadata 없이 동작 | experimentalDecorators 불필요 |
| 마이그레이션 제약 | experimentalDecorators 옵션은 파일 단위가 아닌 tsconfig 단위 적용 — 하나의 tsconfig 범위 내에서 두 시스템을 혼용 불가 | 점진적 마이그레이션 시 tsconfig 분리 필요 |
레거시 → 표준 마이그레이션 시 핵심 변경점:
@Injectable()같은 클래스 데코레이터는 API가 유사하지만, 메타데이터 주입 방식이reflect-metadata→ ECMAScriptSymbol/DecoratorContext기반으로 바뀐다- TypeORM, TypeGraphQL 등
emitDecoratorMetadata의존 라이브러리는 별도 마이그레이션 필요 - 표준 데코레이터에서 의존성 메타데이터는
@Inject(Token)형식 명시 토큰으로 대체
Before / After — 클래스 데코레이터 시그니처 비교:
// ─── BEFORE: 레거시 데코레이터 (experimentalDecorators: true) ───function Log(target: Function) { // target = 클래스 생성자 자체 console.log("decorated:", target.name);}
@Logclass OrderService {}
// ─── AFTER: TC39 Stage 3 데코레이터 (experimentalDecorators: false) ───function Log(target: typeof OrderService, context: ClassDecoratorContext) { // context에 name, kind, addInitializer 등 표준 메타정보 포함 console.log("decorated:", context.name);}
@Logclass OrderService {}핵심 차이: 레거시는 target(클래스 함수) 하나만 받지만, 표준은 context: ClassDecoratorContext 객체를 두 번째 인자로 받는다. 함수 시그니처가 달라 두 시스템의 데코레이터 구현체는 상호 호환되지 않는다. 하나의 tsconfig 범위에서 두 시스템을 혼용하면 컴파일 에러가 발생하므로 tsconfig를 분리(legacy/modern 디렉터리)해야 한다.
출처: leapcell.io — How Stage 3 Decorators Will Revolutionize NestJS, tc39/proposal-decorators, TypeScript 5.x Decorators Migration Guide — DEV Community
3-5. 전이 모델: 컴파일타임 → 런타임 메타데이터 보존
섹션 제목: “3-5. 전이 모델: 컴파일타임 → 런타임 메타데이터 보존”TypeScript의 emitDecoratorMetadata는 “컴파일타임 타입 정보를 런타임에 보존”하는 일반 원리의 한 구현이다. 이 패턴은 여러 언어/플랫폼에서 반복된다.
| 언어/도구 | 메커니즘 | 보존 방식 | 활용 예시 |
|---|---|---|---|
| TypeScript | emitDecoratorMetadata | 컴파일러가 __metadata() 호출 코드를 JS에 삽입 | NestJS DI, design:paramtypes |
| Java | @Retention(RUNTIME) + Reflection | 어노테이션을 .class 바이트코드에 기록, JVM이 런타임에 유지 | Spring DI (@Autowired), JPA (@Entity) |
| Python | __annotations__ dict | 인터프리터가 임포트 시점에 타입 힌트를 딕셔너리로 저장 | Pydantic 모델 검증, FastAPI 파라미터 파싱 |
| Go | go generate + 코드 생성 | 빌드 전 도구가 소스를 분석해 별도 .go 파일 생성 | Wire (DI), protobuf 코드 생성 |
Java — RetentionPolicy 3단계:
Java 어노테이션은 RetentionPolicy로 메타데이터 수명을 명시적으로 제어한다.
SOURCE: 컴파일러가 버린다 (Lombok@Getter등). TypeScript의 일반 타입 소거에 해당.CLASS:.class파일에 기록되지만 JVM이 런타임에 로드하지 않는다 (기본값).RUNTIME: JVM이 런타임에 유지하며ReflectionAPI로 읽을 수 있다. Spring@Autowired가 이 정책을 사용한다.
TypeScript의 emitDecoratorMetadata는 Java의 RUNTIME 보존에 대응한다. 차이점은 Java는 언어 레벨에서 보존 정책을 선택할 수 있지만, TypeScript는 데코레이터가 붙은 클래스에 한해서만 암묵적으로 보존한다는 것이다.
Python — __annotations__:
Python은 타입 힌트를 런타임 딕셔너리(__annotations__)로 유지한다. typing.get_type_hints() 또는 inspect.get_annotations()로 런타임에 읽을 수 있다.
# Python: 런타임에 타입 정보 접근from typing import get_type_hints
class UserService: repo: "UserRepository" cache: "CacheService"
hints = get_type_hints(UserService)# {'repo': <class 'UserRepository'>, 'cache': <class 'CacheService'>}Pydantic과 FastAPI는 이 메커니즘으로 런타임 검증과 자동 문서화를 수행한다. TypeScript의 Reflect.getMetadata('design:paramtypes', ...) → NestJS DI와 같은 패턴이다.
전이 모델 검증 미니 실습 — “언어가 달라도 원리는 같다”:
아래 두 코드는 각각 TypeScript와 Python에서 “생성자 파라미터 타입을 런타임에 읽는다”는 동일한 목표를 달성한다. 직접 실행해 출력을 비교하면 보존 원리가 언어 무관임을 체감할 수 있다.
// TypeScript: Reflect.getMetadata로 파라미터 타입 읽기// 실행: npm install reflect-metadata && npx tsc && node dist/app.jsimport "reflect-metadata";
function Injectable(): ClassDecorator { return (target) => {};}
class UserRepository {}class CacheService {}
@Injectable()class UserService { constructor( private repo: UserRepository, private cache: CacheService, ) {}}
// tsconfig: experimentalDecorators: true, emitDecoratorMetadata: true 필요const types = Reflect.getMetadata("design:paramtypes", UserService);console.log(types.map((t: any) => t.name));// 출력: [ 'UserRepository', 'CacheService' ]# Python: get_type_hints로 파라미터 타입 읽기# 실행: python3 app.pyfrom typing import get_type_hints
class UserRepository: passclass CacheService: pass
class UserService: repo: UserRepository cache: CacheService
hints = get_type_hints(UserService)print(list(hints.values()))# 출력: [<class 'UserRepository'>, <class 'CacheService'>]확인 포인트: 두 언어 모두 클래스 정의 시점에 타입 정보를 별도 저장소(TypeScript: WeakMap 기반 Reflect 메타데이터 / Python: __annotations__ dict)에 보존하고, 런타임에 읽는다. 보존 메커니즘이 다를 뿐 “컴파일타임 정보 → 런타임 보존 → 프레임워크가 활용” 흐름은 동일하다.
전이 사고 모델: “컴파일타임 정보를 런타임에 쓰려면 별도의 보존 메커니즘이 필요하다”는 원리는 언어에 무관하다. 새로운 언어나 프레임워크를 만났을 때, (1) 타입/메타정보가 런타임에 존재하는가? (2) 어떤 메커니즘으로 보존되는가? 를 질문하면 DI, ORM, 직렬화 프레임워크의 동작 원리를 빠르게 파악할 수 있다.
4. 프론트엔드 → 플랫폼 브릿지
섹션 제목: “4. 프론트엔드 → 플랫폼 브릿지”React PropTypes vs TypeScript 타입
섹션 제목: “React PropTypes vs TypeScript 타입”프론트엔드 개발을 하다 보면 타입 관련 기능을 두 가지 맥락에서 쓴다.
| 구분 | 목적 | 존재 시점 | 예시 |
|---|---|---|---|
| TypeScript 타입 | 컴파일 타임 안전성 | 컴파일 시에만 | props: { name: string } |
| React PropTypes | 런타임 경고 | 런타임에 존재 | Component.propTypes = { name: PropTypes.string } |
TypeScript 타입은 런타임에 사라진다. React PropTypes는 런타임 객체로 존재해 개발 모드에서 실제로 검사가 수행된다. 둘은 목적과 동작 시점이 다르다.
NestJS의 emitDecoratorMetadata는 이 사이의 다리 역할을 한다. TypeScript 타입 정보 중 일부(데코레이터가 붙은 클래스의 파라미터 타입)를 런타임 메타데이터로 변환해 준다.
TypeScript 타입 (컴파일 타임) ↓ emitDecoratorMetadata 활성화 시런타임 메타데이터 (design:paramtypes) ↓NestJS DI가 런타임에 읽어 의존성 주입Babel vs tsc vs SWC
섹션 제목: “Babel vs tsc vs SWC”백엔드 프로젝트를 다루다 보면 빌드 도구 선택이 중요해진다.
| 구분 | tsc (TypeScript Compiler) | Babel + @babel/preset-typescript | SWC (@swc/core) |
|---|---|---|---|
| 역할 | 타입 검사 + 코드 변환 | 코드 변환만 (타입 무시) | 코드 변환만 (타입 무시) |
| 속도 | 상대적으로 느림 (타입 검사 포함) | 빠름 (타입 스트리핑만) | 매우 빠름 (Rust, tsc 대비 ~20x) |
| 타입 에러 | 빌드 실패 가능 | 무시하고 통과 | 무시하고 통과 |
| emitDecoratorMetadata | 지원 | 기본 지원 안 함 | 지원 (설정 필요) |
| NestJS와 사용 | 공식 권장 | 별도 플러그인 필요 | NestJS 10+ 공식 지원 |
Babel이 emitDecoratorMetadata를 지원하지 않는 이유:
Babel은 파일을 한 번에 하나씩, 타입 정보 없이 변환한다. design:paramtypes를 생성하려면 UserRepository 타입이 실제 어느 클래스인지 알아야 하는데, 이는 크로스-파일 타입 분석이 필요하다. Babel의 단일 파일 처리 방식으로는 불가능하다.
tsc / Babel / SWC 선택 Decision Matrix
섹션 제목: “tsc / Babel / SWC 선택 Decision Matrix”| 판단 기준 | tsc | Babel | SWC |
|---|---|---|---|
| NestJS 프로젝트 | ✅ 기본 선택 | ❌ 메타데이터 미지원 | ✅ nest start --builder swc |
| CI 빌드 시간이 병목 | ⚠️ 타입 검사 분리 필요 (tsc --noEmit + SWC) | ⚠️ 메타데이터 제한 | ✅ 최적 (20x 빠름) |
| 모노레포 (100k+ LoC) | ⚠️ 프로젝트 참조로 증분 빌드 | ⚠️ 단일 파일 한계 | ✅ 병렬 처리 + 빠른 빌드 |
| 프론트엔드 (React/Next.js) | ⚠️ 느림 | ✅ 생태계 플러그인 풍부 | ✅ Next.js 기본 컴파일러 |
| 타입 검사 필요 | ✅ 내장 | ❌ 별도 tsc --noEmit | ❌ 별도 tsc --noEmit |
.d.ts 생성 | ✅ 내장 | ❌ 별도 tsc --emitDeclarationOnly | ❌ 별도 tsc 필요 |
실무 권장 패턴:
- NestJS 소규모 프로젝트:
tsc만으로 충분. 빌드 시간이 문제 되지 않는다. - NestJS 대규모/모노레포: SWC로 트랜스파일 +
tsc --noEmit으로 타입 검사 분리. NestJS CLI는nest start --builder swc를 지원한다. - 프론트엔드 프로젝트: Next.js는 SWC 내장, Vite는 esbuild 사용. 데코레이터 메타데이터가 불필요하므로 빌드 속도 중심으로 선택.
벤치마크 측정 조건 명시: SWC 공식 벤치마크(swc.rs/docs/benchmarks)는 단일 파일 트랜스파일 속도를 측정하며, tsc 대비 약 20x를 보고한다. 이 수치의 전제 조건:
- 측정 대상: 트랜스파일(타입 소거 + JS 변환)만. 타입 검사(type-check) 시간은 포함하지 않는다.
- 빌드 유형: 콜드 빌드(캐시 없음). 인크리멘탈 빌드(변경 파일만)에서는 tsc watch 모드와의 차이가 줄어든다.
- 파일 규모: 파일 수·총 라인 수에 따라 배율이 달라진다. 소규모 프로젝트(10k LoC 미만)에서는 체감 차이가 작을 수 있다.
- esbuild 45x 수치는 별도 멀티프로세스 벤치마크(datastation.multiprocess.io, 2021)에서 측정된 값으로, 측정 방법론이 다르다.
실무 판단 기준: CI 콜드 빌드 시간이 병목이면 SWC 전환이 효과적. 로컬 개발 핫리로드에서는 둘 다 충분히 빠를 수 있다. (참고: swc.rs benchmarks, datastation 벤치마크)
5. 실무 적용
섹션 제목: “5. 실무 적용”tsconfig.json 핵심 옵션
섹션 제목: “tsconfig.json 핵심 옵션”NestJS 프로젝트의 기본 tsconfig.json을 한 줄씩 이해한다.
{ "compilerOptions": { // 타겟 환경 "target": "ES2021", // 출력 JS 버전. Node.js 16+는 ES2021 지원 "module": "commonjs", // 모듈 시스템. Node.js 기본값 CommonJS
// 모듈 해석 "moduleResolution": "node", // node_modules 탐색 방식. Node.js 스타일 "baseUrl": "./", // import 경로의 기준 디렉터리 "paths": { // 경로 별칭 설정 "@/*": ["src/*"] },
// 출력 설정 "outDir": "./dist", // 컴파일 결과물 위치 "rootDir": "./src", // 소스 루트 디렉터리 "sourceMap": true, // .map 파일 생성 (디버깅용) "declaration": true, // .d.ts 타입 선언 파일 생성
// TypeScript 엄격 설정 "strict": true, // 모든 엄격 검사 활성화 (권장) "noImplicitAny": true, // 암묵적 any 타입 금지 "strictNullChecks": true, // null/undefined 명시적 처리 강제
// NestJS 필수 "experimentalDecorators": true, // 데코레이터 문법 활성화 "emitDecoratorMetadata": true, // 메타데이터 생성 (DI 핵심!)
// 빌드 최적화 "skipLibCheck": true, // node_modules .d.ts 검사 건너뜀 (빌드 속도) "esModuleInterop": true, // CommonJS/ESM 호환성 향상 "allowSyntheticDefaultImports": true // default import 허용 }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test"]}strict: true가 활성화하는 옵션들:
strict: true├── strictNullChecks — null/undefined를 별도 타입으로 처리├── strictFunctionTypes — 함수 타입 공변/반변 검사├── strictBindCallApply — bind/call/apply 타입 검사├── strictPropertyInitialization — 클래스 프로퍼티 초기화 강제├── noImplicitAny — 암묵적 any 금지├── noImplicitThis — 암묵적 this 타입 금지├── alwaysStrict — "use strict" 삽입└── useUnknownInCatchVariables — catch 변수를 unknown으로 (TS 4.4+)주요 하위 옵션 선택 가이드:
| 옵션 | 켜야 하는 상황 | 끄는 게 합리적인 상황 | 실무 주의점 |
|---|---|---|---|
strictNullChecks | 신규 프로젝트 (항상 권장) | 레거시 JS→TS 마이그레이션 초기 | 끄면 null 관련 런타임 에러가 타입 시스템에서 잡히지 않음 |
strictPropertyInitialization | 일반 클래스 사용 시 | NestJS에서 @Inject로 DI 받는 프로퍼티 → ! (definite assignment) 사용 | private repo!: UserRepository 패턴 필요 |
strictFunctionTypes | 콜백/이벤트 핸들러 타입 안전성 | 서드파티 라이브러리 타입이 깨질 때 일시적 | 함수 파라미터의 반변(contravariance) 검사를 활성화 |
noImplicitAny | 모든 프로젝트 (필수) | 마이그레이션 초기 | 끄면 any가 암묵적으로 퍼져 타입 안전성이 무너짐 |
NestJS 프로젝트 tsconfig 해석
섹션 제목: “NestJS 프로젝트 tsconfig 해석”실제 NestJS CLI 생성 프로젝트의 tsconfig.build.json:
{ "extends": "./tsconfig.json", "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]}빌드용 설정은 기본 설정을 확장하고 테스트 파일을 제외한다. **/*spec.ts 패턴으로 Jest 테스트 파일을 컴파일 대상에서 제외한다.
6. 자주 하는 실수
섹션 제목: “6. 자주 하는 실수”1. interface를 런타임 타입 가드로 사용:
// ❌ 틀림: interface는 런타임에 없음interface User { id: number; name: string; }if (data instanceof User) { ... } // 에러!
// ✅ 올바름: typeof 또는 사용자 정의 타입 가드function isUser(obj: unknown): obj is User { return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj;}2. type assertion(as)으로 실제 타입 검사를 대체:
// ❌ 위험: 런타임 에러 가능성const user = JSON.parse(response) as User;console.log(user.name.toUpperCase()); // name이 실제로 없으면 런타임 에러
// ✅ Zod 같은 런타임 검증 라이브러리 사용import { z } from "zod";const UserSchema = z.object({ id: z.number(), name: z.string() });const user = UserSchema.parse(JSON.parse(response)); // 검증 실패 시 예외3. enum / const enum / as const 선택 trade-off:
// TypeScript enum은 런타임 객체로 존재한다 (소거되지 않음)enum Direction { Up = "UP", Down = "DOWN",}
// 컴파일 출력:// var Direction;// (function (Direction) {// Direction["Up"] = "UP";// Direction["Down"] = "DOWN";// })(Direction || (Direction = {}));
// const enum은 인라인 대체되어 런타임 객체가 없음const enum Status { Active = 1, Inactive = 0,}// if (status === Status.Active) → if (status === 1) 로 인라인 치환
// as const — 순수 JS 객체 + 타입 추론 (번들 크기 최소, 트리셰이킹 가능)const ROLE = { Admin: "ADMIN", User: "USER",} as const;type Role = (typeof ROLE)[keyof typeof ROLE]; // "ADMIN" | "USER"enum 종류별 선택 기준:
| 구분 | enum | const enum | as const 객체 |
|---|---|---|---|
| 런타임 객체 존재 | ✅ 있음 (IIFE 생성) | ❌ 인라인 치환 | ✅ 있음 (일반 객체) |
| 역방향 매핑 | ✅ 숫자 enum만 | ❌ 불가 | ❌ 불가 |
| 번들 크기 | 중간 (IIFE 코드) | 최소 (인라인) | 최소 (객체 리터럴) |
| 트리셰이킹 | ❌ 불가 (IIFE 부수효과) | ✅ 가능 | ✅ 가능 |
isolatedModules 호환 | ✅ | ⚠️ 비호환 (아래 참고) | ✅ |
const enum의 isolatedModules 함정:
isolatedModules: true (Babel, SWC, esbuild 등 단일 파일 트랜스파일러 사용 시 필수)와 const enum은 함께 사용할 수 없다. 다른 모듈에서 export된 const enum을 참조하면 TS2748 에러가 발생한다.
export const enum HttpStatus { OK = 200, NotFound = 404 }
// handler.tsimport { HttpStatus } from './constants';// ❌ TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.if (res.status === HttpStatus.OK) { ... }이유: const enum은 인라인 치환을 위해 크로스-파일 분석이 필요하지만, isolatedModules 모드의 트랜스파일러는 파일을 독립적으로 처리하므로 다른 파일의 const enum 값을 알 수 없다.
실무 권장: 라이브러리나 isolatedModules 환경에서는 as const 객체를 사용한다. const enum은 단일 파일 내에서만 안전하다.
6. 5분 실습: emitDecoratorMetadata ON/OFF 비교
섹션 제목: “6. 5분 실습: emitDecoratorMetadata ON/OFF 비교”아래 절차를 직접 실행하면 __metadata 코드가 실제로 삽입되고 사라지는 것을 눈으로 확인할 수 있다.
단계별 실습
섹션 제목: “단계별 실습”# 1. 빈 디렉터리 생성 후 초기화mkdir ts-meta-lab && cd ts-meta-labnpm init -ynpm install typescript reflect-metadata
# 2. tsconfig.json 작성 (emitDecoratorMetadata ON)cat > tsconfig.json << 'EOF'{ "compilerOptions": { "target": "ES2017", "module": "commonjs", "experimentalDecorators": true, "emitDecoratorMetadata": true, "outDir": "dist" }}EOF
# 3. 데코레이터 클래스 작성cat > service.ts << 'EOF'import "reflect-metadata";
function Injectable(): ClassDecorator { return (target) => { const params = Reflect.getMetadata("design:paramtypes", target); console.log("paramtypes:", params?.map((p: any) => p.name)); };}
class UserRepository {}class LogService {}
@Injectable()class UserService { constructor( private repo: UserRepository, private logger: LogService ) {}}EOF
# 4. 컴파일 후 __metadata 확인npx tscgrep "__metadata" dist/service.js# 출력 예시: __metadata("design:paramtypes", [UserRepository, LogService])
# 5. 실행으로 타입 정보 확인node dist/service.js# 출력: paramtypes: [ 'UserRepository', 'LogService' ]
# 6. emitDecoratorMetadata를 false로 변경 후 재컴파일 비교sed -i '' 's/"emitDecoratorMetadata": true/"emitDecoratorMetadata": false/' tsconfig.jsonnpx tscgrep "__metadata" dist/service.js # 아무것도 출력되지 않음node dist/service.js # 출력: paramtypes: undefined확인 포인트
섹션 제목: “확인 포인트”| 확인 항목 | emitDecoratorMetadata: true | emitDecoratorMetadata: false |
|---|---|---|
dist/service.js에 __metadata 코드 존재 | ✅ 있음 | ❌ 없음 |
Reflect.getMetadata("design:paramtypes") 결과 | [UserRepository, LogService] | undefined |
| NestJS DI 동작 가능 여부 | ✅ 가능 | ❌ “Nest can’t resolve dependencies” 에러 |
출처: TypeScript TSConfig emitDecoratorMetadata, Wolk Software — Decorators & Metadata Reflection
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”케이스 1: emitDecoratorMetadata: false일 때 NestJS DI 에러
섹션 제목: “케이스 1: emitDecoratorMetadata: false일 때 NestJS DI 에러”증상:
Nest can't resolve dependencies of the UserService (?).Please make sure that the argument UserRepository at index [0] is available in the AppModule context.또는:
Error: Cannot determine GraphQL output type for "userId"원인:
tsconfig.json에 emitDecoratorMetadata: true가 없거나 false로 설정되어 있다. 컴파일러가 design:paramtypes 메타데이터를 생성하지 않아 NestJS가 생성자 파라미터 타입을 알 수 없다.
확인 방법:
# 컴파일된 JS 파일에서 __metadata 검색grep -r "__metadata" dist/
# 있으면 정상, 없으면 emitDecoratorMetadata가 꺼진 것해결:
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true // 반드시 추가 }}Webpack이나 esbuild를 사용한다면 별도 플러그인 필요:
// webpack.config.js (ts-loader 사용 시 자동 처리됨)// esbuild 사용 시: @anatine/esbuild-decorators 플러그인 필요케이스 2: Circular Dependency 에러
섹션 제목: “케이스 2: Circular Dependency 에러”증상:
Error: A circular dependency has been detected (AppModule -> UserModule -> OrderModule -> UserModule).또는 더 흔하게:
Cannot read properties of undefined (reading 'name')(undefined가 된 서비스를 사용하려 할 때)
원인:
UserService가 OrderService를 의존하고, OrderService가 다시 UserService를 의존한다. NestJS의 모듈 초기화 순서가 결정 불가능해진다.
해결 1 — forwardRef() 사용:
@Injectable()export class UserService { constructor( @Inject(forwardRef(() => OrderService)) private orderService: OrderService, ) {}}
// order.service.ts@Injectable()export class OrderService { constructor( @Inject(forwardRef(() => UserService)) private userService: UserService, ) {}}해결 2 (권장) — 의존성 방향 정리:
공통 로직을 추출해 별도 서비스로 분리한다. 순환 의존은 대부분 설계 문제의 신호다.
Before: UserService ↔ OrderService (순환)
After: UserService → CommonService OrderService → CommonService (순환 해소)케이스 3: reflect-metadata 미임포트 에러
섹션 제목: “케이스 3: reflect-metadata 미임포트 에러”증상:
TypeError: Reflect.metadata is not a function또는 조용히 design:paramtypes가 undefined로 반환됨.
원인:
reflect-metadata 패키지가 설치되어 있지 않거나, 애플리케이션 진입점에서 임포트되지 않았다. Reflect.metadata는 ECMAScript 표준이 아니므로 폴리필이 필요하다.
해결:
npm install reflect-metadata// main.ts — 반드시 맨 위에서 importimport "reflect-metadata"; // ← 첫 번째 줄import { NestFactory } from "@nestjs/core";import { AppModule } from "./app.module";
async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000);}bootstrap();NestJS CLI로 프로젝트를 생성하면
@nestjs/core가 내부적으로reflect-metadata를 임포트한다. 하지만 NestJS 없이 직접Reflect.metadata를 쓴다면 수동 임포트가 필요하다.
케이스 4: Babel/SWC 환경에서 메타데이터 없음
섹션 제목: “케이스 4: Babel/SWC 환경에서 메타데이터 없음”증상:
Jest 테스트에서만 DI 에러 발생. 프로덕션 빌드는 정상.
원인:
Jest가 ts-jest 대신 babel-jest를 사용하도록 설정되어 있을 때, Babel은 emitDecoratorMetadata를 지원하지 않으므로 메타데이터가 생성되지 않는다.
해결:
module.exports = { moduleFileExtensions: ["js", "json", "ts"], rootDir: "src", testRegex: ".*\\.spec\\.ts$", transform: { "^.+\\.(t|j)s$": "ts-jest", // babel-jest가 아닌 ts-jest 사용 }, collectCoverageFrom: ["**/*.(t|j)s"], coverageDirectory: "../coverage", testEnvironment: "node",};7. 추천 리소스
섹션 제목: “7. 추천 리소스”- TypeScript Handbook: Decorators — 공식 데코레이터 문서. 레거시 데코레이터와 표준 데코레이터의 차이, 변환 예시 포함
- NestJS Metadata Deep Dive — Trilon Consulting —
design:paramtypes가 NestJS DI에서 어떻게 쓰이는지 내부 구현 레벨 설명 - emitDecoratorMetadata — TypeScript TSConfig Reference — 공식 옵션 문서. 코드 변환 예시 포함
- What is Type Erasure in TypeScript? — freeCodeCamp — 타입 소거 개념을 비유와 함께 쉽게 설명
- Decorators & Metadata Reflection — Wolk Software — Reflect.metadata API 심화. 직접 메타데이터 시스템을 구현하는 예시
- Java RetentionPolicy — Oracle Docs — Java 어노테이션 보존 정책(SOURCE/CLASS/RUNTIME) 공식 레퍼런스
- Python Annotations Best Practices — Python Docs —
__annotations__,get_type_hints()런타임 타입 힌트 접근 공식 가이드 - SWC Benchmarks — SWC 공식 벤치마크. tsc 대비 트랜스파일 성능 비교 (측정 대상: 타입 검사 제외 트랜스파일만, 콜드 빌드 기준)
- Benchmarking esbuild, swc, tsc, and babel — DataStation — 파일 수·빌드 유형별 측정 조건이 명시된 비교 벤치마크
- TypeScript TSConfig: isolatedModules —
const enum과isolatedModules비호환 등 단일 파일 트랜스파일 제약 공식 문서 - How Stage 3 Decorators Will Revolutionize NestJS — Leapcell — TC39 표준 데코레이터와 NestJS 마이그레이션 전략 설명
- tc39/proposal-decorators — GitHub — TC39 데코레이터 제안 공식 저장소. Stage 3 확정 후 표준화 경위 포함
- TypeScript 5.8 —erasableSyntaxOnly — Total TypeScript —
enum,namespace, 파라미터 프로퍼티 등 비소거 구문 차단 플래그 상세 설명. Node.js type-stripping 호환성 맥락 포함 - TypeScript 5.x Decorators Migration Guide — DEV Community — 레거시 → TC39 Stage 3 데코레이터 시그니처 변경점 및 점진적 마이그레이션 전략
8. 실제 출력 예시
섹션 제목: “8. 실제 출력 예시”예시 1: 타입 소거 전후
섹션 제목: “예시 1: 타입 소거 전후”TypeScript 입력 (src/user.service.ts):
interface UserDto { id: number; email: string;}
type Role = "admin" | "user";
export class UserService { private users: Map<number, UserDto> = new Map();
findById(id: number): UserDto | undefined { return this.users.get(id); }
hasRole(userId: number, role: Role): boolean { return true; }}tsc 컴파일 출력 (dist/user.service.js):
"use strict";Object.defineProperty(exports, "__esModule", { value: true });exports.UserService = void 0;class UserService { constructor() { this.users = new Map(); } findById(id) { // :number, :UserDto | undefined 제거됨 return this.users.get(id); } hasRole(userId, role) { // 타입 어노테이션 제거됨 return true; }}exports.UserService = UserService;// interface UserDto → 완전히 사라짐// type Role → 완전히 사라짐예시 2: emitDecoratorMetadata 효과
섹션 제목: “예시 2: emitDecoratorMetadata 효과”TypeScript 입력:
import { Injectable } from "@nestjs/common";import { UserRepository } from "./user.repository";import { CacheService } from "./cache.service";
@Injectable()export class UserService { constructor( private readonly userRepo: UserRepository, private readonly cache: CacheService, ) {}}tsc 컴파일 출력 (emitDecoratorMetadata: true):
"use strict";var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { ... };var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);};
let UserService = class UserService { constructor(userRepo, cache) { // 타입 제거됨 this.userRepo = userRepo; this.cache = cache; }};UserService = __decorate([ Injectable(), __metadata("design:paramtypes", [ user_repository_1.UserRepository, // ← 타입 정보가 클래스 참조로 저장됨 cache_service_1.CacheService // ← NestJS DI가 이걸 읽음 ])], UserService);exports.UserService = UserService;예시 3: Reflect API 직접 사용
섹션 제목: “예시 3: Reflect API 직접 사용”import "reflect-metadata";
// 커스텀 메타데이터 저장/조회class SomeClass { @Reflect.metadata("description", "사용자 이름 필드") name: string = "";}
const desc = Reflect.getMetadata("description", SomeClass.prototype, "name");console.log(desc);// 출력: 사용자 이름 필드
// design:type 조회 (emitDecoratorMetadata 필요)function LogType(target: any, key: string) { const type = Reflect.getMetadata("design:type", target, key); console.log(`${key}: ${type.name}`);}
class Example { @LogType count: number = 0; // 출력: count: Number
@LogType label: string = ""; // 출력: label: String}실행 결과:
count: Numberlabel: String9. 관련 개념 연결
섹션 제목: “9. 관련 개념 연결”TypeScript 컴파일 원리├── 타입 소거 → 런타임 타입 검사 필요성 → Zod / io-ts├── emitDecoratorMetadata│ ├── design:paramtypes → NestJS DI/IoC (di-ioc.md)│ └── Reflect.getMetadata → NestJS Discovery Module (nestjs-discovery-module.md)├── AST 변환│ └── Babel / SWC 동작 원리└── 데코레이터 변환 ├── @Injectable() → 런타임 메타데이터 등록 ├── @Controller() → 라우트 메타데이터 등록 └── @SetMetadata() → 커스텀 키-값 메타데이터
관련 tsconfig 옵션├── experimentalDecorators → 데코레이터 문법 활성화├── emitDecoratorMetadata → 메타데이터 코드 생성├── target → 출력 JS 버전 (데코레이터 헬퍼 코드 형태에 영향)└── strict → 타입 검사 강도레이어 간 연결:
| 이 문서에서 배운 것 | 활용되는 곳 |
|---|---|
design:paramtypes 메타데이터 | L0 di-ioc.md — NestJS가 생성자 의존성을 자동 주입하는 원리 |
Reflect.getMetadata API | L0 nestjs-discovery-module.md — 모듈 탐색 시 메타데이터 읽기 |
| 타입 소거 원리 | L1 이상 — 런타임 타입 검증, API 응답 검증 |
| tsconfig 옵션 이해 | 프로젝트 설정 디버깅 전반 |
10. 요약 & 다음 단계
섹션 제목: “10. 요약 & 다음 단계”핵심 정리
섹션 제목: “핵심 정리”- TypeScript 컴파일은 5단계: 스캔 → 파싱 → 바인딩 → 타입 검사 → 코드 생성
- 타입은 런타임에 없다:
interface,type alias, 파라미터 타입, 반환 타입 모두 JS 출력에서 제거됨 emitDecoratorMetadata: true가 예외를 만든다: 데코레이터가 붙은 클래스의 생성자 파라미터 타입을design:paramtypes메타데이터로 런타임에 보존- NestJS DI는 이 메타데이터를 읽는다:
Reflect.getMetadata('design:paramtypes', UserService)→[UserRepository, CacheService]→ 자동 주입 reflect-metadata폴리필이 필요하다:main.ts최상단에서 임포트 필수
체크리스트
섹션 제목: “체크리스트”-
tsconfig.json에experimentalDecorators: true,emitDecoratorMetadata: true확인 -
main.ts최상단에import 'reflect-metadata'확인 - 컴파일된 JS에서
__metadata("design:paramtypes", [...])확인 - Circular Dependency 에러 시
forwardRef()또는 설계 개선 - Jest 설정에서
ts-jest사용 확인
다음 단계
섹션 제목: “다음 단계”이 문서의 내용을 이해했다면 다음으로 넘어갈 준비가 된 것이다:
- di-ioc.md:
design:paramtypes메타데이터를 NestJS IoC Container가 어떻게 읽어 DI를 구현하는지.@Module(),providers,exports의 관계 - nestjs-discovery-module.md:
@SetMetadata(),Reflector,DiscoveryService로 런타임에 메타데이터를 탐색하는 패턴. 플러그인/동적 라우팅 구현의 기초