NestJS Standalone Application 심화

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 배치 처리 글을 참고하세요.

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