Bootstrap & Lifecycle — 앱이 살아나는 6단계

작성일:2026.05.09|조회수:6

Bootstrap & Lifecycle — 앱이 살아나는 6단계

bootstrapModule — 동기식 기반 작업

bootstrap의 출발점은 동기 함수 bootstrapModule()이다. 이 함수는 그래프 컴파일과 컨테이너 등록을 책임진다. 여기에는 비동기가 없다. lifecycle hook이나 HTTP 연결 같은 부수 효과는 상위 레이어가 담당한다.

TS
export function bootstrapModule(
  rootModule: ModuleType,
  options: BootstrapModuleOptions = {},
): BootstrapResult {
  // 1. 모듈 그래프 컴파일 (DFS + 가시성 검증)
  const modules = compileModuleGraph(rootModule, options);
  const container = new Container();
  const policy = options.duplicateProviderPolicy ?? 'warn';

  // 2. 중복 provider 정책 처리 후 유효 provider 선택
  const effectiveProviders = selectEffectiveBootstrapProviders(
    modules, options.providers, rootModule, policy, options.logger,
  );

  // 3. runtime provider → module provider 순으로 등록
  if (effectiveProviders.runtimeProviders.length > 0) {
    container.register(...effectiveProviders.runtimeProviders);
  }
  if (effectiveProviders.moduleProviders.length > 0) {
    container.register(...effectiveProviders.moduleProviders);
  }

  // 4. controller, middleware도 등록
  registerControllers(container, modules);
  registerModuleMiddleware(container, modules);

  return { container, effectiveProviders, modules, rootModule };
}

여기서 만들어지는 것은 아직 “실행 중인 애플리케이션”이 아니라, 검증된 모듈 그래프와 초기화된 DI 컨테이너의 baseline이다. 즉 bootstrapModule()은 request dispatcher, adapter listen, readiness 전이 같은 런타임 동작을 시작하지 않는다. 대신 뒤쪽 단계가 믿고 사용할 수 있는 modules, container, effectiveProviders를 확정한다.

이 분리가 중요하다. 모듈 그래프가 틀렸거나 provider 등록 정책이 충돌한다면 lifecycle hook을 실행하기 전에 실패해야 한다. 반대로 이 함수가 성공했다면, 최소한 “어떤 provider가 컨테이너에 들어갈 것인가”와 “어떤 module record가 bootstrap의 기준이 될 것인가”는 이미 결정된 상태다.

provider 우선순위와 중복 정책

runtime provider와 module provider가 같은 토큰을 등록하면 runtime이 이긴다. selectEffectiveBootstrapProviders()가 이 경쟁을 정리한다.

TS
// runtime 토큰이 있으면 module provider는 등록 대상에서 제외
if (runtimeSingleTokens.has(entry.token) || ...) {
  return; // 이 module provider는 건너뜀
}

module 간 중복은 duplicateProviderPolicy로 제어한다.

runtime provider가 module provider보다 강하고, module 내에서는 나중에 등록된 것이 이긴다. runtime provider를 더 강하게 두는 이유는 프레임워크가 부트스트랩 과정에서 반드시 주입해야 하는 토큰들이 있기 때문이다.

예를 들어 logger, runtime container, compiled modules, HTTP adapter 같은 값은 사용자가 feature module에서 우연히 같은 token을 선언했다고 해서 덮이면 안 된다. 이런 token들은 앱 코드가 의존할 수 있는 runtime surface이면서, 동시에 runtime 자신의 일관성을 지키는 내부 계약이다.

그래서 이후 registerRuntimeBootstrapTokens()가 실행되면 RUNTIME_CONTAINER, COMPILED_MODULES, HTTP_APPLICATION_ADAPTER, PLATFORM_SHELL 같은 토큰이 lifecycle hook 전에 실제 컨테이너에 들어간다. OnModuleInit 안에서 이런 runtime token을 주입받을 수 있는 이유가 바로 이 순서 때문이다.

bootstrapApplication — 비동기 6단계 파이프라인

HTTP 앱을 위한 bootstrapApplication()은 비동기 파이프라인으로 구성된다. 각 단계는 timing 측정 옵션(diagnostics.timing: true)이 켜져 있으면 소요 시간을 기록한다.

TS
// 1. bootstrapModule() — 컴파일 + 등록
const bootstrapped = bootstrapModule(options.rootModule, { ... });

// 2. 런타임 토큰 등록 (RUNTIME_CONTAINER, HTTP_ADAPTER 등)
registerRuntimeBootstrapTokens(bootstrapped, adapter, platformShell);

// 3. lifecycle 인스턴스 해결 (singleton providers 병렬 resolve)
lifecycleInstances = await resolveBootstrapLifecycleInstances(bootstrapped);

// 4. lifecycle hooks 실행
await runBootstrapLifecycle(bootstrapped.modules, lifecycleInstances, logger, platformShell);

// 5. HTTP dispatcher 생성 (핸들러 매핑 + 에러 필터)
const dispatcher = createRuntimeDispatcher(bootstrapped, options, logger);

// 6. FluoApplication 인스턴스 반환
return new FluoApplication(...);

이 여섯 단계는 단순한 구현 순서가 아니라 failure boundary이기도 하다. bootstrap_module에서 실패하면 모듈 그래프나 provider 등록 문제이고, resolve_lifecycle_instances에서 실패하면 lifecycle 대상 singleton을 만드는 과정의 문제이며, create_dispatcher에서 실패하면 HTTP handler mapping이나 dispatcher 구성 문제에 가깝다. timing phase 이름이 그대로 디버깅 단서가 되는 셈이다.

또 하나 눈여겨볼 점은 dispatcher가 lifecycle hook 뒤에 만들어진다는 것이다. fluo는 request 처리 shell을 먼저 열어두고 provider 초기화를 기다리지 않는다. module graph, runtime token, lifecycle hook, platform shell startup이 끝난 뒤에야 HTTP 요청을 dispatch할 수 있는 구조를 만든다.

lifecycle 인스턴스 해결 — 병렬 실행

lifecycle hook은 singleton provider 인스턴스에서만 의미가 있다. resolveLifecycleInstances()는 singleton provider를 걸러내어 병렬로 해결한다.

TS
const resolutionResults = await Promise.allSettled(
  lifecycleEntries.map((entry) => entry.useValue ?? container.resolve(entry.token)),
);

Promise.allSettled를 쓰는 이유: 하나가 실패해도 나머지 해결을 완료한다. 에러는 수집해서 첫 번째 것을 던진다. 어떤 provider가 문제인지 파악하기 쉽다.

해결되는 provider 범위: runtime providers + module providers. 단, 다음은 제외한다.

useValue provider 중 lifecycle hook이 있는 것(onModuleInit 등이 구현된 객체)은 resolve 없이 그 값 자체가 lifecycle 인스턴스로 사용된다.

이 단계는 모든 provider를 eager하게 만드는 단계가 아니다. lifecycle hook을 가질 수 있고 singleton으로 안전하게 공유될 수 있는 대상만 미리 resolve한다. request scope나 transient provider를 여기서 만들어 버리면 scope 의미가 깨지고, multi: true나 alias provider를 lifecycle instance처럼 취급하면 실제 resolution 규칙과 어긋난다.

Promise.allSettled를 쓰는 점도 미묘하게 중요하다. 병렬로 singleton을 만들되, 하나가 실패했다고 나머지 promise를 즉시 버리지 않는다. 가능한 resolution을 끝까지 관찰한 뒤 첫 번째 에러를 올리므로, bootstrap 실패 정리 단계에서 이미 만들어진 lifecycle instance를 더 일관되게 다룰 수 있다.

lifecycle hook 실행 순서

bootstrap 시와 shutdown 시의 실행 순서가 의도적으로 다르다. lifecycle 인스턴스의 순서는 provider 등록 순서를 따른다. 모듈 그래프가 post-order로 정렬되어 있으므로, 의존성이 먼저 init되고 나중에 destroy된다. LIFO(Last In, First Out) 원칙.

TS
// bootstrap — 정방향
async function runBootstrapHooks(instances: unknown[]): Promise {
  // 모든 인스턴스의 onModuleInit을 먼저 다 돌리고
  for (const instance of instances) {
    if (isOnModuleInit(instance)) await instance.onModuleInit();
  }
  // 그 다음 onApplicationBootstrap을 다 돌린다
  for (const instance of instances) {
    if (isOnApplicationBootstrap(instance)) await instance.onApplicationBootstrap();
  }
}

// shutdown — 역방향
async function runShutdownHooks(instances: readonly unknown[], signal?: string): Promise {
  // onModuleDestroy 역순
  for (const instance of [...instances].reverse()) {
    if (isOnModuleDestroy(instance)) await instance.onModuleDestroy();
  }
  // onApplicationShutdown 역순
  for (const instance of [...instances].reverse()) {
    if (isOnApplicationShutdown(instance)) await instance.onApplicationShutdown(signal);
  }
}

bootstrap hook은 인스턴스마다 onModuleInit()onApplicationBootstrap()을 연달아 호출하지 않는다. 먼저 모든 대상의 onModuleInit()을 끝낸 뒤, 그 다음 모든 대상의 onApplicationBootstrap()을 실행한다. 이 차이 덕분에 onApplicationBootstrap()은 “각 모듈 초기화가 모두 끝난 뒤”라는 더 넓은 의미를 가질 수 있다.

shutdown은 반대로 역순이다. 나중에 올라온 리소스부터 먼저 내려야 의존하는 쪽이 아직 살아 있는 리소스를 정리 과정에서 사용할 수 있다. 데이터베이스 연결, message consumer, background worker처럼 서로 기대는 리소스가 많아질수록 이 LIFO 구조가 중요해진다.

onApplicationShutdown에는 프로세스 시그널이 전달된다.

TS
@Inject()
class DatabaseService implements OnApplicationShutdown {
  async onApplicationShutdown(signal?: string) {
    console.log(`Shutting down on signal: ${signal}`);
    await this.connection.close();
  }
}

bootstrap 실패 시 정리

bootstrap 도중 어디서든 에러가 나면 이미 만들어진 리소스를 정리한다.

TS
} catch (error: unknown) {
  logger.error('Failed to bootstrap...', error, 'FluoFactory');

  await runBootstrapFailureCleanup({
    container: bootstrappedContainer,
    lifecycleInstances,
    modules: bootstrappedModules,
    runtimeCleanup,
    scope: 'application',
  });

  throw error; // 원본 에러 재던짐
}

runBootstrapFailureCleanup()은 세 가지를 역순으로 처리한다.

  1. runtimeCleanup 콜백 실행
  2. runShutdownHooks(lifecycleInstances, 'bootstrap-failed')
  3. container.dispose()

정리 중 에러가 나도 삼켜서 로그만 남기고 원본 에러를 다시 던진다. 이미 실패한 bootstrap에서 정리 에러가 원본 에러를 덮어쓰면 디버깅이 힘들어지기 때문이다.

이 정책은 운영 관점에서 특히 중요하다. bootstrap 실패의 원인은 대개 잘못된 module graph, provider 생성 실패, platform startup 실패처럼 “처음 실패한 지점”에 있다. cleanup 중 adapter close나 shutdown hook이 추가로 실패할 수는 있지만, 그것이 원인을 덮어쓰면 장애 분석이 엉뚱한 곳으로 흐른다. fluo는 cleanup 실패를 기록하되, caller에게는 원래 bootstrap error를 돌려준다.

또한 이 경로에서도 shutdown hook은 bootstrap-failed라는 signal 값으로 호출된다. 서비스 입장에서는 정상적인 SIGTERM 종료와 “시작하다 실패해서 롤백되는 상황”을 구분할 수 있다.

ApplicationState — 세 가지 상태

애플리케이션은 세 가지 상태를 가진다. 닫힌 앱을 다시 열 수 없다. listen()은 이미 ready이면 no-op이다.

TS
const app = await fluoFactory.create(AppModule, { adapter });
// state: 'bootstrapped'

await app.listen();
// state: 'ready'

await app.close();
// state: 'closed'

bootstrappedready를 분리한 점도 중요하다. FluoFactory.create()가 성공했다는 것은 모듈 그래프, DI, lifecycle bootstrap이 끝났다는 뜻이지, HTTP port가 열렸다는 뜻은 아니다. 실제 트래픽 수신은 listen()이 readiness gate를 통과하고 adapter bind에 성공한 뒤에야 시작된다.

이 덕분에 테스트나 스크립트에서는 애플리케이션을 완전히 bootstrap한 뒤에도 네트워크를 열지 않고 dispatcher나 container를 다룰 수 있다. 반대로 production server에서는 ready 상태를 adapter listen 성공 이후로 늦춰, 준비되지 않은 인스턴스가 트래픽을 받는 일을 막는다.

FluoFactory.create vs createApplicationContext

두 팩토리 메서드의 차이는 HTTP 어댑터 유무다.

TS
// HTTP 없이 DI만 필요할 때
const ctx = await fluoFactory.createApplicationContext(AppModule);
const svc = await ctx.get(SomeService);
await svc.doWork();
await ctx.close();

createApplicationContext()는 “반쪽짜리 bootstrap”이 아니다. HTTP adapter와 dispatcher만 없을 뿐, module graph compile, runtime token registration, lifecycle instance resolution, bootstrap hook 실행은 동일한 spine을 탄다. 그래서 CLI 작업, migration, worker process처럼 HTTP listener가 필요 없는 실행 환경에서도 애플리케이션과 같은 DI/lifecycle 계약을 사용할 수 있다.

차이는 capability surface다. full application은 listen(), dispatch(), readiness state, adapter close까지 책임진다. application context는 get()close()만 노출한다. 같은 기반 위에 어떤 shell을 얹느냐가 다를 뿐, fluo가 별도의 context 전용 DI 엔진을 따로 유지하는 것은 아니다.

다음 파트에서는 이렇게 만들어진 컨테이너가 실제로 토큰을 어떻게 해결하는지, container.resolve() 내부를 추적한다.

더 읽어보기

댓글

댓글을 불러오는 중...