NestJS Lifecycle Hooks 심화

NestJS Lifecycle Hooks란?

NestJS는 애플리케이션과 모듈의 생명주기(Lifecycle)를 제어하는 훅 인터페이스를 제공합니다. DB 연결 초기화, 캐시 워밍업, 외부 서비스 등록, Graceful Shutdown 시 리소스 정리 등 서버 시작/종료 시점의 로직을 정확한 타이밍에 실행할 수 있습니다.

이 글에서는 7가지 Lifecycle Hook의 실행 순서, 각 훅의 실전 활용 패턴, 비동기 초기화, Graceful Shutdown 구현, 그리고 흔한 실수와 디버깅 기법을 다룹니다.

Lifecycle Hook 실행 순서

NestJS 앱이 부트스트랩될 때 다음 순서로 훅이 호출됩니다:

1. Constructor           → 의존성 주입 (DI)
2. onModuleInit()        → 모듈 초기화 완료 직후
3. onApplicationBootstrap() → 모든 모듈 초기화 후, 리스닝 시작 전
--- 앱 실행 중 ---
4. onModuleDestroy()     → 종료 시그널 수신 후
5. beforeApplicationShutdown(signal?) → 연결 종료 전
6. onApplicationShutdown(signal?) → 앱 완전 종료 직전

각 훅은 모듈 등록 순서대로 실행됩니다. 종료 훅은 역순으로 실행됩니다.

각 Hook 상세 분석 및 실전 패턴

onModuleInit: 서비스 초기화

모듈 내 모든 의존성이 해결된 직후 호출됩니다. DB 연결, 캐시 워밍업, 외부 SDK 초기화에 사용합니다.

import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  private readonly logger = new Logger(PrismaService.name);

  async onModuleInit() {
    // Prisma 연결 초기화 — 비동기 지원
    await this.$connect();
    this.logger.log('Prisma DB 연결 완료');

    // Middleware 등록 (쿼리 로깅)
    this.$use(async (params, next) => {
      const before = Date.now();
      const result = await next(params);
      const after = Date.now();
      this.logger.debug(
        `${params.model}.${params.action} — ${after - before}ms`,
      );
      return result;
    });
  }
}

핵심: onModuleInitPromise를 반환하면 NestJS가 완료될 때까지 기다린 후 다음 단계로 넘어갑니다. 이 점이 constructor와의 가장 큰 차이입니다.

onApplicationBootstrap: 앱 전체 준비 완료

모든 모듈이 초기화된 후 호출됩니다. 서비스 디스커버리 등록, 헬스체크 활성화 등 앱 전체가 준비된 상태에서 해야 하는 작업에 적합합니다.

@Injectable()
export class ServiceRegistryService implements OnApplicationBootstrap {
  constructor(
    private readonly consul: ConsulService,
    private readonly config: ConfigService,
  ) {}

  async onApplicationBootstrap() {
    const port = this.config.get<number>('PORT', 3000);
    const serviceId = `my-api-${process.pid}`;

    await this.consul.register({
      id: serviceId,
      name: 'my-api',
      address: '0.0.0.0',
      port,
      check: {
        http: `http://localhost:${port}/health`,
        interval: '10s',
        deregistercriticalserviceafter: '30s',
      },
    });

    Logger.log(`Consul 등록 완료: ${serviceId}`, 'Bootstrap');
  }
}

onModuleDestroy + onApplicationShutdown: Graceful Shutdown

종료 훅을 사용하려면 main.ts에서 반드시 enableShutdownHooks()를 호출해야 합니다.

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 종료 훅 활성화 — 이것 없으면 onModuleDestroy 등이 호출 안 됨!
  app.enableShutdownHooks();

  await app.listen(3000);
}
bootstrap();
@Injectable()
export class GracefulShutdownService
  implements OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown
{
  private readonly logger = new Logger(GracefulShutdownService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly redis: RedisService,
    private readonly consul: ConsulService,
  ) {}

  // 1단계: 진행 중인 작업 완료 대기
  async onModuleDestroy() {
    this.logger.warn('종료 시작 — 신규 요청 수락 중지');
    // BullMQ Worker 등 백그라운드 잡 종료
  }

  // 2단계: 외부 연결 정리 (signal 인자로 SIGTERM/SIGINT 구분 가능)
  async beforeApplicationShutdown(signal?: string) {
    this.logger.warn(`외부 연결 정리 시작 (signal: ${signal})`);

    // Consul 등록 해제
    await this.consul.deregister(`my-api-${process.pid}`);

    // 진행 중인 요청이 완료될 시간 확보
    await new Promise(resolve => setTimeout(resolve, 5000));
  }

  // 3단계: 최종 정리
  async onApplicationShutdown(signal?: string) {
    this.logger.warn('최종 리소스 정리');
    await this.prisma.$disconnect();
    await this.redis.quit();
    this.logger.warn('모든 연결 종료 완료');
  }
}

DI Scope와 결합하면 REQUEST 스코프 프로바이더의 생명주기도 세밀하게 제어할 수 있습니다.

모듈 간 초기화 순서 제어

Lifecycle Hook은 imports 배열의 순서대로 실행됩니다. 의존 관계가 있는 초기화에서는 모듈 순서가 중요합니다.

@Module({
  imports: [
    ConfigModule,      // 1번째: 설정 로드
    DatabaseModule,    // 2번째: DB 연결 (ConfigModule에 의존)
    CacheModule,       // 3번째: 캐시 워밍업 (DB 필요)
    AppModule,         // 4번째: 비즈니스 로직
  ],
})
export class RootModule {}

// 종료 시에는 역순:
// AppModule → CacheModule → DatabaseModule → ConfigModule

실전 패턴: 캐시 워밍업

@Injectable()
export class CacheWarmerService implements OnModuleInit {
  private readonly logger = new Logger(CacheWarmerService.name);

  constructor(
    private readonly productRepo: ProductRepository,
    private readonly cache: CacheManager,
  ) {}

  async onModuleInit() {
    const start = Date.now();

    // 인기 상품 Top 100 캐시 프리로딩
    const popular = await this.productRepo.findPopular(100);
    await Promise.all(
      popular.map(p =>
        this.cache.set(`product:${p.id}`, p, { ttl: 3600 }),
      ),
    );

    // 카테고리 트리 캐시
    const categories = await this.productRepo.getCategoryTree();
    await this.cache.set('categories:tree', categories, { ttl: 7200 });

    this.logger.log(
      `캐시 워밍업 완료: ${popular.length}개 상품, ` +
      `${Date.now() - start}ms 소요`,
    );
  }
}

흔한 실수와 디버깅

1. enableShutdownHooks() 누락

// ❌ 종료 훅이 호출되지 않음
const app = await NestFactory.create(AppModule);
await app.listen(3000);

// ✅ 반드시 추가
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);

2. Constructor에서 비동기 작업

// ❌ constructor는 async가 안 됨 — Promise가 무시됨
constructor(private db: DatabaseService) {
  this.db.connect(); // Fire-and-forget!
}

// ✅ onModuleInit 사용
async onModuleInit() {
  await this.db.connect(); // 완료까지 대기
}

3. 순환 의존성에서의 훅 순서

forwardRef()로 순환 의존성을 해결한 경우, 초기화 순서가 예측 불가능할 수 있습니다. Dynamic ModuleforRootAsync 패턴으로 의존성을 명시적으로 표현하는 것이 안전합니다.

4. 디버깅: 훅 실행 추적

// 전역 인터셉터로 모든 훅 실행을 로깅
@Injectable()
export class LifecycleLogger
  implements OnModuleInit, OnModuleDestroy, OnApplicationBootstrap
{
  private readonly logger = new Logger(this.constructor.name);

  onModuleInit() {
    this.logger.verbose('→ onModuleInit');
  }
  onApplicationBootstrap() {
    this.logger.verbose('→ onApplicationBootstrap');
  }
  onModuleDestroy() {
    this.logger.verbose('→ onModuleDestroy');
  }
}

// 각 모듈에 providers로 추가하면 순서 추적 가능

마무리

NestJS Lifecycle Hooks는 앱의 시작과 종료를 예측 가능하게 만드는 핵심 메커니즘입니다. onModuleInit으로 비동기 초기화, onApplicationBootstrap으로 서비스 등록, beforeApplicationShutdown으로 Graceful Shutdown — 이 세 가지만 제대로 활용해도 프로덕션 안정성이 크게 향상됩니다. Pipe, Interceptor와 함께 NestJS의 요청 생명주기를 완전히 이해하면 어떤 상황에서도 정확한 지점에 로직을 배치할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux