콘텐츠로 이동

TypeScript 컴파일 원리

분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-04-09


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의 기반이 되는지를 코드 레벨에서 설명한다.


개념한 줄 요약
타입 소거 (Type Erasure)TypeScript 타입 정보는 컴파일 시 전부 제거되어 런타임 JS에는 존재하지 않는다
emitDecoratorMetadata데코레이터가 붙은 클래스의 타입 정보를 런타임 메타데이터로 변환해 JS에 심는 컴파일 옵션
Reflect.metadata메타데이터를 키-값으로 저장/조회하는 런타임 API (reflect-metadata 폴리필로 제공)
design:paramtypes생성자 파라미터의 타입 배열을 담는 메타데이터 키. NestJS DI의 핵심
AST (Abstract Syntax Tree)소스코드를 트리 자료구조로 파싱한 표현. tsc가 타입 검사와 코드 생성에 사용

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은 타입 검사를 건너뛰고 에미터 역할만 한다.


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


타입 소거가 원칙이라면, NestJS DI는 어떻게 타입 정보를 런타임에 읽는가? 답은 컴파일러가 타입 정보를 메타데이터 형태로 런타임 코드에 삽입해주는 것이다.

tsconfig.json 설정:

{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
  • experimentalDecorators: @Injectable() 같은 데코레이터 문법을 활성화한다
  • emitDecoratorMetadata: 데코레이터가 붙은 클래스/메서드의 타입 정보를 런타임 메타데이터로 내보낸다

두 옵션은 함께 사용해야 한다. emitDecoratorMetadataexperimentalDecorators가 켜져 있을 때만 의미가 있다.

메타데이터 키 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.getMetadatareflect-metadata 폴리필이 Reflect 전역 객체에 추가하는 메서드다. 키-값 저장소처럼 동작하며, 메타데이터를 클래스/프로퍼티에 연결해 보관한다.

NestJS DI가 이를 어떻게 활용하는가:

앱 시작
NestJS IoC Container 초기화
@Module() 스캔 → providers 목록 수집
각 provider 클래스에 대해:
Reflect.getMetadata('design:paramtypes', FooService)
→ [BarService, LogService]
BarService, LogService를 먼저 인스턴스화
FooService 생성자에 주입:
new FooService(barServiceInstance, logServiceInstance)

@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,
);

핵심 포인트:

  1. constructor(private readonly userRepository: UserRepository)constructor(userRepository): 타입 어노테이션과 접근 제어자가 제거됨
  2. __metadata("design:paramtypes", [UserRepository, LogService]): 컴파일러가 자동으로 삽입. 원본 TypeScript의 타입 정보(UserRepository, LogService)를 클래스 참조로 저장
  3. __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 → ECMAScript Symbol/DecoratorContext 기반으로 바뀐다
  • TypeORM, TypeGraphQL 등 emitDecoratorMetadata 의존 라이브러리는 별도 마이그레이션 필요
  • 표준 데코레이터에서 의존성 메타데이터는 @Inject(Token) 형식 명시 토큰으로 대체

Before / After — 클래스 데코레이터 시그니처 비교:

// ─── BEFORE: 레거시 데코레이터 (experimentalDecorators: true) ───
function Log(target: Function) {
// target = 클래스 생성자 자체
console.log("decorated:", target.name);
}
@Log
class OrderService {}
// ─── AFTER: TC39 Stage 3 데코레이터 (experimentalDecorators: false) ───
function Log(target: typeof OrderService, context: ClassDecoratorContext) {
// context에 name, kind, addInitializer 등 표준 메타정보 포함
console.log("decorated:", context.name);
}
@Log
class 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는 “컴파일타임 타입 정보를 런타임에 보존”하는 일반 원리의 한 구현이다. 이 패턴은 여러 언어/플랫폼에서 반복된다.

언어/도구메커니즘보존 방식활용 예시
TypeScriptemitDecoratorMetadata컴파일러가 __metadata() 호출 코드를 JS에 삽입NestJS DI, design:paramtypes
Java@Retention(RUNTIME) + Reflection어노테이션을 .class 바이트코드에 기록, JVM이 런타임에 유지Spring DI (@Autowired), JPA (@Entity)
Python__annotations__ dict인터프리터가 임포트 시점에 타입 힌트를 딕셔너리로 저장Pydantic 모델 검증, FastAPI 파라미터 파싱
Gogo generate + 코드 생성빌드 전 도구가 소스를 분석해 별도 .go 파일 생성Wire (DI), protobuf 코드 생성

Java — RetentionPolicy 3단계:

Java 어노테이션은 RetentionPolicy로 메타데이터 수명을 명시적으로 제어한다.

  • SOURCE: 컴파일러가 버린다 (Lombok @Getter 등). TypeScript의 일반 타입 소거에 해당.
  • CLASS: .class 파일에 기록되지만 JVM이 런타임에 로드하지 않는다 (기본값).
  • RUNTIME: JVM이 런타임에 유지하며 Reflection API로 읽을 수 있다. 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.js
import "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.py
from typing import get_type_hints
class UserRepository: pass
class 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. 프론트엔드 → 플랫폼 브릿지”

프론트엔드 개발을 하다 보면 타입 관련 기능을 두 가지 맥락에서 쓴다.

구분목적존재 시점예시
TypeScript 타입컴파일 타임 안전성컴파일 시에만props: { name: string }
React PropTypes런타임 경고런타임에 존재Component.propTypes = { name: PropTypes.string }

TypeScript 타입은 런타임에 사라진다. React PropTypes는 런타임 객체로 존재해 개발 모드에서 실제로 검사가 수행된다. 둘은 목적과 동작 시점이 다르다.

NestJS의 emitDecoratorMetadata는 이 사이의 다리 역할을 한다. TypeScript 타입 정보 중 일부(데코레이터가 붙은 클래스의 파라미터 타입)를 런타임 메타데이터로 변환해 준다.

TypeScript 타입 (컴파일 타임)
↓ emitDecoratorMetadata 활성화 시
런타임 메타데이터 (design:paramtypes)
NestJS DI가 런타임에 읽어 의존성 주입

백엔드 프로젝트를 다루다 보면 빌드 도구 선택이 중요해진다.

구분tsc (TypeScript Compiler)Babel + @babel/preset-typescriptSWC (@swc/core)
역할타입 검사 + 코드 변환코드 변환만 (타입 무시)코드 변환만 (타입 무시)
속도상대적으로 느림 (타입 검사 포함)빠름 (타입 스트리핑만)매우 빠름 (Rust, tsc 대비 ~20x)
타입 에러빌드 실패 가능무시하고 통과무시하고 통과
emitDecoratorMetadata지원기본 지원 안 함지원 (설정 필요)
NestJS와 사용공식 권장별도 플러그인 필요NestJS 10+ 공식 지원

Babel이 emitDecoratorMetadata를 지원하지 않는 이유:

Babel은 파일을 한 번에 하나씩, 타입 정보 없이 변환한다. design:paramtypes를 생성하려면 UserRepository 타입이 실제 어느 클래스인지 알아야 하는데, 이는 크로스-파일 타입 분석이 필요하다. Babel의 단일 파일 처리 방식으로는 불가능하다.

판단 기준tscBabelSWC
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 벤치마크)


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 CLI 생성 프로젝트의 tsconfig.build.json:

{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

빌드용 설정은 기본 설정을 확장하고 테스트 파일을 제외한다. **/*spec.ts 패턴으로 Jest 테스트 파일을 컴파일 대상에서 제외한다.


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 종류별 선택 기준:

구분enumconst enumas const 객체
런타임 객체 존재✅ 있음 (IIFE 생성)❌ 인라인 치환✅ 있음 (일반 객체)
역방향 매핑✅ 숫자 enum만❌ 불가❌ 불가
번들 크기중간 (IIFE 코드)최소 (인라인)최소 (객체 리터럴)
트리셰이킹❌ 불가 (IIFE 부수효과)✅ 가능✅ 가능
isolatedModules 호환⚠️ 비호환 (아래 참고)

const enumisolatedModules 함정:

isolatedModules: true (Babel, SWC, esbuild 등 단일 파일 트랜스파일러 사용 시 필수)와 const enum은 함께 사용할 수 없다. 다른 모듈에서 exportconst enum을 참조하면 TS2748 에러가 발생한다.

constants.ts
export const enum HttpStatus { OK = 200, NotFound = 404 }
// handler.ts
import { 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 코드가 실제로 삽입되고 사라지는 것을 눈으로 확인할 수 있다.

Terminal window
# 1. 빈 디렉터리 생성 후 초기화
mkdir ts-meta-lab && cd ts-meta-lab
npm init -y
npm 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 tsc
grep "__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.json
npx tsc
grep "__metadata" dist/service.js # 아무것도 출력되지 않음
node dist/service.js # 출력: paramtypes: undefined
확인 항목emitDecoratorMetadata: trueemitDecoratorMetadata: 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


케이스 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.jsonemitDecoratorMetadata: true가 없거나 false로 설정되어 있다. 컴파일러가 design:paramtypes 메타데이터를 생성하지 않아 NestJS가 생성자 파라미터 타입을 알 수 없다.

확인 방법:

Terminal window
# 컴파일된 JS 파일에서 __metadata 검색
grep -r "__metadata" dist/
# 있으면 정상, 없으면 emitDecoratorMetadata가 꺼진 것

해결:

tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true // 반드시 추가
}
}

Webpack이나 esbuild를 사용한다면 별도 플러그인 필요:

// webpack.config.js (ts-loader 사용 시 자동 처리됨)
// esbuild 사용 시: @anatine/esbuild-decorators 플러그인 필요

증상:

Error: A circular dependency has been detected (AppModule -> UserModule -> OrderModule -> UserModule).

또는 더 흔하게:

Cannot read properties of undefined (reading 'name')

(undefined가 된 서비스를 사용하려 할 때)

원인:

UserServiceOrderService를 의존하고, OrderService가 다시 UserService를 의존한다. NestJS의 모듈 초기화 순서가 결정 불가능해진다.

해결 1 — forwardRef() 사용:

user.service.ts
@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:paramtypesundefined로 반환됨.

원인:

reflect-metadata 패키지가 설치되어 있지 않거나, 애플리케이션 진입점에서 임포트되지 않았다. Reflect.metadata는 ECMAScript 표준이 아니므로 폴리필이 필요하다.

해결:

Terminal window
npm install reflect-metadata
// main.ts — 반드시 맨 위에서 import
import "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를 지원하지 않으므로 메타데이터가 생성되지 않는다.

해결:

jest.config.js
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",
};


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 → 완전히 사라짐

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;

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: Number
label: String

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 APIL0 nestjs-discovery-module.md — 모듈 탐색 시 메타데이터 읽기
타입 소거 원리L1 이상 — 런타임 타입 검증, API 응답 검증
tsconfig 옵션 이해프로젝트 설정 디버깅 전반

  1. TypeScript 컴파일은 5단계: 스캔 → 파싱 → 바인딩 → 타입 검사 → 코드 생성
  2. 타입은 런타임에 없다: interface, type alias, 파라미터 타입, 반환 타입 모두 JS 출력에서 제거됨
  3. emitDecoratorMetadata: true가 예외를 만든다: 데코레이터가 붙은 클래스의 생성자 파라미터 타입을 design:paramtypes 메타데이터로 런타임에 보존
  4. NestJS DI는 이 메타데이터를 읽는다: Reflect.getMetadata('design:paramtypes', UserService)[UserRepository, CacheService] → 자동 주입
  5. reflect-metadata 폴리필이 필요하다: main.ts 최상단에서 임포트 필수
  • tsconfig.jsonexperimentalDecorators: 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로 런타임에 메타데이터를 탐색하는 패턴. 플러그인/동적 라우팅 구현의 기초