NestJS Standalone Application이란?
Standalone Application은 HTTP 서버를 띄우지 않고 NestJS의 DI 컨테이너만 활용하는 실행 모드입니다. CLI 도구, 크론 워커, 데이터 마이그레이션 스크립트, 이벤트 컨슈머 등 요청-응답 패턴이 아닌 작업에서 NestJS의 모듈 시스템, 의존성 주입, 설정 관리를 그대로 사용할 수 있습니다.
HTTP 서버 vs Standalone 비교
| 비교 항목 | NestFactory.create() | NestFactory.createApplicationContext() |
|---|---|---|
| HTTP 서버 | Express/Fastify 시작 | 없음 |
| DI 컨테이너 | ✅ | ✅ |
| Lifecycle Hooks | ✅ | ✅ (수동 init 필요) |
| 메모리 사용 | ~80MB+ | ~30MB |
| 시작 시간 | ~2초 | ~0.5초 |
| 적합한 상황 | API 서버, WebSocket | CLI, 크론, 워커, 스크립트 |
기본 사용법: createApplicationContext
// standalone.ts — 최소 예시
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { UserService } from './user/user.service';
async function bootstrap() {
// HTTP 서버 없이 DI 컨테이너만 생성
const app = await NestFactory.createApplicationContext(AppModule);
// Lifecycle hooks 수동 트리거
await app.init();
// DI로 서비스 가져오기
const userService = app.get(UserService);
const users = await userService.findAll();
console.log(`총 ${users.length}명의 사용자`);
// 정리: onModuleDestroy, DB 커넥션 해제 등
await app.close();
process.exit(0);
}
bootstrap().catch((err) => {
console.error(err);
process.exit(1);
});
실전 1: CLI 도구 (Commander.js 통합)
NestJS DI와 Commander.js를 결합한 타입 안전한 CLI 도구 패턴입니다.
// cli.ts
import { NestFactory } from '@nestjs/core';
import { Command } from 'commander';
import { CliModule } from './cli.module';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(CliModule, {
logger: ['error', 'warn'], // CLI에서는 로그 최소화
});
await app.init();
const program = new Command();
program
.name('myapp')
.version('1.0.0');
// 사용자 관리 명령어
program
.command('user:create')
.description('새 사용자 생성')
.requiredOption('-e, --email <email>', '이메일')
.requiredOption('-n, --name <name>', '이름')
.option('-r, --role <role>', '역할', 'user')
.action(async (opts) => {
const userService = app.get(UserService);
const user = await userService.create({
email: opts.email,
name: opts.name,
role: opts.role,
});
console.log(`✅ 사용자 생성: ${user.id}`);
await app.close();
});
// 데이터 시딩
program
.command('db:seed')
.description('테스트 데이터 시딩')
.option('-c, --count <count>', '생성할 레코드 수', '100')
.action(async (opts) => {
const seeder = app.get(SeederService);
await seeder.seed(parseInt(opts.count));
console.log(`✅ ${opts.count}건 시딩 완료`);
await app.close();
});
// 캐시 관리
program
.command('cache:clear')
.description('Redis 캐시 전체 삭제')
.option('-p, --pattern <pattern>', '패턴', '*')
.action(async (opts) => {
const cacheService = app.get(CacheService);
const count = await cacheService.clearByPattern(opts.pattern);
console.log(`✅ ${count}개 캐시 키 삭제`);
await app.close();
});
await program.parseAsync(process.argv);
}
bootstrap();
// cli.module.ts — CLI 전용 모듈 (HTTP 관련 제거)
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService) => ({
type: 'postgres',
url: config.get('DATABASE_URL'),
autoLoadEntities: true,
}),
inject: [ConfigService],
}),
UserModule,
CacheModule,
SeederModule,
// ❌ AuthModule, ThrottlerModule 등 HTTP 전용 모듈 제외
],
})
export class CliModule {}
실전 2: 크론 워커 (독립 프로세스)
API 서버와 분리된 독립 크론 워커 프로세스입니다. K8s CronJob으로 실행하기에 적합합니다.
// worker.ts — 크론 워커 엔트리
import { NestFactory } from '@nestjs/core';
import { WorkerModule } from './worker.module';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(WorkerModule);
await app.init();
const taskName = process.env.TASK_NAME;
const taskRegistry = app.get(TaskRegistryService);
try {
const task = taskRegistry.get(taskName);
if (!task) {
throw new Error(`알 수 없는 작업: ${taskName}`);
}
console.log(`🔄 작업 시작: ${taskName}`);
const startTime = Date.now();
await task.execute();
const duration = Date.now() - startTime;
console.log(`✅ 작업 완료: ${taskName} (${duration}ms)`);
} catch (error) {
console.error(`❌ 작업 실패: ${taskName}`, error);
process.exitCode = 1;
} finally {
await app.close();
}
}
bootstrap();
// task-registry.service.ts
@Injectable()
export class TaskRegistryService {
private readonly tasks = new Map<string, TaskRunner>();
constructor(
private readonly moduleRef: ModuleRef,
) {}
onModuleInit() {
// 작업 등록
this.register('daily-report', DailyReportTask);
this.register('cleanup-expired', CleanupExpiredTask);
this.register('sync-external', SyncExternalTask);
this.register('send-digests', SendDigestsTask);
}
private register(name: string, taskClass: Type<TaskRunner>) {
const instance = this.moduleRef.get(taskClass, { strict: false });
this.tasks.set(name, instance);
}
get(name: string): TaskRunner | undefined {
return this.tasks.get(name);
}
}
// 작업 인터페이스
export interface TaskRunner {
execute(): Promise<void>;
}
@Injectable()
export class DailyReportTask implements TaskRunner {
constructor(
private readonly reportService: ReportService,
private readonly mailer: MailerService,
) {}
async execute() {
const report = await this.reportService.generateDaily();
await this.mailer.sendReport(report);
}
}
# K8s CronJob 매니페스트
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-report
spec:
schedule: "0 9 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: myapp:latest
command: ["node", "dist/worker.js"]
env:
- name: TASK_NAME
value: "daily-report"
envFrom:
- secretRef:
name: app-secrets
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
실전 3: 데이터 마이그레이션 스크립트
대량 데이터 변환이나 일회성 마이그레이션에 Standalone을 활용합니다.
// migrations/migrate-user-profiles.ts
import { NestFactory } from '@nestjs/core';
import { MigrationModule } from './migration.module';
async function migrate() {
const app = await NestFactory.createApplicationContext(MigrationModule);
await app.init();
const dataSource = app.get(DataSource);
const batchSize = 500;
let offset = 0;
let processed = 0;
console.log('🔄 사용자 프로필 마이그레이션 시작');
while (true) {
// 배치 단위로 조회
const users = await dataSource.query(
`SELECT id, legacy_profile FROM users
WHERE new_profile IS NULL
ORDER BY id LIMIT $1 OFFSET $2`,
[batchSize, offset],
);
if (users.length === 0) break;
// 트랜잭션 내에서 배치 업데이트
await dataSource.transaction(async (manager) => {
for (const user of users) {
const newProfile = transformProfile(user.legacy_profile);
await manager.query(
`UPDATE users SET new_profile = $1 WHERE id = $2`,
[JSON.stringify(newProfile), user.id],
);
}
});
processed += users.length;
offset += batchSize;
console.log(` 처리: ${processed}건`);
}
console.log(`✅ 마이그레이션 완료: 총 ${processed}건`);
await app.close();
}
migrate();
실전 4: 이벤트 컨슈머 워커
Kafka/SQS 메시지를 소비하는 장기 실행 워커 패턴입니다.
// consumer-worker.ts
import { NestFactory } from '@nestjs/core';
import { ConsumerModule } from './consumer.module';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(ConsumerModule);
await app.init();
const consumer = app.get(EventConsumerService);
// Graceful Shutdown 처리
const signals = ['SIGTERM', 'SIGINT'];
for (const signal of signals) {
process.on(signal, async () => {
console.log(`${signal} 수신 — 종료 시작`);
await consumer.stop();
await app.close();
process.exit(0);
});
}
// 메시지 소비 시작 (블로킹)
console.log('🔄 이벤트 컨슈머 시작');
await consumer.start();
}
bootstrap();
// event-consumer.service.ts
@Injectable()
export class EventConsumerService implements OnModuleDestroy {
private running = true;
constructor(
private readonly kafka: KafkaService,
private readonly orderHandler: OrderEventHandler,
private readonly paymentHandler: PaymentEventHandler,
) {}
async start() {
await this.kafka.subscribe(['order-events', 'payment-events']);
await this.kafka.consume(async (message) => {
if (!this.running) return;
const { topic, value } = message;
switch (topic) {
case 'order-events':
await this.orderHandler.handle(value);
break;
case 'payment-events':
await this.paymentHandler.handle(value);
break;
}
});
}
async stop() {
this.running = false;
await this.kafka.disconnect();
}
async onModuleDestroy() {
await this.stop();
}
}
모듈 분리 전략
API 서버와 Standalone 워커가 같은 코드베이스를 공유하면서 필요한 모듈만 로드하는 구조입니다.
// 모듈 계층 구조
src/
├── main.ts → NestFactory.create(AppModule)
├── cli.ts → NestFactory.createApplicationContext(CliModule)
├── worker.ts → NestFactory.createApplicationContext(WorkerModule)
├── consumer.ts → NestFactory.createApplicationContext(ConsumerModule)
│
├── modules/
│ ├── core/ # 공통: Config, DB, Cache, Logger
│ │ └── core.module.ts
│ ├── user/ # 도메인 모듈 (API + Worker 공유)
│ │ └── user.module.ts
│ ├── order/
│ │ └── order.module.ts
│ └── auth/ # HTTP 전용 (API만 사용)
│ └── auth.module.ts
│
├── app.module.ts # API: Core + 도메인 + Auth + 컨트롤러
├── cli.module.ts # CLI: Core + 도메인 (Auth 제외)
├── worker.module.ts # Worker: Core + 도메인 + Task
└── consumer.module.ts # Consumer: Core + 도메인 + Kafka
// core.module.ts — 모든 엔트리포인트가 공유
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({ /* ... */ }),
RedisModule.forRootAsync({ /* ... */ }),
LoggerModule,
],
exports: [TypeOrmModule, RedisModule, LoggerModule],
})
export class CoreModule {}
주의사항
- app.init() 호출 필수:
createApplicationContext만으로는 OnModuleInit 등 라이프사이클 훅이 실행되지 않습니다. 반드시await app.init()을 호출하세요. - app.close() 호출 필수: DB 커넥션, Redis 연결 등이 정리되지 않으면 프로세스가 종료되지 않습니다.
- HTTP 전용 모듈 제외: Guard, Interceptor, Pipe 등 HTTP 의존 프로바이더가 포함된 모듈을 import하면 불필요한 오버헤드가 발생합니다.
- 에러 시 exit code: CLI/크론에서는
process.exit(1)로 실패를 명시해야 K8s CronJob이 재시도합니다.
Standalone Application은 NestJS의 DI를 HTTP 너머의 모든 Node.js 실행 환경으로 확장합니다. NestJS 라이프사이클 훅 상세는 NestJS Lifecycle Hooks 심화 가이드를, K8s 크론잡 운영은 K8s Job·CronJob 배치 처리 글을 참고하세요.