Dart CLI 애플리케이션 구축하기
목차
- 1. 핵심 아키텍처 및 프로세스 생명주기
- 2. 스트림, 진단 및 형식 지정
- 3. 프로젝트 구성 및 패키징
- 4. 인수 파싱 및 명령 라우팅
- 5. 네이티브 비동기 및 최신 스택 트레이스
- 6. 서브프로세스 생성 및 AOT 복원력
- 7. 시그널 처리 및 터미널 종료
- 8. CLI 애플리케이션 테스트
- 9. 최신 컴파일 및 배포
- 10. 워크플로우 및 감사 체크리스트
- 참고 자료 및 예제
1. 핵심 아키텍처 및 프로세스 생명주기
파괴적인 종료(exit(N)) 피하기
dart:io의 exit(int code)를 호출하면 C++ 런타임에서 Platform::Exit(code)를 호출합니다. 이는 Dart 스택을 해제하지 않고 OS 프로세스를 즉시 종료시킵니다:
- 디버거 연결 해제:
--pause-isolates-on-exit플래그와 함께 실행되면 VM 서비스가 IDE 검사를 위해 종료 전에 격리(isolate)를 일시 중지합니다.exit()는 VM 서비스가 상태를 일시 중지하거나 검사하기 전에 OS 프로세스를 종료시킵니다. - 커버리지 손실:
package:coverage는 일시 중지된 종료 상태에서 VM 서비스 RPC를 통해 실행 라인을 쿼리합니다.exit()는 RPC 추출 전에 프로세스를 파괴하여 0% 커버리지를 초래합니다. - 버퍼 잘림:
stdout과stderr는 버퍼링된 비동기IOSink스트림입니다.exit()는 플러시되지 않은 바이트를 버립니다. - 리소스 누수:
finally블록(락 해제, 임시 디렉토리 삭제 등)이 무시됩니다.
규칙: 정상 실행 중에는 exit(code)를 직접 호출하지 말고, exitCode = code를 설정하거나 CommandRunner<int>(package:args에서 제공)에서 정수 종료 코드를 반환하여 비동기 main() 함수가 자연스럽게 반환되도록 하세요. 처리되지 않은 오류에 대해 exit()를 호출하지 말고, 처리되지 않은 Error 또는 예외를 던져 런타임이 깔끔하게 해제되고 0이 아닌 상태로 종료되도록 하세요.
표준 POSIX 종료 코드 (/usr/include/sysexits.h):
0: 성공 (EX_OK/ExitCode.success.code)64: 명령줄 사용법 오류 (EX_USAGE/ExitCode.usage.code)65: 데이터 형식 오류 (EX_DATAERR/ExitCode.data.code)70: 내부 소프트웨어 충돌 (EX_SOFTWARE/ExitCode.software.code)78: 구성 오류 (EX_CONFIG/ExitCode.config.code)
참고: package:io/io.dart를 가져와서 ExitCode 상수(예: ExitCode.usage.code, ExitCode.software.code)를 사용하는 것이 매직 정수 리터럴을 사용하는 것보다 좋습니다. 패키지 의존성이 없는 최소한의 독립 스크립트의 경우 표준 POSIX 정수 리터럴(0, 64, 70)을 사용할 수 있습니다.
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:io/io.dart' show ExitCode; // 표준 POSIX ExitCode 상수 제공
Future<void> main(List<String> args) async {
final runner = CommandRunner<int>('tool', 'CLI 도구 설명.');
try {
final status = await runner.run(args);
exitCode = status ?? ExitCode.success.code;
} on UsageException catch (e) {
stderr
..writeln(e.message)
..writeln(e.usage);
exitCode = ExitCode.usage.code;
}
}
얇은 진입점 패턴 (bin/ vs. lib/src/)
bin/*.dart 파일은 최소한의 진입점 트램펄린(인스턴스 생성, args 전달, 종료 코드 대기)으로 엄격하게 유지하세요. 모든 명령 정의, 인수 파서, 포맷터 및 비즈니스 로직은 lib/src/ 안에 배치하세요.
- 이유:
bin/의 코드는package:URI를 통해 깔끔하게 가져올 수 없습니다. 로직을lib/src/로 이동하면 전체 명령 실행기, 하위 명령 계층 구조 및 비즈니스 로직을 OS 서브프로세스를 생성하지 않고 메모리 내에서 밀리초(< 2ms) 단위로 단위 테스트할 수 있습니다.
// bin/my_cli.dart — 얇은 진입점 트램펄린
import 'dart:io';
import 'package:my_cli/src/cli.dart';
Future<void> main(List<String> args) async {
exitCode = await runCli(args);
}
2. 출력, 진단 및 형식 지정
- 데이터 vs. 진단: 의도된 프로그램 결과와 기계 판독 가능 데이터는
stdout에만 기록하세요. 경고, 오류 메시지 및 디버그 로그는stderr에만 기록하세요. - 오류 사용 규칙: 인수 파싱 또는 필수 옵션 오류가 발생할 때(
FormatException,UsageException, 또는results.option(...)을 통해 누락된mandatory: true옵션에 접근할 때 발생하는ArgumentError), 오류 메시지와 사용법 텍스트 모두stderr에 기록되어야 하며, 종료 코드64(EX_USAGE/ExitCode.usage.code)가 반환되어야 합니다.stdout은 사용자가--help또는-h를 통해 명시적으로 요청할 때만 사용법 도움말을 받아야 합니다. - 오류 처리기에서
print()사용 금지:print()는stdout으로 라우팅됩니다. 모든 실패 알림에는stderr.writeln()을 사용하세요. 표준 출력의 경우,avoid_print린트 규칙을 준수하려면print()보다stdout.writeln()을 선호하세요(analysis_options.yaml에서 명시적으로avoid_print: false를 구성하지 않는 한). - 터미널 기능 감지 및
NO_COLOR: ANSI 색상 또는 커서 이스케이프 코드를 내보내기 전에stdout.hasTerminal,stdout.supportsAnsiEscapes및!Platform.environment.containsKey('NO_COLOR')를 확인하세요:bool get useAnsi => stdout.hasTerminal && stdout.supportsAnsiEscapes && !Platform.environment.containsKey('NO_COLOR'); - 기계 판독 가능 모드:
--json또는--machine플래그가 전달되면 데이터를 JSON 형식으로stdout에 포맷하고 로그는stderr로 라우팅하세요.
3. 프로젝트 구성 및 패키징
스캐폴딩 및 Pubspec 실행 파일 매핑 (executables:)
dart create -t console <package_name>을 사용하여 새 명령줄 프로젝트를 스캐폴딩하세요. 이 명령은 표준 bin/ 및 lib/ 레이아웃을 초기화합니다. 항상 pubspec.yaml의 executables: 아래에 실행 파일을 선언하여 명령 이름을 bin/의 스크립트에 매핑하세요. 이렇게 하면 dart run <command>를 통해 깔끔하게 호출할 수 있고(bin/...dart를 지정하지 않아도 됨) dart install을 위한 전역 바이너리 심볼릭 링크를 구성할 수 있습니다:
name: my_cli
description: 고성능 CLI 도구.
version: 1.0.0
executables:
my_cli: # bin/my_cli.dart에 매핑
secondary_cmd: helper # bin/helper.dart에 매핑
단일 소스 버전 관리 (package:build_version)
bin/*.dart에 --version 문자열을 하드코딩하거나 상수 파일을 수동으로 동기화하지 마세요. package:build_version을 사용하여 빌드 중에 pubspec.yaml에서 직접 const packageVersion = 'x.y.z';를 포함하는 lib/src/version.dart를 생성하세요.
캐싱 규칙
임시 캐시 파일은 .dart_tool/<package_name>/에 저장하세요. 영구 캐시 파일을 프로젝트 루트에 직접 쓰지 마세요.
4. 인수 파싱 및 명령 라우팅
package:args를 가져와서 명령줄 인수를 관리하세요:
- 간단한 스크립트:
addFlag()및addOption()과 함께ArgParser를 직접 사용하세요. - 다중 명령 도구: 각 하위 명령에 대해
CommandRunner<int>를 구현하고Command<int>를 확장하여 POSIX 종료 코드를 직접 반환하세요. - 타입 안전 접근자: 맵 인덱싱
operator []대신results.flag('name'),results.option('name')및results.multiOption('name')을 사용하세요(package:args2.5+에서 사용 가능). 이를 통해 수동 타입 캐스트(as bool,as String?)를 제거할 수 있습니다. - 복잡한 옵션 모델: 플래그가 많은 애플리케이션의 경우
package:build_cli를 사용하여 강력한 타입의 옵션 클래스를 생성하세요. 명명된 기본값 재정의(예:{String? hostDefaultOverride})를 활용하여 구성 파일과 CLI 플래그를 깔끔하게 병합하세요.
5. 네이티브 비동기 및 최신 스택 트레이스
Chain.capture()피하기: Dart VM은await중단 지점에서 비동기 스택 프레임을 기본적으로 보존합니다.Chain.capture는 사용자 정의 Zone으로 이벤트 루프를 래핑하여 상당한 할당 오버헤드를 발생시키고 Zone 경계에서 오류를 포착합니다.Trace.from(st).terse로 정리하기: Zone을 캡처하지 않고 처리되지 않은 오류에 대해package:stack_trace의 정적 유틸리티를 사용하세요:
import 'dart:io';
import 'package:io/io.dart' show ExitCode;
import 'package:stack_trace/stack_trace.dart';
Future<void> runMain(List<String> args) async {
try {
await executeLogic(args);
exitCode = ExitCode.success.code;
} catch (e, st) {
stderr.writeln('치명적 오류: $e');
if (args.contains('-v') || args.contains('--verbose')) {
stderr.writeln(Trace.from(st).terse);
}
exitCode = ExitCode.software.code;
}
}
6. 서브프로세스 생성 및 AOT 복원력
Dart SDK 서브프로세스를 생성하거나 다른 Dart 도구(예: dart format, dart test, build_runner)를 실행할 때:
Platform.resolvedExecutable또는Platform.executable이dart명령줄 실행 파일을 가리킨다고 가정하지 마세요: 독립 실행형 AOT 컴파일 바이너리(dart install/dart compile exe)에서resolvedExecutable은 컴파일된 애플리케이션 바이너리 자체를 가리키므로 재귀적 자체 호출 루프 또는 플래그 거부 충돌이 발생할 수 있습니다.package:cli_util사용하기: 사용자 정의 PATH 또는 디렉토리 스크레이퍼를 작성하는 대신cli_util.dartExecutable또는cli_util.sdkPath를 사용하여 Dart SDK 실행 파일을 확인하세요.- 버전 요구 사항 및 자세한 기술 가이드는 references/aot_sdk_discovery.md를 참조하세요.
7. 시그널 처리 및 터미널 종료
CLI가 터미널 모드를 변경하거나, 스피너를 표시하거나, 수신 소켓을 여는 경우:
- Windows 시그널 보호: Windows에서
ProcessSignal.sigterm.watch()는UnsupportedError를 던집니다.sigterm을if (!Platform.isWindows)로 보호하세요. - 에코 및 라인 모드 종료:
stdin.echoMode = false또는stdin.lineMode = false를 설정하는 경우, 먼저if (!stdin.hasTerminal) return;을 확인하고SIGINT리스너와finally블록을 설치하여 종료 후에도 사용자 키 입력이 계속 표시되도록 복원하세요. - 커서 가시성: ANSI 커서 숨기기(
\x1B[?25l)를 내보내는 경우, 종료 또는 취소 시 항상 커서 가시성(\x1B[?25h)을 복원하세요. - 소켓 정리: 종료 시그널에서 수신 중인
HttpServer또는ServerSocket인스턴스를 명시적으로 닫아(server.close(force: true)) OS 포트를 즉시 해제하세요. - 자세한 패턴은 references/signals_and_terminal.md를 참조하세요.
8. CLI 애플리케이션 테스트
두 가지 별개의 계층으로 테스트를 구성하세요:
- 단위 테스트 (메모리 내,
< 5ms):test/에서package:<pkg>/src/...를 가져와 명령 클래스, 옵션 파싱 및 비즈니스 로직을 직접 테스트하세요. - 통합 테스트 (서브프로세스):
package:test_process와package:test_descriptor를 사용하여 종단 간 바이너리 실행, 프로세스 I/O 스트리밍 및 OS 종료 코드를 확인하세요:
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
import 'package:test_process/test_process.dart';
void main() {
test('CLI가 입력을 처리하고 깔끔하게 종료됩니다', () async {
await d.file('input.txt', 'hello').create();
final process = await TestProcess.start('dart', [
'run',
'bin/my_cli.dart',
'--input',
d.path('input.txt'),
]);
await expectLater(process.stdout, emitsThrough('처리 완료.'));
await process.shouldExit(0);
});
}
9. 최신 컴파일 및 배포
Dart 3.12+는 CLI 배포를 dart run 및 dart install로 표준화합니다(dart pub global activate에서 전환):
- 임시 실행 (JIT):
dart run <package>@<version> [args]는 CLI를 필요에 따라 다운로드하고 실행합니다. - 전역 설치 (네이티브 AOT):
dart install <package>는 패키지 진입점을~/.dart/install/bin/에 있는 빠른 네이티브 독립 실행형 바이너리로 컴파일합니다. - 로컬 개발:
dart run <command>(pubspec.yaml의executables:를 통해 확인) 또는dart run bin/cli.dart를 사용하세요. - 동적 라이브러리 및 코드 자산 번들링:
dart build cli를 사용하세요.build/cli/_/bundle/에 번들을 출력합니다. - 독립 실행형 실행 파일 컴파일:
dart compile exe bin/cli.dart -o <output_path>를 사용하세요.
10. 워크플로우 및 감사 체크리스트
구현 워크플로우
-
pubspec.yaml의executables:아래에 진입점을 선언합니다. -
bin/*.dart를 얇은 진입점으로 유지하고 명령 로직은lib/src/에 배치합니다. - 정수 종료 코드를 반환하거나
exitCode = N을 설정하고 원시exit(N)은 피합니다. - 파싱 실패 시 오류, 경고 및 사용법을
stderr로 보냅니다. - 타입 안전성을 위해
results.flag(),results.option()및results.multiOption()을 사용합니다. - ANSI 코드를 내보내기 전에
useAnsi를 확인합니다(stdout.hasTerminal,supportsAnsiEscapes및NO_COLOR확인). -
cli_util.dartExecutable을 사용하여 자식 도구를 생성하고 절대Platform.resolvedExecutable을 사용하지 않습니다. - 메모리 내에서 명령 실행기를 단위 테스트하고
test_process로 종단 간 바이너리 실행을 테스트합니다.
참고 자료 및 예제
- 단일 명령 도구 템플릿: examples/single_command_tool.dart
- 다중 명령 실행기 템플릿: examples/multi_command_runner.dart
- AOT SDK 검색 및 서브프로세스 생성: references/aot_sdk_discovery.md
- 시그널 처리 및 터미널 종료: references/signals_and_terminal.md