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;
});
}
}
핵심: onModuleInit이 Promise를 반환하면 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 Module의 forRootAsync 패턴으로 의존성을 명시적으로 표현하는 것이 안전합니다.
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의 요청 생명주기를 완전히 이해하면 어떤 상황에서도 정확한 지점에 로직을 배치할 수 있습니다.