Dart CLI 하나에 REPL과 원샷 명령을 같이 두는 법

CLI 계산기를 만든다고 해봅시다. 보통 첫 버전은 이런 모양입니다.

dart run calculator:calculate "2+3"

결과를 출력하고 종료합니다. 자동화 스크립트에서 쓰기 좋고, 셸 파이프라인에도 넣기 좋습니다.

그런데 직접 여러 식을 입력하며 놀아보려면 매번 명령을 다시 실행해야 합니다. 그래서 인터프리터를 하나 더 만들고 싶어집니다.

dart run calculator:interpreter
> 1/5
Result: 0.2
> history
Calculation History:
- 1/5 = 0.2

이때 선택지가 생깁니다. 두 프로그램을 별도 패키지로 만들까요? 하나의 실행 파일에 옵션을 잔뜩 붙일까요?

flutter_calculator_app은 같은 Dart 패키지에 실행 진입점 두 개를 둡니다.

  • calculator:interpreter는 상태를 유지하는 REPL입니다.
  • calculator:calculate는 한 번 계산하고 끝나는 원샷 명령입니다.

계산 기능은 공유하되 사용 방식은 섞지 않습니다. 이 구조가 생각보다 깔끔합니다. 사용자도 명령 이름만 보고 수명을 알 수 있으니까요.

(하나는 대화를 시작하고, 하나는 답만 놓고 퇴근합니다. 저는 가끔 후자가 부럽습니다.)

REPL과 원샷은 무엇이 다른가요?

REPL은 Read-Eval-Print Loop의 약자입니다.

  1. 입력을 읽습니다.
  2. 계산합니다.
  3. 결과를 출력합니다.
  4. 다시 입력을 기다립니다.

프로세스가 살아 있으므로 세션 상태를 가질 수 있습니다. 계산 기록, 현재 작업 디렉터리, 선택된 환경, 연결 세션 같은 정보가 그 예입니다.

원샷 명령은 인자를 받고 한 번 처리한 뒤 종료합니다.

tool <input>

상태를 유지하지 않는 대신 셸 스크립트와 CI에서 다루기 쉽습니다. 성공과 실패를 종료 코드로 전달할 수 있고, 표준 출력만 캡처하면 됩니다.

둘 중 하나가 더 좋은 게 아닙니다. 사용 목적이 다릅니다.

bin/에 진입점 두 개 두기

Dart 패키지는 bin/ 아래에 파일을 여러 개 두어 실행 진입점을 여럿 노출할 수 있습니다. 현재 저장소의 두 파일은 bin/interpreter.dartbin/calculate.dart입니다.

개념적으로 pubspec.yaml은 다음 관계를 만듭니다.

bin/
  interpreter.dart
  calculate.dart

dart run <패키지>:<이름>bin/<이름>.dart를 찾아 실행합니다. pubspec.yaml에 따로 등록할 것은 없습니다 — 파일을 그 자리에 두는 것이 등록입니다. 그래서 패키지 이름과 파일 이름을 조합해 호출할 수 있습니다.

dart run calculator:interpreter
dart run calculator:calculate "2+3"

두 모드가 한 패키지의 의존성과 계산 구현을 공유하면서도 진입점은 분리됩니다.

명령 하나에 --interactive 같은 플래그를 붙이는 방법도 있습니다. 하지만 실행 수명과 입출력 방식이 완전히 다르다면 이름을 나누는 편이 사용법을 더 분명하게 만듭니다.

원샷 명령은 작게 유지합니다

현재 calculate.dart는 인자를 합쳐 식을 만들고 로컬 계산기를 호출합니다.

void main(List<String> args) {
  if (args.isEmpty || args.contains('--help')) {
    print('Usage: dart run calculator:calculate <expression>');
    print('Example: dart run calculator:calculate "2+3"');
    return;
  }

  final expression = args.join(' ');
  if (expression.trim().isEmpty) {
    print('Expression cannot be empty');
    exitCode = 1;
    return;
  }

  final datasource = CalculatorLocalDatasource();

  try {
    final result = datasource.calculate(expression);
    print('Result: $result');
  } catch (e) {
    print('ERROR: ${e.runtimeType} - $e');
    exitCode = 1;
  }
}

원샷 진입점에서 중요한 건 세 가지입니다.

도움말은 계산보다 먼저 처리합니다

인자가 없거나 --help가 있으면 사용법을 출력하고 종료합니다. 다만 이 분기는 인자가 아예 없을 때만 걸립니다. calculate " "처럼 공백만 넘어오면 인자는 존재하므로 통과해 버리기 때문에, 식을 합친 뒤 trim().isEmpty를 한 번 더 봅니다. 빈 입력을 계산기까지 내려보내지 않는 건 이쪽입니다.

인자는 하나의 식으로 합칩니다

셸에서 공백이 들어간 식을 여러 인자로 넘길 수 있으므로 args.join(' ')으로 복원합니다. 물론 공백과 특수문자 해석은 셸의 영향을 받으니 문서 예제에서는 식 전체를 따옴표로 감싸는 편이 안전합니다.

dart run calculator:calculate "2 + 3 * 4"

실패는 종료 코드로 남깁니다

예외를 출력만 하고 정상 종료하면 자동화 도구는 성공으로 오해할 수 있습니다. 현재 코드는 오류 시 exitCode = 1을 설정합니다.

사람에게는 에러 메시지가 필요하고, 기계에는 비영(非零) 종료 코드가 필요합니다. 둘 다 줘야 합니다.

REPL은 입출력 루프와 세션 상태를 가집니다

인터프리터 진입점은 더 단순합니다.

void main() async {
  final calculatorCLI = CalculatorCLI(CalculatorLocalDatasource());
  await calculatorCLI.run();
}

진짜 동작은 CalculatorCLI가 담당합니다. 이 객체는 입력 함수와 출력 함수를 주입받고, 세션의 계산 기록을 유지합니다.

class CalculatorCLI {
  CalculatorCLI(
    this.datasource, {
    this.print = _print,
    this.scanner = _read,
  });

  final CalculatorLocalDatasource datasource;
  final ValueChanged<String> print;
  final ValueGetter<String?> scanner;
  final List<String> history = [];
}

입출력 함수를 주입하는 이유는 테스트 때문입니다. 테스트에서 실제 stdin을 붙잡고 키보드 입력을 흉내 내는 대신, 정해진 문자열을 반환하는 가짜 scanner와 출력을 모으는 가짜 print를 넣을 수 있습니다.

REPL 루프는 입력이 끝나거나 exit 명령이 나올 때까지 반복합니다.

Future<void> run() async {
  printBanner();

  while (true) {
    print('> ');
    final input = scanner();
    if (input == null) break;

    final response = handleInput(input);
    print(response);
    if (response == 'Goodbye!') break;
  }
}

계산 외 명령은 handleInput()에서 분기합니다.

  • help는 사용법을 보여줍니다.
  • history는 현재 세션의 계산 기록을 보여줍니다.
  • clear는 콘솔 정리 응답을 반환합니다.
  • exit는 종료 메시지를 반환합니다.
  • 그 외 입력은 계산식으로 처리합니다.

원샷 명령에는 필요 없는 상태와 명령입니다. 따라서 공통 진입점에 억지로 넣지 않고 REPL 객체 안에 둡니다.

공통으로 공유할 것과 공유하지 않을 것

두 실행 파일은 같은 CalculatorLocalDatasource를 사용합니다. 계산 파서와 연산 규칙은 공유합니다.

반면 다음은 공유하지 않습니다.

  • REPL의 배너
  • 프롬프트 문자 >
  • 계산 기록
  • help, history, clear, exit 명령
  • 원샷 전용 종료 코드 처리

이 구분이 중요합니다. 코드 중복을 없앤다고 모든 입출력 흐름을 하나의 거대한 main()으로 합치면 조건문만 늘어납니다.

공유해야 하는 것은 업무 규칙입니다. 사용자와 대화하는 방법은 각 표면이 소유해도 됩니다.

요리로 치면 레시피는 공유하지만, 코스 요리와 테이크아웃의 접객 방식까지 같은 함수로 만들 필요는 없습니다. 접시에 담을지 종이봉투에 담을지 정도는 입구에서 결정하게 두세요.

현재 코드에서 보이는 솔직한 한계

이 구조를 “완벽하게 프레임워크 독립적인 순수 Dart CLI”라고 부르면 정확하지 않습니다.

현재 CalculatorCLI는 콜백 타입으로 ValueChangedValueGetter를 쓰기 위해 flutter/widgets.dart를 import합니다. 또 두 CLI 진입점은 연결 상태에 따라 로컬/원격 계산을 고르는 CalculatorRepositoryImpl이 아니라 CalculatorLocalDatasource를 직접 사용합니다.

즉 현재 보장되는 공유 범위는 로컬 계산 구현입니다. Flutter UI의 모든 데이터 경로를 그대로 공유한다고 말할 수는 없습니다.

이게 나쁜 구조라는 뜻은 아닙니다. 다만 재사용 범위를 과장하지 말자는 뜻입니다.

CLI를 정말 dart만으로 실행 가능한 독립 패키지로 만들고 싶다면 Flutter 콜백 타입을 자체 typedef나 void Function(String) 형태로 바꾸고, 공통 도메인 계층을 별도 Dart 패키지로 분리하는 다음 단계를 생각할 수 있습니다. 필요하기 전에는 하지 않아도 됩니다. 아키텍처는 미래의 모든 가능성을 미리 결제하는 구독 서비스가 아니니까요.

어떤 도구에 잘 맞을까요?

REPL과 원샷 조합은 계산기 외에도 잘 맞습니다.

  • 데이터 변환기
  • SQL 또는 쿼리 실험 도구
  • 코드 생성기
  • 로컬 AI 클라이언트
  • 이미지·오디오 처리 도구
  • 패키지 분석기

탐색할 때는 REPL이 편합니다. 자동화할 때는 원샷이 편합니다.

같은 핵심 기능이 두 사용 방식을 모두 지원할 가치가 있다면, 실행 진입점을 나누는 것이 가장 작은 확장일 수 있습니다.

정리

하나의 Dart 패키지에서 두 CLI를 운영하는 기준은 간단합니다.

  1. bin/에 진입점을 각각 둡니다.
  2. bin/에 진입점 파일을 두어 이름을 노출합니다.
  3. 계산이나 변환 같은 핵심 규칙은 공유합니다.
  4. REPL의 세션 상태와 메타 명령은 REPL에 둡니다.
  5. 원샷 명령은 인자, 표준 출력, 종료 코드를 명확히 합니다.
  6. 입력과 출력 함수를 주입해 REPL을 테스트 가능하게 만듭니다.

명령 하나로 모든 상황을 처리하려고 하면 사용법이 복잡해집니다. 패키지를 둘로 찢으면 공통 로직 관리가 번거롭습니다.

진입점 두 개, 핵심 구현 하나.

딱 그 사이가 이 문제에는 꽤 좋은 자리입니다. 헤헤, 이번에는 정말 계산 끝입니다.