TypeScript 컴파일과 NestJS 메타데이터
TypeScript 컴파일의 큰 흐름과 타입 소거 원리를 설명하고, NestJS DI가 예외적으로 런타임 메타데이터를 활용하는 구조를 정리합니다. tsconfig 옵션과 대표적인 DI 실패 원인도 함께 짚습니다.
Script Companion
오디오와 함께 스크립트 보기
- 01
TypeScript 컴파일을 이해할 때 먼저 잡아야 할 흐름은 다섯 단계입니다. 소스코드는 먼저 스캔되고, 이어서 파싱되어 AST, 즉 Abstract Syntax Tree라는 트리 형태의 표현으로 바뀝니다. 그 다음 바인딩과 타입 검사를 거치고, 마지막에 JavaScript 코드가 생성됩니다. 여기서 중요한 점은 AST가 단순한 중간 산출물이 아니라, tsc가 타입을 확인하고 코드를 만들어 내는 데 사용하는 구조라는 것입니다. 그래서 컴파일은 그냥 파일 확장자를 바꾸는 과정이 아니라, 소스코드를 해석 가능한 구조로 바꾼 뒤 타입과 출력 코드를 결정하는 과정입니다.
- 02
이 과정에서 TypeScript의 타입 정보는 기본적으로 런타임에 남지 않습니다. 이것을 타입 소거, Type Erasure라고 부릅니다. interface, type alias, 파라미터 타입, 반환 타입은 타입 검사에는 쓰이지만 JavaScript 출력에서는 제거됩니다. 따라서 실행 중인 JavaScript가 TypeScript의 타입 선언을 직접 읽을 수 있다고 생각하면 오해가 생깁니다. 타입은 개발 시점과 컴파일 시점의 안전망이고, 런타임 객체에 자동으로 붙는 정보가 아닙니다. 이 경계를 알아야 테스트나 의존성 주입 문제를 볼 때 원인을 잘못 짚지 않습니다.
- 03
다만 문서에서 다루는 중요한 예외가 있습니다. emitDecoratorMetadata 옵션을 true로 켜면, 데코레이터가 붙은 클래스의 타입 정보 일부가 런타임 메타데이터로 변환되어 JavaScript에 심어집니다. 특히 생성자 파라미터 타입 배열은 design:paramtypes라는 메타데이터 키로 남을 수 있습니다. Reflect.metadata는 이런 메타데이터를 키와 값으로 저장하고 조회하는 런타임 API이며, reflect-metadata 폴리필이 제공하는 기능입니다. 여기서 핵심은 모든 타입이 살아나는 것이 아니라, 데코레이터와 컴파일 옵션이 만나는 특정 상황에서 일부 정보가 보존된다는 점입니다.
- 04
NestJS DI는 바로 이 design:paramtypes 메타데이터에 기대고 있습니다. 예를 들어 UserService 생성자에 UserRepository와 CacheService가 들어간다면, 컴파일 결과에는 이 생성자 파라미터 타입 배열이 메타데이터로 남을 수 있습니다. NestJS는 Reflect.getMetadata로 UserService의 design:paramtypes를 읽고, 그 배열을 바탕으로 어떤 provider를 주입할지 판단합니다. 그래서 TypeScript 타입은 런타임에 없다는 원칙과, NestJS DI가 타입 정보를 읽는 것처럼 보이는 현상은 모순이 아닙니다. emitDecoratorMetadata가 제한된 예외를 만들고, NestJS가 그 예외를 활용하는 구조입니다.
- 05
tsconfig 설정은 이 구조를 실제 프로젝트에서 안정적으로 만들기 위한 출발점입니다. strict true는 여러 엄격한 타입 검사 옵션을 묶어 활성화합니다. strictNullChecks는 신규 프로젝트에서 항상 권장되며, 끄면 null 관련 런타임 에러를 타입 시스템에서 잡지 못할 수 있습니다. strictPropertyInitialization은 일반 클래스에서는 유용하지만, NestJS에서 @Inject로 프로퍼티 주입을 받을 때는 느낌표를 붙이는 definite assignment 패턴이 필요할 수 있습니다. noImplicitAny는 모든 프로젝트에서 필수에 가깝고, 끄면 any가 암묵적으로 퍼져 타입 안전성이 무너집니다. strictFunctionTypes는 콜백과 이벤트 핸들러의 파라미터 타입 안전성을 높이지만, 서드파티 타입이 깨질 때는 일시적으로 조정할 수 있습니다.
- 06
빌드 설정도 분리해서 봐야 합니다. NestJS CLI가 만든 프로젝트의 tsconfig.build.json은 기본 tsconfig 설정을 확장하고, 테스트 파일을 컴파일 대상에서 제외하는 역할을 합니다. 문서에서는 이중 와일드카드 슬래시 별표 spec.ts 패턴으로 Jest 테스트 파일을 제외한다고 설명합니다. 이 말은 개발 중 타입 검사와 테스트 실행에 필요한 파일, 실제 빌드 산출물에 들어갈 파일의 범위를 구분한다는 뜻입니다. TypeScript 컴파일 원리를 볼 때는 타입 옵션만 보는 것이 아니라, 어떤 파일이 컴파일 대상인지도 함께 확인해야 합니다.
- 07
트러블슈팅에서 가장 먼저 볼 사례는 emitDecoratorMetadata가 false이거나 빠진 경우입니다. 이때 컴파일러가 design:paramtypes 메타데이터를 만들지 않으므로, NestJS는 생성자 파라미터 타입을 알 수 없습니다. 결과적으로 DI 에러가 발생할 수 있습니다. Webpack이나 esbuild를 쓰는 환경이라면 별도 플러그인이 필요할 수 있다는 점도 문서가 짚습니다. 또 reflect-metadata 패키지가 없거나 애플리케이션 진입점에서 임포트되지 않으면 Reflect.metadata를 쓸 수 없고, design:paramtypes가 조용히 undefined로 반환될 수 있습니다. NestJS CLI 생성 프로젝트에서는 @nestjs/core가 내부적으로 임포트하지만, 직접 Reflect.metadata를 쓰는 경우에는 수동 임포트가 필요합니다.
- 08
순환 의존성도 NestJS DI에서 자주 만나는 실패 모드입니다. UserService가 OrderService를 의존하고, OrderService가 다시 UserService를 의존하면 모듈 초기화 순서를 결정하기 어려워집니다. 이때 forwardRef를 사용할 수 있지만, 문서는 공통 로직을 별도 서비스로 분리해 의존성 방향을 정리하는 방식을 권장합니다. Jest 테스트에서만 DI 에러가 나고 프로덕션 빌드는 정상인 경우도 있습니다. 원문 사례에서는 Jest가 ts-jest 대신 babel-jest를 사용할 때, Babel이 emitDecoratorMetadata를 지원하지 않아 메타데이터가 생성되지 않는다고 설명합니다.
- 09
정리하면 TypeScript 컴파일은 스캔, 파싱, 바인딩, 타입 검사, 코드 생성의 흐름으로 진행됩니다. 기본 원칙은 타입이 런타임 JavaScript에 남지 않는다는 것이고, emitDecoratorMetadata true와 데코레이터가 만날 때 design:paramtypes 같은 제한된 메타데이터가 생긴다는 것이 예외입니다. NestJS DI는 이 메타데이터를 읽어 자동 주입을 수행하므로, experimentalDecorators, emitDecoratorMetadata, reflect-metadata, 그리고 Jest 설정까지 함께 확인해야 합니다. 이 구조를 이해하면 다음 주제인 NestJS IoC Container와 @Module, providers, exports의 관계를 더 자연스럽게 이어서 볼 수 있습니다.
같은 레이어