Node
Nest.js
Nest Command

NestJS Commander

결론

nest-commander는 NestJS의 구조와 DI(Dependency Injection)를 활용하여 CLI 애플리케이션을 구축할 수 있게 해주는 라이브러리입니다. 내부적으로 Commander.js (opens in a new tab)를 기반으로 동작합니다. nest-commander GitHub (opens in a new tab)

"A package to bring building CLI applications to the Nest world with the same structure that you already know and love ❤️ Built on top of the popular Commander package."

핵심 개념

CommandRunner

모든 커맨드 클래스가 상속해야 하는 추상 클래스입니다. run() 메서드를 반드시 구현해야 하며, NestJS의 DI가 정상적으로 동작합니다. nest-commander README (opens in a new tab)

"Every command is seen as an @Injectable() by Nest, so your normal Dependency Injection still works as you would expect it to."

export abstract class CommandRunner {
  abstract run(
    passedParams: string[],
    options?: Record<string, any>
  ): Promise<void>;
}
  • passedParams: 옵션 플래그와 매칭되지 않은 인자들의 배열
  • options: @Option() 데코레이터로 정의된 옵션들의 파싱된 값

@Command() 데코레이터

CLI 커맨드의 메타데이터를 정의하는 클래스 데코레이터입니다. nest-commander API docs (opens in a new tab)

속성타입필수설명
namestring커맨드 이름
argumentsstring인자 정의. 필수는 <>, 선택은 []
descriptionstring커맨드 설명 (--help 출력 시 사용)
argsDescriptionRecord<string, string>각 인자에 대한 설명
optionsCommandOptionsCommander에 전달할 추가 옵션
subCommandsType<CommandRunner>[]서브커맨드 클래스 배열
aliasesstring[]커맨드 별칭

@Option() 데코레이터

커맨드에 옵션 플래그를 추가하는 메서드 데코레이터입니다. nest-commander README (opens in a new tab)

속성타입필수설명
flagsstring플래그 정의 (예: -n, --number [number])
descriptionstring옵션 설명
defaultValuestring | boolean기본값
choicesstring[] | true선택 가능한 값 제한

"The method that the @Option() is decorating is the custom parser passed to commander for how the value should be parsed."

실제 코드 분석

사용자가 제공한 코드를 분석해보겠습니다:

import { Command, CommandRunner } from 'nest-commander';
import { Logger } from '@nestjs/common';
import { parse } from 'date-fns';
import { getCurrentKSTDate } from 'src/helpers/locale-date.helper';
import { BestFanficsCronService } from './best-fanfics-cron.service';
 
@Command({
  name: 'create-best-fanfics',
  description:
    '특정 날짜 기준으로 베스트 팬픽들을 생성한다.\n\n 날짜를 argument로 받으며 해당 날짜는 local date로 취급하고 실행한다.\n주어진 date argument가 없으면 command가 실행된 local date의 전날 기준으로 실행한다.',
  arguments: '[date]',  // 선택적 인자
  argsDescription: {
    date: 'yyyy-MM-dd HH:mm:ss',
  },
})
export class CreateBestFanficsCommand extends CommandRunner {
  private readonly logger = new Logger(CreateBestFanficsCommand.name);
 
  constructor(private readonly bestFanficsCronService: BestFanficsCronService) {
    super();
  }
 
  async run(inputs: string[]): Promise<void> {
    const targetDate = inputs[0]
      ? parse(inputs[0], 'yyyy-MM-dd', new Date())
      : getCurrentKSTDate();
    try {
      await this.bestFanficsCronService.createBestFanficsBatch(targetDate);
    } catch (err) {
      this.logger.error(
        `Failed to create best fanfics batch: ${err.message}`,
        err.stack,
      );
    }
  }
}

동작 흐름

  1. 커맨드 실행: node dist/main create-best-fanfics 2024-01-15
  2. 인자 파싱: [date]가 선택적이므로 날짜가 없어도 실행 가능
  3. run() 호출: inputs[0]'2024-01-15' 전달 (없으면 빈 배열)
  4. DI 활용: BestFanficsCronService가 주입되어 비즈니스 로직 실행

CommandFactory

CLI 애플리케이션의 진입점입니다. NestFactory와 유사하게 동작합니다. nest-commander README (opens in a new tab)

import { CommandFactory } from 'nest-commander';
import { AppModule } from './app.module';
 
async function bootstrap() {
  await CommandFactory.run(AppModule);
}
 
bootstrap();

"Under the hood, CommandFactory will worry about calling NestFactory for you and calling app.close() when necessary, so you shouldn't need to worry about memory leaks there."

로깅 설정

// 에러 로그만 출력
await CommandFactory.run(AppModule, ['warn', 'error']);
 
// 커스텀 에러 핸들러
await CommandFactory.run(AppModule, {
  errorHandler: (err) => {
    console.error(err);
    process.exit(1);
  }
});

SubCommand

docker compose up처럼 중첩된 커맨드를 만들 수 있습니다. nest-commander docs (opens in a new tab)

// 부모 커맨드
@Command({
  name: 'docker',
  subCommands: [ComposeCommand],
})
export class DockerCommand extends CommandRunner {
  async run(): Promise<void> {}
}
 
// 서브커맨드
@SubCommand({ 
  name: 'compose', 
  aliases: ['c'],
  subCommands: [UpCommand]
})
export class ComposeCommand extends CommandRunner {
  async run(): Promise<void> {}
}

모듈 등록

서브커맨드를 포함한 모든 커맨드는 모듈에 provider로 등록해야 합니다:

@Module({
  providers: [DockerCommand, ComposeCommand, UpCommand],
})
export class AppModule {}
 
// 또는 편의 메서드 사용
@Module({
  providers: [...DockerCommand.registerWithSubCommands()],
})
export class AppModule {}

Inquirer 통합

대화형 프롬프트를 추가할 수 있습니다. nest-commander README (opens in a new tab)

@QuestionSet({ name: 'user-questions' })
export class UserQuestions {
  @Question({
    type: 'input',
    name: 'username',
    message: '사용자 이름을 입력하세요:',
  })
  parseUsername(val: string): string {
    return val.trim();
  }
}
 
@Command({ name: 'create-user' })
export class CreateUserCommand extends CommandRunner {
  constructor(private readonly inquirer: InquirerService) {
    super();
  }
 
  async run(): Promise<void> {
    const answers = await this.inquirer.ask('user-questions', {});
    console.log(answers.username);
  }
}

테스트

nest-commander-testing 패키지로 테스트할 수 있습니다. nest-commander testing (opens in a new tab)

import { CommandTestFactory } from 'nest-commander-testing';
 
describe('CreateBestFanficsCommand', () => {
  let commandInstance: TestingModule;
 
  beforeAll(async () => {
    commandInstance = await CommandTestFactory.createTestingCommand({
      imports: [AppModule],
    })
      .overrideProvider(BestFanficsCronService)
      .useValue({ createBestFanficsBatch: jest.fn() })
      .compile();
  });
 
  it('should run with date argument', async () => {
    await CommandTestFactory.run(commandInstance, ['create-best-fanfics', '2024-01-15']);
  });
});

참고 자료