NestJS Lazy Module이란?
NestJS는 기본적으로 애플리케이션 시작 시 모든 모듈을 즉시 로드한다(Eager Loading). 모듈이 수십 개로 늘어나면 Cold Start 시간이 길어지고, 사용하지 않는 모듈까지 메모리에 올라간다. Lazy Module은 LazyModuleLoader를 사용하여 필요한 시점에 모듈을 동적 로드하는 패턴이다.
왜 Lazy Loading이 필요한가?
- 서버리스 환경: AWS Lambda, Cloud Functions에서 Cold Start를 줄여야 할 때
- 대규모 모놀리스: 50+ 모듈이 있지만, 요청 경로에 따라 일부만 사용할 때
- 무거운 의존성: ML 모델 로드, 대용량 설정 파싱 등 초기화 비용이 큰 모듈
- 선택적 기능: 특정 환경/설정에서만 활성화되는 모듈 (리포팅, 어드민 등)
기본 사용법
import { LazyModuleLoader } from '@nestjs/core';
@Injectable()
export class ReportService {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
async generateReport(type: string): Promise<Buffer> {
// 리포트 요청이 들어올 때만 모듈 로드
const { ReportModule } = await import('./report/report.module');
const moduleRef = await this.lazyModuleLoader.load(() => ReportModule);
// 로드된 모듈에서 서비스 가져오기
const reportGenerator = moduleRef.get(ReportGeneratorService);
return reportGenerator.generate(type);
}
}
핵심: LazyModuleLoader.load()는 모듈을 최초 호출 시 한 번만 초기화하고, 이후에는 캐시된 인스턴스를 반환한다.
실전 패턴: 조건부 모듈 로드
설정에 따라 모듈을 선택적으로 로드하는 패턴이다:
@Injectable()
export class NotificationService {
constructor(
private lazyModuleLoader: LazyModuleLoader,
private configService: ConfigService,
) {}
async send(userId: string, message: string): Promise<void> {
const channel = this.configService.get('NOTIFICATION_CHANNEL');
if (channel === 'slack') {
const { SlackModule } = await import('./channels/slack.module');
const moduleRef = await this.lazyModuleLoader.load(() => SlackModule);
const slack = moduleRef.get(SlackService);
await slack.send(userId, message);
} else if (channel === 'email') {
const { EmailModule } = await import('./channels/email.module');
const moduleRef = await this.lazyModuleLoader.load(() => EmailModule);
const email = moduleRef.get(EmailService);
await email.send(userId, message);
}
}
}
LazyModuleLoader vs Dynamic Module
Dynamic Module과 혼동하기 쉽지만 완전히 다른 개념이다:
| 항목 | Dynamic Module | Lazy Module |
|---|---|---|
| 로드 시점 | 앱 시작 시 (Eager) | 최초 호출 시 (Lazy) |
| 목적 | 설정값에 따른 모듈 구성 | 초기화 지연, 메모리 절약 |
| DI 컨테이너 | 글로벌 DI에 등록 | 별도 모듈 레퍼런스 |
| Controller 등록 | 가능 | 불가 (라우트 미등록) |
주의: Controller와 라우트
Lazy Module의 가장 중요한 제약이다. Lazy 로드된 모듈의 Controller는 라우트로 등록되지 않는다. NestJS는 앱 시작 시 모든 라우트를 결정하므로, 나중에 로드된 모듈의 Controller는 무시된다.
// ❌ Lazy Module의 Controller는 라우트 등록 안 됨
@Module({
controllers: [ReportController], // 이 Controller는 동작하지 않음
providers: [ReportService],
})
export class ReportModule {}
// ✅ Service만 제공하는 모듈로 설계
@Module({
providers: [ReportGeneratorService, PdfService, CsvService],
exports: [ReportGeneratorService],
})
export class ReportModule {}
Lazy Module은 Service 레이어 전용이다. HTTP 엔드포인트가 필요하면 Eager 모듈의 Controller에서 Lazy Module의 Service를 호출하는 구조로 설계하라.
서버리스 최적화: Cold Start 줄이기
// main.ts - 최소한의 모듈만 Eager 로드
@Module({
imports: [
CoreModule, // 필수: ConfigService, Logger
AuthModule, // 필수: 모든 요청에 인증 필요
// ReportModule, // ❌ Lazy로 전환
// AdminModule, // ❌ Lazy로 전환
// AnalyticsModule // ❌ Lazy로 전환
],
})
export class AppModule {}
// API Gateway 패턴: 라우트별 Lazy 로드
@Controller('reports')
export class ReportController {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
@Get(':type')
async getReport(@Param('type') type: string) {
const { ReportModule } = await import('./report/report.module');
const moduleRef = await this.lazyModuleLoader.load(() => ReportModule);
const service = moduleRef.get(ReportGeneratorService);
return service.generate(type);
}
}
캐싱과 성능
LazyModuleLoader.load()는 내부적으로 캐싱을 수행한다:
// 첫 번째 호출: 모듈 초기화 (~50-200ms)
const moduleRef1 = await this.lazyModuleLoader.load(() => HeavyModule);
// 두 번째 호출: 캐시 히트 (~0ms)
const moduleRef2 = await this.lazyModuleLoader.load(() => HeavyModule);
// moduleRef1 === moduleRef2 (같은 인스턴스)
첫 요청만 약간의 지연이 발생하고, 이후 요청은 즉시 응답한다. 워밍업이 필요하면 OnApplicationBootstrap 훅에서 미리 로드할 수도 있다:
@Injectable()
export class WarmupService implements OnApplicationBootstrap {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
async onApplicationBootstrap() {
// 앱 시작 후 백그라운드에서 미리 로드 (non-blocking)
setTimeout(async () => {
const { ReportModule } = await import('./report/report.module');
await this.lazyModuleLoader.load(() => ReportModule);
}, 5000);
}
}
테스트 전략
describe('ReportService', () => {
let service: ReportService;
let lazyModuleLoader: LazyModuleLoader;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
ReportService,
{
provide: LazyModuleLoader,
useValue: {
load: jest.fn().mockResolvedValue({
get: jest.fn().mockReturnValue({
generate: jest.fn().mockResolvedValue(Buffer.from('pdf')),
}),
}),
},
},
],
}).compile();
service = module.get(ReportService);
lazyModuleLoader = module.get(LazyModuleLoader);
});
it('should lazy load ReportModule', async () => {
const result = await service.generateReport('monthly');
expect(lazyModuleLoader.load).toHaveBeenCalled();
expect(result).toBeInstanceOf(Buffer);
});
});
사용 가이드라인
- Lazy로 전환할 후보: 리포팅, 어드민, 분석, 배치 처리, ML 추론 등 사용 빈도가 낮거나 초기화 비용이 큰 모듈
- Eager로 유지할 것: 인증, 로깅, 설정, DB 연결 등 모든 요청에 필요한 핵심 모듈
- Controller는 Eager에: 라우트 등록은 앱 시작 시에만 가능. Lazy Module은 Service만 제공
- 에러 처리 필수:
import()실패 시 적절한 에러 핸들링을 구현하라
정리
NestJS Lazy Module은 LazyModuleLoader로 모듈을 필요 시점에 동적 로드하는 패턴이다. 서버리스 Cold Start 최적화, 대규모 모놀리스 메모리 절약, 조건부 기능 로드에 효과적이다. Controller 등록 불가라는 핵심 제약을 이해하고, Service 레이어 전용으로 설계하는 것이 핵심이다.