iBetter Books
수정

CLI 앱 프로젝트 생성

이제 실제 프로젝트를 만들 차례입니다. PART 04에서는 파일을 정리하고 변환하는 CLI 도구, file_organizer를 처음부터 완성까지 만들어 봅니다. 문법 예제로 끝나는 것이 아니라 실제로 쓸 수 있는 도구를 만드는 과정입니다.

CLI 도구는 터미널에서 명령어로 실행하는 프로그램입니다. git, dart, npm 같은 도구들이 모두 CLI 도구입니다. Dart는 이런 CLI 도구를 만들기에 매우 적합한 언어입니다. 빠른 컴파일, 단일 실행 파일 생성, 그리고 dart:io라는 강력한 표준 라이브러리 덕분입니다.

프로젝트 생성

Dart SDK는 프로젝트 템플릿을 제공합니다. 콘솔 애플리케이션 템플릿으로 프로젝트를 생성합니다.

dart create -t console file_organizercd file_organizer

-t console 옵션은 콘솔 애플리케이션 템플릿을 사용하겠다는 뜻입니다. 생성된 디렉토리 구조를 살펴봅니다.

file_organizer/├── bin/   └── file_organizer.dart 엔트리포인트├── lib/   └── file_organizer.dart 라이브러리 코드├── test/   └── file_organizer_test.dart├── pubspec.yaml├── analysis_options.yaml└── README.md

bin/ 디렉토리에 있는 파일이 실행의 시작점입니다. lib/ 디렉토리에는 실제 로직을 작성합니다. 이 구분이 중요합니다. 엔트리포인트(bin/)와 로직(lib/)을 분리하면 테스트 작성이 쉬워집니다.

프로젝트 구조 설계

file_organizer가 제공할 기능을 먼저 정의합니다.

  • organize: 지정한 디렉토리의 파일을 확장자별로 분류
  • stats: 디렉토리 통계 출력 (파일 수, 크기, 종류)
  • rename: 규칙에 따라 파일 일괄 이름 변경

이 세 가지 서브커맨드를 지원하는 구조로 설계합니다.

lib/
├── src/
│   ├── commands/
│   │   ├── organize_command.dart
│   │   ├── stats_command.dart
│   │   └── rename_command.dart
│   ├── models/
│   │   └── file_info.dart
│   └── utils/
│       ├── file_utils.dart
│       └── formatter.dart
└── file_organizer.dart        ← 라이브러리 공개 API

먼저 디렉토리를 만들고 기본 파일들을 생성합니다.

mkdir -p lib/src/commands lib/src/models lib/src/utils

pubspec.yaml 설정

이 프로젝트에서 사용할 패키지를 pubspec.yaml에 추가합니다.

# 새 파일: pubspec.yamlname: file_organizerdescription: A CLI tool for organizing and transforming files.version: 1.0.0publish_to: noneenvironment:  sdk: ">=3.0.0 <4.0.0"dependencies:  args: ^2.4.2  path: ^1.9.0dev_dependencies:  lints: ^3.0.0  test: ^1.24.0

args는 명령줄 인자 파싱, path는 크로스 플랫폼 파일 경로 처리에 사용합니다.

dart pub get

엔트리포인트 작성

bin/file_organizer.dart를 작성합니다. 이 파일은 최대한 단순하게 유지합니다. 실제 로직은 lib/에 있어야 합니다.

// 새 파일: bin/file_organizer.dart
import 'dart:io';

import 'package:file_organizer/file_organizer.dart';

Future<void> main(List<String> arguments) async {
  final runner = FileOrganizerRunner();

  try {
    await runner.run(arguments);
  } on UsageException catch (e) {
    stderr.writeln(e.message);
    stderr.writeln();
    stderr.writeln(e.usage);
    exit(64); // EX_USAGE
  } catch (e) {
    stderr.writeln('오류: $e');
    exit(1);
  }
}

UsageException은 잘못된 인자가 들어왔을 때 던질 예외입니다. exit(64)는 Unix 관례로 "사용법 오류"를 의미합니다.

라이브러리 공개 API

lib/file_organizer.dart는 패키지의 공개 API를 정의합니다. 외부에서 사용할 클래스와 함수만 내보냅니다.

// 새 파일: lib/file_organizer.dart
library file_organizer;

export 'src/runner.dart';
export 'src/exceptions.dart';

예외 클래스 정의

// 새 파일: lib/src/exceptions.dart

/// 잘못된 사용법에 대한 예외.
class UsageException implements Exception {
  const UsageException(this.message, this.usage);

  final String message;
  final String usage;

  @override
  String toString() => 'UsageException: $message';
}

Runner 클래스 (뼈대)

FileOrganizerRunner는 명령줄 인자를 받아 적절한 커맨드로 연결하는 역할을 합니다. 아직 커맨드를 작성하지 않았으므로 뼈대만 만들어 둡니다.

// 새 파일: lib/src/runner.dart
import '../file_organizer.dart';

/// CLI 애플리케이션의 진입점 역할을 하는 러너.
class FileOrganizerRunner {
  FileOrganizerRunner();

  Future<void> run(List<String> arguments) async {
    if (arguments.isEmpty) {
      _printUsage();
      return;
    }

    // Ch 02에서 args 패키지로 교체합니다.
    final command = arguments.first;
    switch (command) {
      case 'organize':
        print('organize 커맨드 — 아직 구현되지 않았습니다.');
      case 'stats':
        print('stats 커맨드 — 아직 구현되지 않았습니다.');
      case 'rename':
        print('rename 커맨드 — 아직 구현되지 않았습니다.');
      default:
        throw UsageException(
          '알 수 없는 커맨드: $command',
          _usageText(),
        );
    }
  }

  void _printUsage() {
    print(_usageText());
  }

  String _usageText() {
    return '''
사용법: file_organizer <command> [options]

커맨드:
  organize    파일을 확장자별로 분류합니다.
  stats       디렉토리 통계를 출력합니다.
  rename      파일을 일괄 이름 변경합니다.

도움말: file_organizer <command> --help
''';
  }
}

첫 실행 확인

프로그램이 올바르게 동작하는지 확인합니다.

# 인자 없이 실행dart run bin/file_organizer.dart# 알 수 없는 커맨드dart run bin/file_organizer.dart unknown# organize 커맨드dart run bin/file_organizer.dart organize
사용법: file_organizer <command> [options]

커맨드:
  organize    파일을 확장자별로 분류합니다.
  stats       디렉토리 통계를 출력합니다.
  rename      파일을 일괄 변경합니다.

도움말: file_organizer <command> --help

FileInfo 모델 정의

파일 정보를 담을 모델 클래스를 미리 만들어 둡니다. 이후 챕터에서 계속 사용합니다.

// 새 파일: lib/src/models/file_info.dart
import 'dart:io';

import 'package:path/path.dart' as p;

/// 파일 하나의 메타데이터를 담는 값 객체.
class FileInfo {
  const FileInfo({
    required this.path,
    required this.name,
    required this.extension,
    required this.sizeBytes,
    required this.modifiedAt,
  });

  /// 파일의 절대 경로.
  final String path;

  /// 확장자를 포함한 파일 이름.
  final String name;

  /// 소문자로 정규화된 확장자 (점 포함, 예: '.dart').
  final String extension;

  /// 파일 크기 (바이트).
  final int sizeBytes;

  /// 마지막 수정 시각.
  final DateTime modifiedAt;

  /// [File]로부터 [FileInfo]를 생성합니다.
  static Future<FileInfo> fromFile(File file) async {
    final stat = await file.stat();
    return FileInfo(
      path: file.path,
      name: p.basename(file.path),
      extension: p.extension(file.path).toLowerCase(),
      sizeBytes: stat.size,
      modifiedAt: stat.modified,
    );
  }

  /// 파일 크기를 사람이 읽기 좋은 형태로 반환합니다.
  String get humanReadableSize {
    if (sizeBytes < 1024) return '$sizeBytes B';
    if (sizeBytes < 1024 * 1024) {
      return '${(sizeBytes / 1024).toStringAsFixed(1)} KB';
    }
    if (sizeBytes < 1024 * 1024 * 1024) {
      return '${(sizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
    }
    return '${(sizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
  }

  @override
  String toString() => 'FileInfo($name, $humanReadableSize)';
}

정리

이번 챕터에서는 file_organizer 프로젝트의 뼈대를 만들었습니다.

  • dart create -t console로 콘솔 프로젝트를 생성했습니다.
  • bin/lib/src/로 엔트리포인트와 로직을 분리했습니다.
  • FileInfo 모델과 UsageException, FileOrganizerRunner 뼈대를 정의했습니다.

다음 챕터에서는 args 패키지를 사용해 명령줄 인자를 제대로 파싱합니다. 지금의 단순한 switch문을 대체할 것입니다.