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)
| 속성 | 타입 | 필수 | 설명 |
|---|---|---|---|
| name | string | ✅ | 커맨드 이름 |
| arguments | string | ❌ | 인자 정의. 필수는 <>, 선택은 [] |
| description | string | ❌ | 커맨드 설명 (--help 출력 시 사용) |
| argsDescription | Record<string, string> | ❌ | 각 인자에 대한 설명 |
| options | CommandOptions | ❌ | Commander에 전달할 추가 옵션 |
| subCommands | Type<CommandRunner>[] | ❌ | 서브커맨드 클래스 배열 |
| aliases | string[] | ❌ | 커맨드 별칭 |
@Option() 데코레이터
커맨드에 옵션 플래그를 추가하는 메서드 데코레이터입니다. nest-commander README (opens in a new tab)
| 속성 | 타입 | 필수 | 설명 |
|---|---|---|---|
| flags | string | ✅ | 플래그 정의 (예: -n, --number [number]) |
| description | string | ❌ | 옵션 설명 |
| defaultValue | string | boolean | ❌ | 기본값 |
| choices | string[] | 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,
);
}
}
}동작 흐름
- 커맨드 실행:
node dist/main create-best-fanfics 2024-01-15 - 인자 파싱:
[date]가 선택적이므로 날짜가 없어도 실행 가능 - run() 호출:
inputs[0]에'2024-01-15'전달 (없으면 빈 배열) - 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']);
});
});