값 공간 / 타입 공간
JavaScript가 실행하는 값과 TypeScript가 컴파일 때만 검사하는 타입은 별도 공간에 있다.
`interface`가 런타임에 없는 이유를 볼 때분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-04-09
NestJS를 처음 읽으면 가장 먼저 이상한 지점이 보인다.
@Injectable()export class UserService { constructor(private readonly userRepository: UserRepository) {}}new UserRepository()를 호출하지 않았는데 UserService는 실행 시점에 UserRepository 인스턴스를 받는다. 더 이상한 점은 UserRepository가 TypeScript 타입처럼 보인다는 것이다. TypeScript 타입은 컴파일하면 JavaScript에서 사라진다고 배웠는데, NestJS는 어떻게 생성자 파라미터 타입을 알아내는가?
이 질문이 이 문서의 중심이다. TypeScript 컴파일러는 기본적으로 타입을 지운다. 다만 데코레이터가 붙은 코드에 한해서 emitDecoratorMetadata 옵션이 켜져 있으면, 일부 타입 정보를 __metadata("design:paramtypes", [...]) 같은 JavaScript 호출로 바꿔 런타임에 남길 수 있다. NestJS DI, @SetMetadata(), Reflector, Discovery Module은 이 런타임 메타데이터 표면 위에서 동작한다.
이 메커니즘을 이해하면 다음 문제가 한 덩어리로 연결된다.
Nest can't resolve dependencies of the UserService (?)가 단순 등록 누락인지, 메타데이터가 생성되지 않은 빌드 문제인지 구분할 수 있다.interface를 생성자 타입으로 쓰면 왜 자동 주입 토큰이 사라지는지 설명할 수 있다.tsc --noEmit, isolatedModules, const enum 같은 빌드 옵션을 속도 문제가 아니라 “파일 하나만 보고 변환하는가, 프로그램 전체 타입 정보를 보는가”라는 기준으로 판단할 수 있다.정량 신호도 있다. Airbnb의 50k+ LoC TypeScript 마이그레이션 사례에서는 운영에서 발견된 버그의 38%가 타입 시스템으로 사전에 막을 수 있었다고 보고됐다. 이 숫자는 TypeScript가 모든 버그를 막는다는 뜻이 아니라, “런타임에 터질 일부 계약 오류를 컴파일 시점으로 앞당긴다”는 철학을 보여주는 사례로 읽어야 한다.
JavaScript 런타임은 값(value)만 실행한다. class UserService처럼 실제 함수/객체로 출력되는 것은 런타임 값이다. 반대로 interface User, type Role, : UserRepository 같은 TypeScript 타입 표기는 컴파일러가 검사한 뒤 출력물에서 제거한다. 이 설계 덕분에 TypeScript는 JavaScript 생태계와 호환되지만, 프레임워크 입장에서는 “생성자 첫 번째 인자가 무엇인가”를 런타임에 알 수 없다는 문제가 생긴다.
emitDecoratorMetadata는 이 한계를 전부 없애는 기능이 아니다. 데코레이터가 붙은 선언에 대해, 컴파일러가 추론 가능한 일부 타입 정보를 런타임 코드로 복사해 주는 선택적 다리다. 그래서 이 문서의 핵심 질문은 “타입이 남는가?”가 아니라 “이 정보가 값 공간에 실제로 남는가, 남는다면 어떤 형태로 남는가?” 다.
작은 운영 감각
메타데이터가 없으면 실패는 대개 앱 시작 시점에 난다. 컨테이너가 provider 목록을 스캔하고 생성자 파라미터를 해석하는 순간 design:paramtypes가 없거나 [Object]로 뭉개져 있으면, NestJS는 어떤 토큰을 주입해야 하는지 결정하지 못한다. 이 실패는 요청 한 건의 버그가 아니라 프로세스 부팅 실패로 나타나므로 readiness probe 실패, 배포 롤백, 테스트 환경에서만 재현되는 DI 오류로 번지기 쉽다.
이 문서는 TypeScript 컴파일 파이프라인을 먼저 읽고, 그다음 타입 소거와 런타임 메타데이터 보존을 손으로 확인한 뒤, NestJS DI/Discovery로 이어지는 판단 기준을 만든다.
JavaScript가 실행하는 값과 TypeScript가 컴파일 때만 검사하는 타입은 별도 공간에 있다.
`interface`가 런타임에 없는 이유를 볼 때TypeScript 타입 정보는 컴파일 시 제거되어 런타임 JS에는 기본적으로 존재하지 않는다.
런타임 검사와 타입 검사를 구분할 때데코레이터가 붙은 선언의 일부 타입 정보를 런타임 메타데이터 코드로 삽입한다.
NestJS DI가 생성자 타입을 읽어야 할 때`reflect-metadata` 폴리필이 제공하는 키-값 메타데이터 저장·조회 API다.
커스텀 데코레이터 값을 보존할 때생성자·메서드 파라미터 타입 배열을 담는 메타데이터 키다.
IoC Container가 의존성 그래프를 만들 때소스코드를 트리로 파싱한 표현이며, tsc가 타입 검사와 코드 생성에 사용한다.
컴파일러 흐름을 추적할 때이름이 가리키는 선언을 대표하는 컴파일러 내부 객체다. 같은 이름이라도 스코프가 다르면 다른 Symbol이다.
Binder와 Type Checker의 역할을 구분할 때TypeScript 컴파일은 “타입 제거 + JS 변환” 한 단계가 아니다. 컴파일러는 먼저 소스 텍스트를 구조화하고, 이름이 무엇을 가리키는지 연결하고, 타입 관계를 계산한 뒤, 마지막에 JavaScript를 출력한다.
이 흐름을 이해할 때 세 단어가 중요하다.
class UserService { ... }는 클래스 선언 노드가 되고, 생성자 파라미터와 타입 표기도 하위 노드가 된다.UserRepository라는 이름이 import된 클래스인지, 같은 파일의 interface인지, 지역 변수인지 Binder가 Symbol로 연결한다.작은 예로 보면 흐름이 선명하다.
import { UserRepository } from "./user.repository";
@Injectable()class UserService { constructor(private readonly repo: UserRepository) {}}Parser는 repo 파라미터에 UserRepository 타입 표기가 붙어 있다는 구조를 만든다. Binder는 UserRepository라는 이름을 import 선언의 Symbol에 연결한다. Type Checker는 그 Symbol이 실제 class 값으로도 존재하는지 판단할 수 있다. Emitter는 타입 표기 자체는 지우지만, emitDecoratorMetadata가 켜져 있고 데코레이터가 붙어 있으므로 UserRepository 클래스 참조를 design:paramtypes 배열에 남길 수 있다.
반대로 UserRepository가 interface였다면 Type Checker는 타입 검사는 할 수 있지만 Emitter가 런타임 값으로 남길 클래스 참조가 없다. 이 차이가 NestJS DI 실패의 첫 번째 갈림길이다.
문자열을 `class`, `{`, `name`, `:`, `string` 같은 토큰 스트림으로 분해한다.
토큰 스트림을 문법 규칙에 따라 AST로 조립한다.
AST를 순회하며 이름과 선언을 Symbol로 연결하고 스코프를 만든다.
AST와 Symbol로 타입 호환성을 검증하고, 필요한 경우 런타임 메타데이터에 쓸 타입을 계산한다.
AST를 JavaScript와 `.d.ts`로 출력한다. `emitDecoratorMetadata`가 켜져 있으면 메타데이터 코드도 삽입한다.
파이프라인을 외워야 하는 이유
이 순서는 빌드 도구 선택의 기준이 된다.
| 질문 | 필요한 단계 | 예시 |
|---|---|---|
| 타입 오류만 잡고 JS를 만들지 않을 것인가? | Scanner~Type Checker | tsc --noEmit |
| 타입을 지우고 JS만 빠르게 만들 것인가? | Parser~Emitter 중심 | Babel, SWC, esbuild |
design:paramtypes처럼 타입 기반 메타데이터를 만들 것인가? | Type Checker + Emitter | tsc 또는 메타데이터 지원 변환기 |
| 파일 하나만 독립 변환할 수 있어야 하는가? | 크로스 파일 Type Checker 회피 | isolatedModules: true |
포인트: 타입 검사와 코드 생성은 분리되어 있다.
tsc --noEmit은 타입 검사만 하고 파일을 생성하지 않는다. 반대로 Babel/SWC는 보통 타입 검사를 건너뛰고 변환 속도에 집중한다. 이 차이가 NestJS 메타데이터,const enum,.d.ts생성에서 실제 문제로 드러난다.
여기서 .d.ts는 런타임 JavaScript가 아니다. 라이브러리 소비자가 import했을 때 TypeScript가 읽는 공개 타입 계약이다.
export declare function parseUser(input: string): User;이 파일은 실행되지 않지만, 소비자 프로젝트의 Type Checker는 이 선언을 읽고 parseUser(123) 같은 호출을 막는다. 그래서 라이브러리 배포에서는 “JS가 잘 실행된다”와 “.d.ts 계약이 정확하다”가 별개의 품질 조건이다.
TypeScript의 가장 중요한 설계 원칙 중 하나는 타입이 기본적으로 컴파일 결과물에 존재하지 않는다는 것이다.
이 원칙은 두 공간을 구분하면 덜 헷갈린다.
| 공간 | 존재 시점 | 예시 | 런타임에서 typeof/instanceof 가능? |
|---|---|---|---|
| 값 공간(value space) | JavaScript 실행 시점 | class, function, const, 객체 리터럴 | 가능 |
| 타입 공간(type space) | TypeScript 검사 시점 | interface, type, 타입 어노테이션, 제네릭 인자 | 불가능 |
같은 이름이 두 공간에 동시에 있을 수도 있다. class UserRepository는 타입으로도 쓸 수 있고 런타임 값으로도 존재한다. 반면 interface UserRepository는 타입 공간에만 있으므로 NestJS가 생성자 토큰으로 읽을 값이 없다.
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를 생성하는 도구”다.
반례 1 — string 타입과 String 값은 다르다
// ❌ 런타임에 동작하지 않는 코드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(); }}string은 타입 키워드이고 런타임 값이 아니다. typeof value === "string"은 JavaScript 런타임 연산자라서 실제 값에 대해 동작한다.
반례 2 — interface는 타입 계약이지 런타임 식별자가 아니다
// ❌ 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; // 정상 동작}이 차이는 DI에서 더 중요해진다. 생성자 파라미터가 class 타입이면 design:paramtypes에 클래스 생성자가 들어갈 수 있다. 생성자 파라미터가 interface나 type이면 런타임 값이 없어서 Object로 뭉개지거나 메타데이터만으로는 토큰을 결정할 수 없다.
interface PaymentPort { charge(amount: number): Promise<void>;}
@Injectable()class CheckoutService { // ❌ PaymentPort는 런타임에 존재하지 않는다. // design:paramtypes는 대개 [Object]에 가까운 정보만 남는다. constructor(private readonly payment: PaymentPort) {}}
const PAYMENT_PORT = Symbol("PAYMENT_PORT");
@Injectable()class CheckoutServiceFixed { // ✅ interface를 쓰고 싶다면 런타임 토큰을 따로 제공한다. constructor(@Inject(PAYMENT_PORT) private readonly payment: PaymentPort) {}}여기서 PAYMENT_PORT는 값 공간에 존재하는 토큰이다. DI 컨테이너는 타입 공간의 PaymentPort가 아니라 값 공간의 PAYMENT_PORT를 key로 provider를 찾는다. 다음 DI/IoC 문서에서 다룰 “토큰 기반 주입”의 출발점이 이 구분이다.
런타임에 살아남는 구문 vs 소거되는 구문:
TypeScript 구문 중 일부는 타입 소거 원칙의 예외다. 런타임 검사에 쓸 수 있는지 판단할 때는 “JS 출력이 생기는가”를 먼저 본다.
완전히 소거된다. `instanceof Interface`처럼 런타임 검사에 사용할 수 없다.
컴파일 타임 계약만 필요할 때`: string` 같은 표기는 JS 출력에 남지 않는다. 실제 값 검사는 `typeof`나 타입 가드가 맡는다.
외부 입력 검증을 설계할 때IIFE 기반 JS 객체로 살아남는다. 트리셰이킹이 어렵고 번들에 포함되기 쉽다.
런타임 객체가 꼭 필요할 때참조 위치에 값이 인라인된다. `isolatedModules` 환경에서 외부 참조가 깨질 수 있다.
프로그램 전체를 보는 tsc 파이프라인에서만 신중히JS `class` 또는 함수로 출력되므로 `instanceof`와 decorator metadata 대상이 될 수 있다.
런타임 타입 식별자가 필요할 때생성자 안의 `this.x = x` 코드로 변환된다. Node type-stripping처럼 변환 없는 환경에서는 막힌다.
`erasableSyntaxOnly`와 함께 점검수치 감각 — 무엇이 번들에 남는가
타입 어노테이션과 interface는 0바이트다. 출력 JavaScript에 남지 않는다. 반대로 enum은 런타임 객체를 만들기 때문에 작은 enum 하나도 IIFE(즉시 실행 함수) 코드가 생긴다. as const 객체는 일반 객체 리터럴만 남고, const enum은 값이 사용 위치에 인라인되어 객체 자체가 남지 않는다. 번들 크기보다 더 중요한 기준은 “런타임에 값이 필요한가”다.
| 요구사항 | 적합한 선택 | 이유 |
|---|---|---|
| 컴파일 타임 계약만 필요 | interface, type | JS 출력이 없어 가장 가볍다. |
instanceof, decorator metadata, DI 토큰이 필요 | class, 명시 토큰 | 런타임 값으로 존재한다. |
| 런타임에서 열거 가능한 상수 객체가 필요 | as const 객체 | 일반 JS 객체라 도구 호환성이 좋다. |
프로그램 전체를 tsc가 컴파일하고 값 인라인이 유리 | const enum | 빠르고 작지만 단일 파일 변환과 충돌한다. |
| 외부 라이브러리 API에 enum 객체가 필요 | enum | 런타임 객체가 필요한 계약이면 사용할 수 있다. |
선택 심화 — 소거만 허용하는 실행 환경
일부 실행 환경은 TypeScript 타입 표기만 제거하고, enum이나 파라미터 프로퍼티처럼 JavaScript 코드를 새로 만들어야 하는 구문은 처리하지 않는다. 이런 환경에서는 “소거 가능한 문법만 쓰는가”가 중요하다. 이 문서의 핵심은 특정 플래그 이름을 외우는 것이 아니라, 타입 표기 제거만으로 충분한 문법인지, 별도 변환이 필요한 문법인지를 구분하는 것이다.
타입 소거가 원칙이라면, NestJS DI는 어떻게 타입 정보를 런타임에 읽는가? 답은 컴파일러가 타입 정보를 메타데이터 형태로 런타임 코드에 삽입해주는 것이다.
tsconfig.json 설정:
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}experimentalDecorators: @Injectable() 같은 데코레이터 문법을 활성화한다emitDecoratorMetadata: 데코레이터가 붙은 클래스/메서드의 타입 정보를 런타임 메타데이터로 내보낸다두 옵션은 함께 사용해야 한다. emitDecoratorMetadata는 experimentalDecorators가 켜져 있을 때만 의미가 있다.
정의 — 데코레이터와 메타데이터
데코레이터는 클래스, 메서드, 프로퍼티, 파라미터 선언에 붙는 함수 호출이다. @Injectable()은 클래스가 선언되는 시점에 실행되어 그 클래스를 프레임워크가 읽을 수 있는 표면으로 만든다. 메타데이터(metadata)는 “데이터에 붙은 데이터”다. NestJS에서는 클래스 자체가 데이터이고, design:paramtypes, PATH_METADATA, 커스텀 이벤트 키 같은 값이 메타데이터다.
reflect-metadata는 ECMAScript 표준 Reflect 객체에 Reflect.defineMetadata, Reflect.getMetadata, Reflect.metadata 같은 API를 추가하는 폴리필이다. TypeScript가 삽입하는 __metadata() 헬퍼는 내부에서 Reflect.metadata(k, v)를 호출한다. 따라서 코드에 __metadata("design:paramtypes", [...])가 있어도 reflect-metadata가 로드되지 않으면 저장소가 없어서 읽을 수 없다.
메타데이터 키 3종:
프로퍼티 또는 파라미터의 타입을 담는다. 예: `String`, `Number`, `UserService`.
프로퍼티 데코레이터에서 타입을 읽을 때생성자나 메서드의 파라미터 타입 배열을 담는다. NestJS DI의 핵심 입력이다.
생성자 의존성 그래프를 만들 때메서드 반환 타입을 담는다. 예: `Promise`, `String`.
메서드 데코레이터에서 반환 타입이 필요할 때보존되는 정보와 보존되지 않는 정보
emitDecoratorMetadata가 “TypeScript 타입을 전부 런타임에 되살린다”는 뜻은 아니다. NestJS를 이해하려면 이 한계를 먼저 알아야 한다.
| TypeScript 타입 표기 | design:paramtypes에 남는 대표 형태 | 이유 |
|---|---|---|
UserRepository class | [UserRepository] | 클래스는 런타임 생성자 값이 있다. |
string, number, boolean | [String], [Number], [Boolean] | 원시 타입은 박싱 생성자로 표현된다. |
UserRepository[] | [Array] | 배열 원소 타입은 런타임에 사라진다. |
Promise<User> | [Promise] | 제네릭 인자 User는 소거된다. |
User | null | 대개 [Object] 또는 넓은 생성자 | union은 하나의 런타임 생성자로 표현할 수 없다. |
interface PaymentPort | 대개 [Object] | interface는 값 공간에 없다. |
type PaymentPort = ... | 대개 [Object] | type alias도 값 공간에 없다. |
따라서 자동 주입이 잘 맞는 경우는 생성자 파라미터 타입이 런타임 클래스인 경우다. interface, type alias, union, generic, primitive wrapper처럼 정보가 흐려지는 경우에는 @Inject(TOKEN)처럼 명시 토큰을 써야 한다.
퀴즈
힌트: 생성자 타입이 실제 어느 클래스인지 알려면 파일 하나만 봐서는 부족하다.
Babel은 기본적으로 파일을 독립적으로 변환하고 TypeScript 타입 체커를 실행하지 않는다. `design:paramtypes`를 만들려면 크로스 파일 타입 분석으로 `UserRepository` 같은 타입이 런타임 클래스인지 알아야 하므로 tsc나 해당 기능을 지원하는 별도 변환기가 필요하다.
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 전역 객체에 추가하는 메서드다. 키-값 저장소처럼 동작하며, 메타데이터를 클래스/프로퍼티에 연결해 보관한다.
직접 저장한 메타데이터와 TypeScript가 만든 메타데이터
NestJS Discovery Module로 이어지는 지점은 여기다. 런타임 메타데이터에는 두 종류가 있다.
import "reflect-metadata";
const HANDLER = Symbol("HANDLER");
function SlackHandler(command: string): MethodDecorator { return (target, propertyKey) => { // 개발자가 직접 저장하는 커스텀 메타데이터 Reflect.defineMetadata(HANDLER, command, target, propertyKey); };}
class SlackController { @SlackHandler("/deploy") deploy() {}}
const command = Reflect.getMetadata( HANDLER, SlackController.prototype, "deploy",);console.log(command); // "/deploy"이 코드는 TypeScript 타입 정보가 아니라 개발자가 직접 저장한 키-값 메타데이터다. Discovery Module은 이런 메타데이터를 provider와 method에서 읽어 “어떤 메서드를 어떤 이벤트에 연결할지” 결정한다. 반면 design:paramtypes는 TypeScript 컴파일러가 자동 삽입하는 메타데이터다. 둘 다 저장소는 reflect-metadata지만, 생성 주체와 사용 목적이 다르다.
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,);핵심 포인트:
constructor(private readonly userRepository: UserRepository) → constructor(userRepository): 타입 어노테이션과 접근 제어자가 제거됨__metadata("design:paramtypes", [UserRepository, LogService]): 컴파일러가 자동으로 삽입. 원본 TypeScript의 타입 정보(UserRepository, LogService)를 클래스 참조로 저장__decorate([ ... ], UserService): 데코레이터를 클래스에 적용하는 헬퍼. Injectable()이 이 시점에 실행됨레거시 데코레이터와 표준 데코레이터를 구분해야 하는 이유
NestJS의 전통적인 DI 메타데이터 모델은 TypeScript의 레거시 데코레이터(experimentalDecorators: true)와 emitDecoratorMetadata: true 조합에 기대고 있다. 반면 ECMAScript 표준 데코레이터 계열은 데코레이터 함수 시그니처와 메타데이터 전달 모델이 다르며, design:paramtypes를 자동으로 만들어 주는 메커니즘과 같은 것으로 보면 안 된다.
따라서 이 문서에서는 특정 NestJS 최신 버전의 지원 상태를 외우기보다, 아래 기준을 기억한다.
design:paramtypes 자동 생성에 의존한다면 레거시 데코레이터 설정이 필요하다.// design:paramtypes 자동 생성에 의존하는 NestJS 프로젝트의 전형적 설정{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}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 디렉터리)해야 한다.
실패 신호
데코레이터 모드를 바꾼 뒤 아래 증상이 나오면 “NestJS provider 등록”보다 먼저 빌드 산출물과 decorator 모드를 의심한다.
__metadata("design:paramtypes", ...)가 없다.Object로 보인다.tsc 빌드에서는 통과하지만 Babel/SWC/Jest 변환 경로에서만 DI 에러가 난다.context 형태가 예상과 다르다.참고: 표준 데코레이터 자체의 문법과 프레임워크별 지원 상태는 변할 수 있다. 여기서는 지속적인 판단 기준인 “자동
design:paramtypes생성에 의존하는가”에 초점을 둔다.
TypeScript의 emitDecoratorMetadata는 “컴파일타임 타입 정보를 런타임에 보존”하는 일반 원리의 한 구현이다. 이 패턴은 여러 언어/플랫폼에서 반복된다.
첫 회독에서는 TypeScript 행과 “보존 정책이 언어/도구마다 다르다”는 결론만 잡아도 충분하다.
Java/Python/Go 비교는 NestJS metadata를 다른 플랫폼의 Reflection·annotation·code generation과 연결해 보는 심화 읽기다.
| 언어/도구 | 메커니즘 | 보존 방식 | 활용 예시 |
|---|---|---|---|
| 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이 런타임에 유지하며 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.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, 직렬화 프레임워크의 동작 원리를 빠르게 파악할 수 있다.
프론트엔드에서 이미 본 적 있는 구분만 짧게 빌려오자. TypeScript 타입은 컴파일 때만 검사된다. 반면 런타임 검증 객체는 실제 JavaScript 값으로 존재한다. 예를 들어 React PropTypes는 개발 모드에서 런타임 객체로 검사되고, TypeScript props 타입은 출력물에서 사라진다.
NestJS의 메타데이터도 같은 질문으로 읽으면 된다. 이 정보가 JavaScript 값으로 남는가? emitDecoratorMetadata는 일부 타입 정보를 design:paramtypes라는 런타임 값으로 남긴다. @SetMetadata()나 직접 만든 데코레이터는 개발자가 원하는 키-값을 런타임에 남긴다. DI/Discovery는 이 두 종류의 메타데이터를 읽는다.
TypeScript 타입 표기 └─ 기본값: 컴파일 후 사라짐
데코레이터가 붙은 클래스 + emitDecoratorMetadata └─ design:paramtypes 런타임 메타데이터 생성 └─ NestJS IoC Container가 생성자 의존성 토큰으로 사용
커스텀 데코레이터 + Reflect.defineMetadata / @SetMetadata └─ 개발자가 정한 런타임 메타데이터 생성 └─ Discovery Module, Reflector가 provider/method를 스캔할 때 사용이 흐름을 다음 두 문서로 넘겨야 한다.
design:paramtypes를 읽어 어떤 provider를 먼저 만들고 어떤 생성자에 주입할지 결정한다.백엔드 프로젝트에서 빌드 도구 선택은 단순한 속도 문제가 아니다. 어떤 도구가 프로그램 전체 타입 정보를 보는지, 어떤 도구가 파일 하나를 독립적으로 변환하는지 구분해야 한다.
프로그램 전체를 만들고 타입 검사와 코드 변환을 모두 수행한다. `emitDecoratorMetadata`와 `.d.ts` 생성을 기본 지원한다.
NestJS 소규모 프로젝트 또는 선언 파일이 필요할 때타입을 제거하고 코드만 빠르게 변환한다. 크로스 파일 타입 분석이 없어 `emitDecoratorMetadata`를 기본 지원하지 않는다.
프론트엔드 변환 파이프라인 중심일 때Rust 기반 트랜스파일러로 빠르다. 타입 검사는 별도 `tsc --noEmit`으로 분리하는 구성이 흔하다.
CI 콜드 빌드 시간이 병목일 때Babel이 emitDecoratorMetadata를 기본적으로 만들기 어려운 이유
Babel은 파일을 한 번에 하나씩, 타입 정보 없이 변환한다. design:paramtypes를 생성하려면 UserRepository 타입이 실제 어느 클래스인지 알아야 하는데, 이는 크로스-파일 타입 분석이 필요하다. Babel의 단일 파일 처리 방식으로는 불가능하다.
판단 기준
| 상황 | 추천 구성 | 이유 |
|---|---|---|
| NestJS DI가 decorator metadata에 의존하고 빌드 속도가 문제 아님 | tsc 빌드 | 타입 검사와 메타데이터 생성을 한 경로에서 처리한다. |
| NestJS에서 빌드 속도가 병목이고 도구가 메타데이터를 지원함 | SWC 등 빠른 변환 + 별도 타입 검사 | 변환과 타입 검사를 분리하되 metadata 생성 여부를 테스트해야 한다. |
| Jest 테스트에서만 DI가 실패 | 테스트 transform 확인 | babel-jest가 타입만 제거하고 metadata를 만들지 않을 수 있다. |
| 라이브러리 배포 | tsc 또는 선언 파일 생성 경로 유지 | .d.ts는 소비자 타입 검사 계약이다. |
| 프론트엔드 앱 중심, 런타임 decorator metadata 불필요 | Babel/SWC/esbuild 중심 | 타입 검사는 별도, 런타임 metadata 요구가 낮다. |
수치 감각
빠른 트랜스파일러 벤치마크는 대개 “타입 검사 없는 JavaScript 변환” 시간을 잰다. 그래서 tsc보다 훨씬 빠르게 보이더라도, 실제 CI에서는 tsc --noEmit 타입 검사 시간이 별도로 남는다. NestJS에서는 속도보다 먼저 “산출물에 __metadata("design:paramtypes", ...)가 있는가”를 확인해야 한다.
벤치마크를 읽을 때는 최소한 세 가지를 분리해야 한다. “타입 검사 포함 시간인가”, “콜드 빌드인가 incremental/watch 빌드인가”, “측정한 프로젝트가 우리 프로젝트와 비슷한 파일 수와 decorator 사용량을 갖는가”다. 이 조건이 다르면 10x faster 같은 문구는 도구 선택의 근거가 아니라 추가 측정의 출발점이다.
예를 들어 SWC 공식 벤치마크의 tsc 대비 약 20x 수치는 단일 파일 트랜스파일 중심이고, esbuild 45x 같은 공개 수치도 별도 측정 조건을 가진다. 이 수치들은 “빠른 변환기가 있다”는 신호로는 유용하지만, NestJS 프로젝트에서는 별도 tsc --noEmit 시간과 decorator metadata 출력 검증까지 합쳐야 실제 CI 비용이 된다.
NestJS 프로젝트의 tsconfig.json은 “JavaScript 출력 모양”, “타입 검사 강도”, “런타임 메타데이터 생성”을 한 파일에 섞어 둔다. 옵션을 외우기보다 어느 축의 선택인지 분리해서 읽어야 한다.
{ "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"],}옵션을 세 묶음으로 읽기
| 묶음 | 대표 옵션 | 질문 |
|---|---|---|
| 출력 환경 | target, module, outDir, sourceMap | 어떤 JavaScript를 어디에 만들 것인가? |
| 타입 검사 | strict, noImplicitAny, strictNullChecks | 컴파일 시점에 어디까지 실패시킬 것인가? |
| 런타임 메타데이터 | experimentalDecorators, emitDecoratorMetadata | DI/Discovery가 읽을 metadata를 만들 것인가? |
| 도구 호환성 | isolatedModules, declaration, esModuleInterop | 단일 파일 변환, 라이브러리 배포, 모듈 interop 요구가 있는가? |
NestJS DI 관점에서 최소 핵심은 아래 두 줄이다.
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}하지만 이 두 줄만으로 프로젝트 품질이 결정되지는 않는다. strict 계열은 런타임 메타데이터와 별개로 컴파일 타임 오류를 줄이고, target/module은 출력 JavaScript와 실행 환경을 맞춘다. isolatedModules는 빠른 단일 파일 변환 도구를 쓰는 순간 제약을 만든다.
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 테스트 파일을 컴파일 대상에서 제외한다.
구성별 선택 기준
| 프로젝트 상황 | 우선 확인할 옵션 | 판단 기준 |
|---|---|---|
| NestJS API 서버 | experimentalDecorators, emitDecoratorMetadata | 생성자 DI 자동 주입이 metadata에 의존한다. |
| interface 기반 port/adapter DI | @Inject(TOKEN) 사용 여부 | interface는 metadata로 토큰을 만들 수 없다. |
| SWC/Babel/esbuild 변환 | isolatedModules, 별도 tsc --noEmit | 변환 속도와 타입 검사를 분리한다. |
| 패키지 라이브러리 배포 | declaration, emitDeclarationOnly | 소비자에게 .d.ts 계약을 제공해야 한다. |
const enum 사용 | isolatedModules와 외부 export 여부 | 단일 파일 변환 환경이면 피한다. |
이 절은 긴 문제 해결 절차가 아니라, TypeScript 컴파일 원리를 실제 판단으로 바꾸는 구간이다. 실행 명령은 선택 부록에 접어 두고, 본문에서는 “무엇이 깨졌다는 신호인가”와 “어떤 선택을 해야 하는가”를 먼저 본다.
as User는 TypeScript 컴파일러에게 “이 값을 User로 믿어 달라”고 말하는 표기다. 실제 JSON 값이 User인지 검사하지 않는다.
interface User { id: number; name: string;}
const user = JSON.parse(response) as User;console.log(user.name.toUpperCase());이 코드는 컴파일될 수 있지만, 서버 응답이 { "id": 1 }이면 런타임에서 user.name은 undefined다. 타입 단언은 출력 JavaScript에 남지 않기 때문이다.
런타임에서 외부 입력을 믿어야 한다면 값 검사 코드를 따로 둔다.
function isUser(value: unknown): value is User { return ( typeof value === "object" && value !== null && "id" in value && typeof (value as { id: unknown }).id === "number" && "name" in value && typeof (value as { name: unknown }).name === "string" );}
const parsed: unknown = JSON.parse(response);
if (!isUser(parsed)) { throw new Error("Invalid user payload");}
console.log(parsed.name.toUpperCase());실무에서는 Zod, Valibot, io-ts, class-validator 같은 검증 라이브러리를 쓸 수 있다. 중요한 기준은 라이브러리 이름이 아니라 런타임 값 검사 코드가 실제로 실행되는가다.
import { z } from "zod";
const UserSchema = z.object({ id: z.number(), name: z.string(),});
const user = UserSchema.parse(JSON.parse(response));상수 집합을 표현할 때도 “값 공간에 무엇이 남는가”가 기준이다.
enum Direction { Up = "UP", Down = "DOWN",}
const enum Status { Active = 1, Inactive = 0,}
const ROLE = { Admin: "ADMIN", User: "USER",} as const;
type Role = (typeof ROLE)[keyof typeof ROLE];세 선택지는 비슷해 보이지만 출력과 도구 호환성이 다르다.
| 구분 | enum | const enum | as const 객체 |
|---|---|---|---|
| 런타임 객체 | 있음 | 없음, 값 인라인 | 있음 |
| 출력 코드 | enum 객체 생성 코드 | 사용 위치에 숫자/문자열 치환 | 객체 리터럴 |
| 단일 파일 변환 | 대체로 가능 | 외부 export와 충돌 가능 | 가능 |
| 트리셰이킹 | IIFE 형태면 불리 | 유리 | 대체로 유리 |
| 적합한 상황 | 런타임 enum 객체가 계약일 때 | 단일 tsc 파이프라인에서 내부 최적화 | 대부분의 앱/라이브러리 상수 |
const enum의 핵심 반례는 isolatedModules다.
export const enum HttpStatus { OK = 200, NotFound = 404,}
// handler.tsimport { HttpStatus } from "./constants";
if (res.status === HttpStatus.OK) { // ...}const enum은 HttpStatus.OK를 200으로 인라인하려면 다른 파일의 선언을 알아야 한다. 그러나 isolatedModules를 켠 Babel/SWC/esbuild 계열 변환은 파일을 독립적으로 처리한다. 그래서 외부로 export한 const enum은 도구 체인에 따라 깨지거나 금지된다.
대표 실패 신호는 TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled 계열 오류다. 이 메시지는 “상수 enum이 나쁘다”가 아니라 “현재 빌드 파이프라인이 파일 하나만 보고 변환하므로 다른 파일의 enum 값을 안전하게 인라인할 수 없다”는 뜻이다.
결정 기준은 단순하다.
as const 객체가 안전하다.enum을 쓴다.const enum은 프로그램 전체를 tsc가 컴파일하고, 라이브러리 경계 밖으로 노출하지 않을 때만 신중히 쓴다.emitDecoratorMetadata ON/OFF를 손으로 읽기긴 실습을 하지 않아도 관찰 포인트는 세 가지다.
import "reflect-metadata";
function Injectable(): ClassDecorator { return (target) => { const params = Reflect.getMetadata("design:paramtypes", target); console.log(params?.map((p: Function) => p.name)); };}
class UserRepository {}class LogService {}
@Injectable()class UserService { constructor( private readonly repo: UserRepository, private readonly logger: LogService, ) {}}emitDecoratorMetadata: true이면 출력 JavaScript에는 아래와 같은 호출이 들어간다.
UserService = __decorate( [Injectable(), __metadata("design:paramtypes", [UserRepository, LogService])], UserService,);emitDecoratorMetadata: false이면 __metadata("design:paramtypes", ...) 호출이 없다. Reflect.getMetadata("design:paramtypes", UserService)도 undefined에 가까운 결과가 된다.
| 관찰 항목 | 켜짐 | 꺼짐 |
|---|---|---|
출력 JS의 __metadata | 존재 | 없음 |
design:paramtypes | [UserRepository, LogService] | undefined |
| NestJS 생성자 DI | 자동 토큰 추론 가능 | 토큰 추론 실패 |
| 주된 실패 위치 | provider 등록/순환 의존 같은 다른 문제 | 앱 부팅 또는 테스트 시작 시 DI 에러 |
이 실험의 목적은 명령을 외우는 것이 아니다. UserRepository가 타입 표기에서 JavaScript 클래스 참조로 복사되는 순간을 보는 것이다.
DI 에러는 메시지만 보면 비슷하다. 먼저 “컨테이너 등록 문제”인지 “컴파일 산출물 문제”인지 나누어야 한다.
| 증상 | 먼저 의심할 원리 | 의미 |
|---|---|---|
Nest can't resolve dependencies of the UserService (?) | design:paramtypes 없음 또는 토큰 해석 실패 | 물음표는 Nest가 어떤 토큰을 찾아야 하는지 모르겠다는 신호일 수 있다. |
같은 코드가 tsc 빌드에서는 되고 Jest에서만 실패 | 테스트 transform 경로 | babel-jest나 SWC 설정이 metadata를 만들지 않을 수 있다. |
Reflect.metadata is not a function | reflect-metadata 로드 누락 | __metadata 헬퍼가 호출할 저장소 API가 없다. |
design:paramtypes가 [Object]로 나온다 | interface/type/union/generic 사용 | 런타임 클래스 토큰이 없어 명시 토큰이 필요하다. |
Cannot determine GraphQL output type | 런타임 타입 힌트 부족 | GraphQL/class-transformer 계열도 metadata에 의존할 수 있다. |
A circular dependency has been detected | 의존성 그래프 순환 | metadata는 있어도 컨테이너가 생성 순서를 결정하지 못한다. |
emitDecoratorMetadata 누락과 provider 등록 누락은 같은 에러 문장으로 보일 수 있다. 그래서 진단 순서는 아래처럼 잡는다.
__metadata("design:paramtypes", ...)가 있는지 본다.reflect-metadata가 앱 진입점 또는 프레임워크 초기화 경로에서 로드되는지 본다.providers에 등록되고, 필요한 module에서 export/import 되는지 본다.forwardRef() 필요 여부를 본다.emitDecoratorMetadata가 켜져 있어도 순환 의존은 해결되지 않는다. 메타데이터는 “무엇을 필요로 하는가”를 알려줄 뿐, 그래프가 순환일 때 생성 순서를 마법처럼 정해주지 않는다.
@Injectable()class UserService { constructor(private readonly orders: OrderService) {}}
@Injectable()class OrderService { constructor(private readonly users: UserService) {}}이 구조에서 컨테이너는 UserService를 만들려면 OrderService가 필요하고, OrderService를 만들려면 다시 UserService가 필요하다. forwardRef()는 이 순환 참조를 지연시켜 부팅을 가능하게 만드는 도구지만, 설계상 더 나은 해법은 방향을 정리하는 것이다.
Before: UserService <-> OrderService
After: UserService -> AccountPolicy OrderService -> AccountPolicy첫 독서 기준은 이것이다. 물음표가 있는 DI 에러는 metadata/토큰 문제일 수 있고, 명시적인 circular dependency 메시지는 그래프 설계 문제일 가능성이 높다.
emitDecoratorMetadata 산출물만 빠르게 확인하기아래 명령은 학습용 관찰 절차다. 본문 결론을 이해하는 데 필수는 아니다.
앞의 UserRepository, LogService, UserService 예제를 service.ts로 저장하고, 같은 프로젝트의 tsconfig.json에서 emitDecoratorMetadata를 켰다 끄며 비교한다고 생각하면 된다.
npm install typescript reflect-metadatanpx tscgrep "__metadata" dist/service.jsnode dist/service.js확인할 것은 두 줄뿐이다.
__metadata("design:paramtypes", [UserRepository, LogService]);paramtypes: [ "UserRepository", "LogService" ]emitDecoratorMetadata를 끄고 다시 빌드하면 첫 줄은 사라지고, 두 번째 출력은 undefined에 가까워진다.
프로덕션 빌드는 정상인데 Jest에서만 DI가 실패하면 테스트 transform을 본다.
module.exports = { transform: { "^.+\\.(t|j)s$": "ts-jest", },};핵심은 특정 패키지 이름이 아니라, 테스트 경로가 design:paramtypes를 생성하는지다. 빠른 변환기를 쓰는 경우 해당 도구의 decorator metadata 지원 여부와 별도 tsc --noEmit 타입 검사 경로를 함께 확인한다.
design:paramtypes가 NestJS DI에서 어떻게 쓰이는지 내부 구현 레벨 설명__annotations__, get_type_hints() 런타임 타입 힌트 접근 공식 가이드const enum과 isolatedModules 비호환 등 단일 파일 트랜스파일 제약 공식 문서enum, namespace, 파라미터 프로퍼티 등 비소거 구문 차단 플래그 상세 설명. Node.js type-stripping 호환성 맥락 포함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: Numberlabel: StringTypeScript 컴파일 원리├── 타입 소거 → 런타임 타입 검사 필요성 → Zod / io-ts├── emitDecoratorMetadata│ ├── design:paramtypes → NestJS DI/IoC (di-ioc.mdx)│ └── Reflect.getMetadata → NestJS Discovery Module (nestjs-discovery-module.mdx)├── AST 변환│ └── Babel / SWC 동작 원리└── 데코레이터 변환 ├── @Injectable() → 런타임 메타데이터 등록 ├── @Controller() → 라우트 메타데이터 등록 └── @SetMetadata() → 커스텀 키-값 메타데이터
관련 tsconfig 옵션├── experimentalDecorators → 데코레이터 문법 활성화├── emitDecoratorMetadata → 메타데이터 코드 생성├── target → 출력 JS 버전 (데코레이터 헬퍼 코드 형태에 영향)└── strict → 타입 검사 강도레이어 간 연결:
| 이 문서에서 배운 것 | 활용되는 곳 |
|---|---|
design:paramtypes 메타데이터 | L0 di-ioc.mdx — NestJS가 생성자 의존성을 자동 주입하는 원리 |
Reflect.getMetadata API | L0 nestjs-discovery-module.mdx — 모듈 탐색 시 메타데이터 읽기 |
| 타입 소거 원리 | L1 이상 — 런타임 타입 검증, API 응답 검증 |
| tsconfig 옵션 이해 | 프로젝트 설정 디버깅 전반 |
interface, type alias, 파라미터 타입, 반환 타입 모두 JS 출력에서 제거됨emitDecoratorMetadata: true가 예외를 만든다: 데코레이터가 붙은 클래스의 생성자 파라미터 타입을 design:paramtypes 메타데이터로 런타임에 보존Reflect.getMetadata('design:paramtypes', UserService) → [UserRepository, CacheService] → 자동 주입reflect-metadata 폴리필이 필요하다: main.ts 최상단에서 임포트 필수이 문서의 내용을 이해했다면 다음으로 넘어갈 준비가 된 것이다:
design:paramtypes 메타데이터를 NestJS IoC Container가 어떻게 읽어 DI를 구현하는지. @Module(), providers, exports의 관계@SetMetadata(), Reflector, DiscoveryService로 런타임에 메타데이터를 탐색하는 패턴. 플러그인/동적 라우팅 구현의 기초