홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

NestFactory.create()를 호출하면 무슨 일이 일어나는가 — NestJS 소스코드 추적 (1편)

정기창·2026년 3월 17일

들어가며 — 수백 번 실행한 한 줄

NestJS로 서버를 만들면서 main.ts의 NestFactory.create(AppModule)을 수백 번 실행했지만, 이 한 줄이 내부에서 무슨 일을 하는지는 몰랐습니다. "그냥 서버 만들어주는 거 아닌가?"라고 생각하며 넘어갔습니다.

그런데 Node.js 시리즈에서 V8과 libuv를 추적했던 것처럼, 프레임워크의 추상화 뒤에 무엇이 있는지 직접 확인하지 않으면 결국 디버깅할 때 막히게 됩니다. DI가 안 되는 이유, 모듈 순환 참조 에러, Provider를 찾을 수 없다는 런타임 오류 — 이런 문제들은 NestJS의 부트스트랩 과정을 이해해야 근본적으로 해결할 수 있습니다.

이번 글에서는 NestJS GitHub 저장소의 소스코드를 직접 열어서, NestFactory.create() 한 줄이 거치는 모든 단계를 추적해보겠습니다.

1. main.ts의 한 줄 — 모든 것의 시작점

NestJS 프로젝트를 만들면 가장 먼저 보는 파일이 main.ts입니다.

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

단 두 줄입니다. NestFactory.create()로 애플리케이션을 만들고, app.listen()으로 포트를 열면 끝입니다. 하지만 이 두 줄 뒤에는 놀라울 정도로 복잡한 초기화 과정이 숨어 있습니다.

NestFactory.create()가 반환하는 app은 단순한 Express 인스턴스가 아닙니다. DI 컨테이너가 구성되고, 모든 모듈이 스캔되고, 모든 Provider와 Controller의 인스턴스가 생성된 후에야 반환되는 완전히 초기화된 애플리케이션 객체입니다.

2. NestFactory 소스코드 추적

NestJS 소스코드에서 NestFactory의 실체를 찾아보겠습니다.

📎 packages/core/nest-factory.ts

// packages/core/nest-factory.ts (간략화)
export class NestFactoryStatic {
  private readonly logger = new Logger('NestFactory', {
    timestamp: true,
  });

  public async create<T extends INestApplication = INestApplication>(
    moduleCls: any,
    serverOrOptions?: AbstractHttpAdapter | NestApplicationOptions,
    options?: NestApplicationOptions,
  ): Promise<T> {
    // 1단계: HTTP 어댑터 결정
    const [httpServer, appOptions] = this.isHttpServer(serverOrOptions)
      ? [serverOrOptions, options]
      : [this.createHttpAdapter(), serverOrOptions];

    // 2단계: ApplicationConfig 생성
    const applicationConfig = new ApplicationConfig();

    // 3단계: NestContainer(DI 컨테이너) 생성
    const container = new NestContainer(applicationConfig, appOptions);

    // 4단계: 초기화 (스캔 + 인스턴스 로딩)
    const { graphInspector } = await this.initialize(
      moduleCls, container, applicationConfig, appOptions, httpServer,
    );

    // 5단계: NestApplication 인스턴스 생성 및 반환
    const instance = new NestApplication(
      container, httpServer, applicationConfig, graphInspector, appOptions,
    );
    const target = this.createNestInstance(instance);
    return this.createAdapterProxy<T>(target, httpServer);
  }
}

이 코드를 보면 create() 메서드가 하는 일이 명확하게 보입니다. 크게 5단계로 나눌 수 있습니다.

단계하는 일핵심 클래스
1단계HTTP 어댑터(Express/Fastify) 결정AbstractHttpAdapter
2단계애플리케이션 설정 객체 생성ApplicationConfig
3단계DI 컨테이너 생성NestContainer
4단계모듈 스캔 + 인스턴스 생성DependenciesScanner + InstanceLoader
5단계NestApplication 인스턴스 반환NestApplication

각 단계를 하나씩 파고들어 보겠습니다.

2-1. NestContainer — DI 컨테이너의 탄생

📎 packages/core/injector/container.ts

3단계에서 생성되는 NestContainer는 NestJS 전체 DI 시스템의 심장입니다. Spring Framework의 ApplicationContext와 비슷한 역할을 합니다.

// packages/core/injector/container.ts (간략화)
export class NestContainer {
  private readonly modules = new ModulesContainer();
  private readonly globalModules = new Set<Module>();
  private readonly dynamicModulesMetadata = new Map<string, Partial<DynamicModule>>();
  private readonly moduleCompiler = new ModuleCompiler();

  constructor(
    private readonly _applicationConfig: ApplicationConfig | undefined,
    private readonly _contextOptions: NestApplicationContextOptions | undefined,
  ) {
    // moduleIdGeneratorAlgorithm에 따라 모듈 키 생성 방식 결정
    // 'deep-hash' 또는 'by-reference' (shallow/random)
  }

  public async addModule(
    metatype: Type<any> | DynamicModule | Promise<DynamicModule>,
    scope: Type<any>[],
  ): Promise<{ moduleRef: Module; inserted: boolean }> {
    // 1. metatype 유효성 검사
    // 2. moduleCompiler.compile()로 토큰, 타입, 메타데이터 추출
    // 3. 이미 등록된 모듈이면 early return
    // 4. setModule()로 모듈 저장 및 설정
    const { type, dynamicMetadata, token } =
      await this.moduleCompiler.compile(metatype);

    if (this.modules.has(token)) {
      return { moduleRef: this.modules.get(token), inserted: false };
    }
    return { moduleRef: await this.setModule(metatype, scope), inserted: true };
  }

  public getModules(): ModulesContainer {
    return this.modules;
  }
}

NestContainer의 핵심은 modules라는 ModulesContainer(실질적으로 Map<string, Module>)입니다. 모든 모듈이 여기에 토큰(고유 키)을 기준으로 저장됩니다. addModule()이 호출될 때마다 모듈이 컴파일되고, 중복 체크 후 등록됩니다.

여기서 중요한 점은, 이 시점에서는 컨테이너만 생성된 상태라는 것입니다. 아직 모듈이 스캔되지도 않았고, 인스턴스도 만들어지지 않았습니다. 빈 그릇을 먼저 준비한 셈입니다.

2-2. initialize() — 스캔과 로딩의 오케스트레이션

4단계인 initialize()가 실질적인 초기화의 핵심입니다. 이 메서드 안에서 모듈 스캔과 인스턴스 생성이 모두 이루어집니다.

// packages/core/nest-factory.ts — initialize() (간략화)
private async initialize(
  module: any,
  container: NestContainer,
  config: ApplicationConfig,
  options: NestApplicationOptions,
  httpServer: AbstractHttpAdapter,
) {
  const metadataScanner = new MetadataScanner();
  const graphInspector = new GraphInspector(container);
  const injector = new Injector({ preview: options?.preview });

  // 1. DependenciesScanner 생성
  const dependenciesScanner = new DependenciesScanner(
    container, metadataScanner, graphInspector, config,
  );

  // 2. 모듈 트리 전체 스캔
  await dependenciesScanner.scan(module);

  // 3. InstanceLoader 생성
  const instanceLoader = new InstanceLoader(
    container, injector, graphInspector,
  );

  // 4. 모든 의존성 인스턴스 생성
  await instanceLoader.createInstancesOfDependencies();

  return { graphInspector };
}

이 메서드의 흐름이 NestJS 부트스트랩의 핵심입니다. 먼저 DependenciesScanner로 모듈 트리를 스캔하고, 그 다음 InstanceLoader로 인스턴스를 생성합니다. 스캔이 먼저, 생성이 나중 — 이 순서가 중요합니다.

3. DependenciesScanner — 모듈 트리를 읽는 눈

📎 packages/core/scanner.ts

DependenciesScanner는 AppModule부터 시작해서 모든 모듈을 재귀적으로 탐색합니다. @Module() 데코레이터에 선언한 imports, providers, controllers, exports를 전부 읽어서 컨테이너에 등록하는 역할입니다.

// packages/core/scanner.ts — scan() (간략화)
export class DependenciesScanner {
  public async scan(
    module: Type<any>,
    options?: { overrides?: ModuleOverride[] },
  ) {
    // 1단계: 코어 모듈(InternalCoreModule) 등록
    await this.registerCoreModule(options?.overrides);

    // 2단계: 모듈 트리 재귀 스캔
    await this.scanForModules({
      moduleDefinition: module,
      overrides: options?.overrides,
    });

    // 3단계: 각 모듈의 의존성(providers, controllers 등) 등록
    await this.scanModulesForDependencies();

    // 4단계: 모듈 거리 계산 (위상 정렬)
    this.calculateModulesDistance();

    // 5단계: 전역 모듈 바인딩
    this.addScopedEnhancersMetadata();
    this.container.bindGlobalScope();
  }
}

scan() 메서드 하나가 5단계를 순서대로 실행합니다. 각 단계를 자세히 보겠습니다.

3-1. scanForModules() — 재귀적 모듈 탐색

// packages/core/scanner.ts — scanForModules() (간략화)
public async scanForModules({
  moduleDefinition,
  lazy,
  scope = [],
  ctxRegistry = [],
  overrides = [],
}: ModulesScanParameters): Promise<Module[]> {
  // 1. 현재 모듈을 컨테이너에 추가
  const { moduleRef, inserted } = await this.container.addModule(
    moduleDefinition, scope,
  );

  // 2. 이미 등록된 모듈이면 중복 스캔 방지
  if (ctxRegistry.includes(moduleDefinition)) {
    return [];
  }
  ctxRegistry.push(moduleDefinition);

  // 3. @Module()의 imports 배열에서 하위 모듈 추출
  const modules = this.reflectImports(moduleDefinition);

  // 4. 하위 모듈들을 재귀적으로 스캔
  for (const innerModule of modules) {
    await this.scanForModules({
      moduleDefinition: innerModule,
      scope: [].concat(scope, moduleDefinition),
      ctxRegistry,
      overrides,
    });
  }

  return [moduleRef];
}

이 메서드가 트리 순회(DFS)를 수행합니다. AppModule의 imports 배열에 있는 모듈을 꺼내고, 그 모듈의 imports도 다시 스캔하고 — 이런 식으로 전체 모듈 트리를 탐색합니다. ctxRegistry 배열로 이미 방문한 모듈을 추적해서 순환 참조를 방지합니다.

예를 들어, 다음과 같은 모듈 구조가 있다고 가정해보겠습니다.

AppModule
├── imports: [AuthModule, BlogModule]
│
AuthModule
├── imports: [DatabaseModule]
│
BlogModule  
├── imports: [DatabaseModule, AuthModule]
│
DatabaseModule
├── imports: []

scanForModules()는 다음 순서로 모듈을 발견하고 등록합니다.

1. AppModule         → container.addModule(AppModule)        ✅ inserted
2. AuthModule        → container.addModule(AuthModule)       ✅ inserted
3. DatabaseModule    → container.addModule(DatabaseModule)   ✅ inserted
4. BlogModule        → container.addModule(BlogModule)       ✅ inserted
5. DatabaseModule    → container.addModule(DatabaseModule)   ❌ already exists
6. AuthModule        → container.addModule(AuthModule)       ❌ already exists

이미 등록된 모듈은 inserted: false로 반환되어 중복 등록을 막습니다. 이 과정이 끝나면 NestContainer.modules Map에 모든 모듈이 등록된 상태가 됩니다.

3-2. scanModulesForDependencies() — 각 모듈의 내부 구성 등록

// packages/core/scanner.ts — scanModulesForDependencies() (간략화)
public async scanModulesForDependencies(
  modules: Map<string, Module> = this.container.getModules(),
) {
  for (const [token, moduleRef] of modules) {
    // 각 모듈에 대해:
    await this.reflectImports(moduleRef.metatype, token);     // imports 등록
    this.reflectProviders(moduleRef.metatype, token);          // providers 등록
    this.reflectControllers(moduleRef.metatype, token);        // controllers 등록
    this.reflectExports(moduleRef.metatype, token);            // exports 등록
  }
}

첫 번째 스캔(scanForModules)이 모듈 자체를 등록했다면, 두 번째 스캔(scanModulesForDependencies)은 각 모듈 안에 선언된 것들을 등록합니다. @Module() 데코레이터에 넣은 providers, controllers, exports 배열을 Reflect.getMetadata()로 읽어서 컨테이너에 집어넣습니다.

// reflectProviders의 핵심 동작
public reflectProviders(module: Type<any>, token: string) {
  // @Module() 데코레이터에서 'providers' 메타데이터 추출
  const providers = [
    ...this.reflectMetadata(MODULE_METADATA.PROVIDERS, module),
    ...this.container.getDynamicMetadataByToken(token, MODULE_METADATA.PROVIDERS),
  ];

  // 각 provider를 컨테이너에 등록
  providers.forEach(provider => {
    this.insertProvider(provider, token);
    this.reflectDynamicMetadata(provider, token); // Guards, Pipes 등 enhancer 반영
  });
}

reflectMetadata()는 결국 Reflect.getMetadata(key, metatype)를 호출합니다. 이것이 가능한 이유는 @Module() 데코레이터가 미리 메타데이터를 저장해두었기 때문입니다. 데코레이터와 메타데이터의 관계는 2편에서 자세히 다루겠습니다.

3-3. 스캔 완료 후 컨테이너 상태

두 번의 스캔이 끝나면 NestContainer는 이런 상태가 됩니다.

NestContainer.modules (Map)
├── "AppModule_token"
│   ├── providers: [AppService]
│   ├── controllers: [AppController]
│   └── exports: []
├── "AuthModule_token"
│   ├── providers: [AuthService, JwtService]
│   ├── controllers: [AuthController]
│   └── exports: [AuthService]
├── "DatabaseModule_token"
│   ├── providers: [DatabaseService]
│   ├── controllers: []
│   └── exports: [DatabaseService]
└── "BlogModule_token"
    ├── providers: [BlogService]
    ├── controllers: [BlogController]
    └── exports: []

중요한 점은, 이 시점에서 인스턴스는 아직 생성되지 않았습니다. 컨테이너에는 "어떤 클래스가 있는지"만 등록된 상태입니다. 실제 new AuthService()는 다음 단계에서 이루어집니다.

4. InstanceLoader — 의존성 그래프를 실체로 만들다

📎 packages/core/injector/instance-loader.ts

스캔이 끝나면 InstanceLoader가 등장합니다. 이 클래스는 컨테이너에 등록된 모든 Provider, Controller, Injectable의 인스턴스를 실제로 생성합니다.

// packages/core/injector/instance-loader.ts (간략화)
export class InstanceLoader<TInjector extends Injector = Injector> {
  constructor(
    private readonly container: NestContainer,
    private readonly injector: TInjector,
    private readonly graphInspector: GraphInspector,
  ) {}

  public async createInstancesOfDependencies() {
    const modules = this.container.getModules();

    // Phase 1: 프로토타입 생성 (의존성 순서 파악)
    this.createPrototypes(modules);

    // Phase 2: 실제 인스턴스 생성
    try {
      await this.createInstances(modules);
    } catch (err) {
      this.graphInspector.inspectModules(modules);
      throw err;
    }
    this.graphInspector.inspectModules(modules);
  }

  private async createInstances(modules: Map<string, Module>) {
    await Promise.all(
      [...modules.values()].map(async (moduleRef) => {
        // 각 모듈에 대해 병렬로:
        await this.createInstancesOfProviders(moduleRef);     // Provider 인스턴스
        await this.createInstancesOfInjectables(moduleRef);   // Injectable 인스턴스
        await this.createInstancesOfControllers(moduleRef);   // Controller 인스턴스
      }),
    );
  }
}

두 가지 페이즈(Phase)로 나뉘어 있다는 점이 눈에 띕니다.

Phase하는 일목적
Phase 1: Prototype각 클래스의 빈 래퍼 객체 생성순환 의존성 감지 및 의존성 그래프 구축
Phase 2: Instance실제 생성자 호출, 의존성 주입런타임에 사용할 진짜 인스턴스 생성

4-1. Injector — 실제 인스턴스 생성의 핵심

📎 packages/core/injector/injector.ts

InstanceLoader는 실제로는 Injector 클래스에 인스턴스 생성을 위임합니다.

// packages/core/injector/injector.ts — loadProvider() (간략화)
public async loadProvider(
  wrapper: InstanceWrapper,
  moduleRef: Module,
  contextId?,
  inquirer?,
) {
  const providers = moduleRef.providers;
  await this.loadInstance(wrapper, providers, moduleRef, contextId, inquirer);
  await this.loadEnhancersPerContext(wrapper, contextId, wrapper);
}

여기서 가장 중요한 것은 의존성 해석(dependency resolution) 과정입니다. Injector는 resolveConstructorParams() 메서드에서 TypeScript 컴파일러가 생성한 메타데이터를 읽어서 생성자에 어떤 의존성이 필요한지 파악합니다.

// 의존성 해석의 핵심 흐름 (개념적으로 표현)
private async resolveConstructorParams(wrapper, moduleRef, ...) {
  // 1. TypeScript가 자동 생성한 생성자 파라미터 타입 정보 읽기
  const paramTypes = Reflect.getMetadata(PARAMTYPES_METADATA, type);
  // paramTypes = [AuthService, JwtService] (예시)

  // 2. 각 파라미터 타입에 해당하는 Provider를 컨테이너에서 찾기
  const resolvedParams = await Promise.all(
    paramTypes.map(param => this.lookupComponent(param, moduleRef))
  );

  // 3. 찾은 인스턴스들을 생성자에 전달
  return resolvedParams;
}

이 과정에서 Reflect.getMetadata(PARAMTYPES_METADATA, type)가 핵심입니다. PARAMTYPES_METADATA는 'design:paramtypes'라는 키인데, 이것은 TypeScript 컴파일러가 emitDecoratorMetadata: true 옵션이 켜져 있을 때 자동으로 생성하는 메타데이터입니다. 이 메커니즘은 2편에서 깊게 다루겠습니다.

4-2. lookupComponent() — Provider 검색 전략

// packages/core/injector/injector.ts — lookupComponent() (간략화)
private lookupComponent(
  name: string | Type<any>,
  moduleRef: Module,
  ...
): InstanceWrapper {
  // 1. 현재 모듈의 providers에서 찾기
  const providers = moduleRef.providers;
  if (name && providers.has(name)) {
    return providers.get(name);
  }

  // 2. 못 찾으면 부모 모듈(imports)에서 찾기
  return this.lookupComponentInParentModules(name, moduleRef, ...);
}

NestJS의 DI는 모듈 스코프를 가집니다. Provider를 먼저 현재 모듈에서 찾고, 없으면 import한 모듈에서 찾습니다. 이것이 @Module({ exports: [SomeService] })를 해줘야 다른 모듈에서 사용할 수 있는 이유입니다.

만약 어디에서도 Provider를 찾지 못하면, 우리가 자주 보는 이 에러가 발생합니다.

Error: Nest can't resolve dependencies of the BlogService (?).
Please make sure that the argument "AuthService" at index [0] 
is available in the BlogModule context.

이제 이 에러가 왜, 어디서 발생하는지 정확히 알 수 있습니다. Injector.lookupComponent()가 현재 모듈과 부모 모듈을 모두 뒤졌지만 해당 Provider를 찾지 못한 것입니다.

5. Express 어댑터는 언제 바인딩되는가

지금까지 DI 컨테이너와 모듈 스캔에 집중했는데, 한 가지 빠진 것이 있습니다. 우리가 실제로 HTTP 요청을 처리하는 Express(또는 Fastify) 서버는 언제 생성되는 걸까요?

5-1. createHttpAdapter() — Express 어댑터 생성

NestFactory.create()의 첫 번째 단계에서 이미 HTTP 어댑터가 결정됩니다.

// packages/core/nest-factory.ts — createHttpAdapter()
private createHttpAdapter<T = any>(httpServer?: T): AbstractHttpAdapter {
  const { ExpressAdapter } = loadAdapter(
    '@nestjs/platform-express',
    'HTTP',
    () => require('@nestjs/platform-express'),
  );
  return new ExpressAdapter(httpServer);
}

별도의 어댑터를 전달하지 않으면, 기본적으로 @nestjs/platform-express를 require()해서 Express 어댑터를 생성합니다. 이것이 NestJS가 기본적으로 Express를 사용하는 이유입니다.

Fastify를 사용하려면 create()에 명시적으로 전달해야 합니다.

// Fastify 사용 시
import { FastifyAdapter } from '@nestjs/platform-fastify';
const app = await NestFactory.create(AppModule, new FastifyAdapter());

이 패턴이 가능한 이유는 NestJS가 AbstractHttpAdapter라는 추상 클래스를 두고, Express와 Fastify가 각각 이를 구현하는 어댑터 패턴(Adapter Pattern)을 사용하기 때문입니다.

5-2. app.listen()이 실제로 하는 일

📎 packages/core/nest-application.ts

NestFactory.create()가 반환한 app 객체에서 listen()을 호출하면 무슨 일이 일어날까요?

// packages/core/nest-application.ts — listen() (간략화)
public async listen(port: number | string, ...args: any[]): Promise<any> {
  // 1. 아직 init()이 안 됐으면 먼저 초기화
  if (!this.isInitialized) {
    await this.init();
  }

  return new Promise((resolve, reject) => {
    const errorHandler = (e: any) => {
      this.logger.error(e?.toString?.());
      reject(e);
    };
    this.httpServer.once('error', errorHandler);

    // 2. HTTP 어댑터의 listen() 호출 (실제 포트 바인딩)
    this.httpAdapter.listen(port, ...listenFnArgs, (...originalCallbackArgs) => {
      if (this.appOptions?.autoFlushLogs ?? true) {
        this.flushLogs();
      }
      const address = this.httpServer.address();
      if (address) {
        this.httpServer.removeListener('error', errorHandler);
        this.isListening = true;
        resolve(this.httpServer);
      }
    });
  });
}

listen()에서 핵심은 두 가지입니다.

첫째, init()이 아직 호출되지 않았으면 먼저 호출합니다. init()은 미들웨어 등록, 라우터 설정, 라이프사이클 훅 실행 등 추가 초기화를 담당합니다.

// packages/core/nest-application.ts — init()
public async init(): Promise<this> {
  if (this.isInitialized) return this;

  this.applyOptions();
  await this.httpAdapter?.init?.();

  // body parser 등록
  const useBodyParser = this.appOptions && this.appOptions.bodyParser !== false;
  useBodyParser && this.registerParserMiddleware();

  await this.registerModules();           // 미들웨어 모듈 등록
  await this.registerRouter();            // 라우트 매핑
  await this.callInitHook();              // OnModuleInit 라이프사이클 훅
  await this.registerRouterHooks();       // 에러 핸들러 등록
  await this.callBootstrapHook();         // OnApplicationBootstrap 훅

  this.isInitialized = true;
  return this;
}

둘째, this.httpAdapter.listen(port, ...)가 실제로 Node.js의 http.Server.listen()을 호출합니다. Express 어댑터의 경우, 이것은 결국 Express 앱의 listen()입니다. Node.js 시리즈에서 다뤘던 것처럼, 이 시점에서 libuv의 이벤트 루프가 TCP 소켓을 열고 연결을 대기하기 시작합니다.

6. 전체 부트스트랩 흐름 다이어그램

지금까지 추적한 전체 흐름을 다이어그램으로 정리하겠습니다.

NestFactory.create(AppModule)
│
├─ 1. HTTP 어댑터 결정
│     └─ createHttpAdapter()
│         └─ new ExpressAdapter()  (기본값)
│
├─ 2. ApplicationConfig 생성
│     └─ new ApplicationConfig()
│
├─ 3. DI 컨테이너 생성
│     └─ new NestContainer(config, options)
│         └─ modules = new ModulesContainer()  (빈 Map)
│
├─ 4. initialize(AppModule, container, ...)
│     │
│     ├─ 4a. DependenciesScanner 생성
│     │     └─ new DependenciesScanner(container, ...)
│     │
│     ├─ 4b. scanner.scan(AppModule)
│     │     ├─ registerCoreModule()        — 내부 코어 모듈 등록
│     │     ├─ scanForModules(AppModule)   — 모듈 트리 DFS 순회
│     │     │    ├─ container.addModule(AppModule)
│     │     │    ├─ container.addModule(AuthModule)
│     │     │    ├─ container.addModule(DatabaseModule)
│     │     │    └─ container.addModule(BlogModule)
│     │     ├─ scanModulesForDependencies()— providers/controllers 등록
│     │     │    ├─ reflectProviders()     — @Module의 providers 읽기
│     │     │    ├─ reflectControllers()   — @Module의 controllers 읽기
│     │     │    └─ reflectExports()       — @Module의 exports 읽기
│     │     ├─ calculateModulesDistance()  — 위상 정렬
│     │     └─ bindGlobalScope()           — @Global() 모듈 바인딩
│     │
│     ├─ 4c. InstanceLoader 생성
│     │     └─ new InstanceLoader(container, injector, ...)
│     │
│     └─ 4d. instanceLoader.createInstancesOfDependencies()
│           ├─ Phase 1: createPrototypes()    — 래퍼 객체 생성
│           └─ Phase 2: createInstances()     — 실제 인스턴스 생성
│                ├─ createInstancesOfProviders()
│                │    └─ injector.loadProvider()
│                │         └─ Reflect.getMetadata('design:paramtypes', ...)
│                │              → 생성자 파라미터 타입 읽기
│                │              → lookupComponent()로 의존성 찾기
│                │              → new Service(dep1, dep2, ...)
│                ├─ createInstancesOfInjectables()
│                └─ createInstancesOfControllers()
│
└─ 5. NestApplication 생성 & 반환
      ├─ new NestApplication(container, httpServer, ...)
      └─ createAdapterProxy(instance, httpServer)

─── ─── ─── ─── ─── ─── ─── ─── ─── ───

app.listen(3000)
│
├─ init() (아직 안 했으면)
│   ├─ httpAdapter.init()
│   ├─ registerParserMiddleware()     — body parser
│   ├─ registerModules()              — 미들웨어 등록
│   ├─ registerRouter()               — 라우트 매핑
│   ├─ callInitHook()                 — OnModuleInit
│   └─ callBootstrapHook()            — OnApplicationBootstrap
│
└─ httpAdapter.listen(3000)
    └─ Express app.listen(3000)
        └─ Node.js http.Server.listen(3000)
            └─ libuv: TCP 소켓 열기 & 이벤트 루프 대기

7. 실전에서의 의미 — 자주 만나는 에러와 부트스트랩의 관계

이 부트스트랩 과정을 이해하면, NestJS를 사용하면서 자주 만나는 에러들의 원인을 정확히 짚을 수 있습니다.

에러 1: "Nest can't resolve dependencies"

Error: Nest can't resolve dependencies of the BlogService (?).
Please make sure that the argument "AuthService" at index [0] 
is available in the BlogModule context.

발생 지점: Phase 2의 Injector.resolveConstructorParams() → lookupComponent()

원인: BlogModule이 AuthModule을 import하지 않았거나, AuthModule이 AuthService를 export하지 않아서 lookupComponent()가 해당 Provider를 찾지 못한 것입니다.

// 해결: AuthModule에서 export하고, BlogModule에서 import
@Module({
  providers: [AuthService],
  exports: [AuthService],    // ← 이것이 빠져 있으면 다른 모듈에서 못 찾음
})
export class AuthModule {}

@Module({
  imports: [AuthModule],     // ← 이것이 빠져 있으면 스코프에 없음
  providers: [BlogService],
})
export class BlogModule {}

에러 2: 순환 의존성 (Circular Dependency)

Error: A circular dependency has been detected.

발생 지점: DependenciesScanner.scanForModules() 또는 Injector.loadInstance()

원인: 모듈 A가 모듈 B를 import하고, 모듈 B도 모듈 A를 import하는 경우입니다. scanForModules()의 ctxRegistry가 이를 감지합니다.

// 해결: forwardRef() 사용
@Module({
  imports: [forwardRef(() => AuthModule)],
})
export class UserModule {}

에러 3: Provider가 undefined

발생 지점: Phase 1의 프로토타입 생성 시점

원인: @Injectable() 데코레이터를 빼먹었거나, tsconfig.json의 emitDecoratorMetadata가 꺼져 있는 경우입니다. 이 경우 Reflect.getMetadata('design:paramtypes', ...)가 undefined를 반환해서 의존성을 해석할 수 없게 됩니다.

8. 정리 — NestFactory.create() 한 줄이 거치는 5단계

돌이켜보면, NestFactory.create(AppModule)이라는 한 줄은 다음 5단계를 압축한 것이었습니다.

단계핵심 클래스하는 일비유
1. 어댑터 결정ExpressAdapterHTTP 서버 프레임워크 선택건물의 외벽 결정
2. 설정 생성ApplicationConfig전역 설정 객체 생성건물의 설계도
3. 컨테이너 생성NestContainerDI 컨테이너(빈 Map) 생성빈 수납장 준비
4a. 모듈 스캔DependenciesScanner모듈 트리 DFS 순회 + 의존성 등록수납장에 라벨 붙이기
4b. 인스턴스 생성InstanceLoader + InjectorProvider/Controller 인스턴스 생성라벨에 맞는 물건 넣기
5. 앱 반환NestApplication초기화된 앱 객체 반환완성된 건물 인도

Node.js 시리즈에서 require() 한 줄 뒤에 모듈 해석, 파일 읽기, V8 컴파일이 숨어 있던 것처럼, NestJS의 NestFactory.create() 한 줄 뒤에는 컨테이너 생성, 모듈 스캔, 의존성 해석, 인스턴스 생성이라는 정교한 과정이 숨어 있었습니다.

이 과정을 이해하고 나면, NestJS의 에러 메시지가 훨씬 명확하게 읽힙니다. "어떤 단계에서, 어떤 클래스가, 왜 실패했는지"를 추적할 수 있게 됩니다.

다음 글 미리보기: 이번 글에서 Reflect.getMetadata('design:paramtypes', ...)가 DI의 핵심이라는 것을 확인했습니다. 그런데 이 메타데이터는 누가, 언제 저장하는 걸까요? 2편에서는 @Injectable() 데코레이터의 실체를 파헤치고, TypeScript의 emitDecoratorMetadata가 컴파일 타임에 어떤 코드를 생성하는지, 그리고 reflect-metadata 라이브러리가 어떻게 동작하는지 추적합니다.

NestJSNestFactory부트스트랩DI 컨테이너ExpressTypeScript

관련 글

DI 컨테이너는 어떻게 의존성을 해결하는가 — NestJS 소스코드 추적 (3편)

@Injectable()이 메타데이터를 저장하는 것과 실제로 인스턴스를 생성해서 주입하는 것은 다른 문제입니다. NestContainer, InstanceLoader, Injector — 세 클래스가 협력하여 의존성을 해결하는 과정을 NestJS 소스코드에서 직접 추적합니다.

관련도 95%

@Module()은 어떻게 앱을 조립하는가 — NestJS 소스코드 추적 (4편)

NestJS 앱은 하나의 거대한 provider 목록이 아니라, @Module()로 나뉜 여러 모듈의 조합입니다. @Module() 데코레이터가 메타데이터를 저장하고, DependenciesScanner가 모듈 그래프를 구축하는 전 과정을 소스코드에서 추적합니다.

관련도 95%

NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)

이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.

관련도 93%