iBetter Books
수정

파일과 디렉토리 다루기 — dart:io

커맨드 뼈대가 완성됐으니 이제 실제 파일 시스템 작업을 구현합니다. Dart의 dart:io 라이브러리는 파일, 디렉토리, 소켓, 프로세스를 다루는 종합 도구입니다. Flutter에서는 사용할 수 없지만, 서버와 CLI 환경에서는 핵심 라이브러리입니다.

dart:io 핵심 클래스

dart:io의 파일 관련 클래스는 세 가지입니다.

  • File: 파일 읽기, 쓰기, 복사, 이동, 삭제
  • Directory: 디렉토리 생성, 목록 조회, 삭제
  • FileSystemEntity: FileDirectory의 공통 부모

모두 비동기 API(async/await)와 동기 API(Sync 접미어)를 제공합니다. CLI 도구에서 간단한 작업은 동기 API가 더 편리할 수 있지만, 대용량 파일이나 반복 작업에는 비동기 API를 사용합니다.

파일 읽기와 쓰기

import 'dart:io';

// 파일 전체를 문자열로 읽기
final file = File('README.md');
final content = await file.readAsString();

// 줄 단위로 읽기
final lines = await file.readAsLines();
for (final line in lines) {
  print(line);
}

// 바이트로 읽기
final bytes = await file.readAsBytes();
print('크기: ${bytes.length} bytes');

// 파일 쓰기 (덮어쓰기)
await File('output.txt').writeAsString('Hello, Dart!\n');

// 파일에 추가 (append)
await File('output.txt').writeAsString(
  '추가 내용\n',
  mode: FileMode.append,
);

디렉토리 탐색

Directory.list()는 디렉토리 내 파일과 하위 디렉토리를 Stream으로 반환합니다.

import 'dart:io';

Future<void> listDirectory(String dirPath, {bool recursive = false}) async {
  final dir = Directory(dirPath);

  await for (final entity in dir.list(recursive: recursive)) {
    if (entity is File) {
      final stat = await entity.stat();
      print('파일: ${entity.path} (${stat.size} bytes)');
    } else if (entity is Directory) {
      print('디렉토리: ${entity.path}');
    }
  }
}

recursive: true를 주면 하위 디렉토리까지 모두 탐색합니다.

path 패키지 — 크로스 플랫폼 경로 처리

Windows에서는 경로 구분자가 \, macOS/Linux에서는 /입니다. 직접 문자열로 경로를 처리하면 플랫폼 호환성 문제가 생깁니다. path 패키지가 이를 해결합니다.

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

// 경로 합치기
final full = p.join('/home/user', 'documents', 'file.txt');
// → /home/user/documents/file.txt

// 파일명만 추출
p.basename('/home/user/file.txt');       // → 'file.txt'
p.basenameWithoutExtension('file.txt'); // → 'file'

// 확장자 추출
p.extension('photo.JPG');               // → '.JPG'

// 절대 경로 변환
p.absolute('relative/path');            // → '/cwd/relative/path'

// 디렉토리 경로 추출
p.dirname('/home/user/file.txt');       // → '/home/user'

// 경로 정규화
p.normalize('/home/user/../docs/');     // → '/home/docs'

file_utils.dart 구현

공통 파일 유틸리티를 file_utils.dart에 모읍니다.

// 새 파일: lib/src/utils/file_utils.dart
import 'dart:io';

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

import '../models/file_info.dart';

/// 디렉토리에서 파일 목록을 수집합니다.
///
/// [recursive]가 true이면 하위 디렉토리까지 탐색합니다.
/// [extensions]이 지정되면 해당 확장자 파일만 반환합니다.
Future<List<FileInfo>> collectFiles(
  Directory dir, {
  bool recursive = false,
  Set<String>? extensions,
}) async {
  final result = <FileInfo>[];

  await for (final entity in dir.list(recursive: recursive)) {
    if (entity is! File) continue;

    final ext = p.extension(entity.path).toLowerCase();

    if (extensions != null && !extensions.contains(ext)) continue;

    result.add(await FileInfo.fromFile(entity));
  }

  // 이름 기준 정렬
  result.sort((a, b) => a.name.compareTo(b.name));
  return result;
}

/// 확장자를 카테고리 이름으로 변환합니다.
String extensionToCategory(String ext) {
  const categories = {
    // 이미지
    '.jpg': '이미지',
    '.jpeg': '이미지',
    '.png': '이미지',
    '.gif': '이미지',
    '.webp': '이미지',
    '.svg': '이미지',
    // 동영상
    '.mp4': '동영상',
    '.mov': '동영상',
    '.avi': '동영상',
    '.mkv': '동영상',
    // 문서
    '.pdf': '문서',
    '.doc': '문서',
    '.docx': '문서',
    '.txt': '문서',
    '.md': '문서',
    // 코드
    '.dart': '코드',
    '.py': '코드',
    '.js': '코드',
    '.ts': '코드',
    '.java': '코드',
    // 압축
    '.zip': '압축',
    '.tar': '압축',
    '.gz': '압축',
    '.rar': '압축',
  };

  return categories[ext] ?? '기타';
}

/// 디렉토리를 재귀적으로 생성합니다. 이미 존재하면 무시합니다.
Future<Directory> ensureDirectory(String path) async {
  final dir = Directory(path);
  if (!dir.existsSync()) {
    await dir.create(recursive: true);
  }
  return dir;
}

/// 파일을 안전하게 이동합니다.
///
/// 대상 경로에 파일이 이미 존재하면 번호를 붙여 이름 충돌을 피합니다.
Future<String> moveFileSafely(File source, String destDir) async {
  await ensureDirectory(destDir);

  final fileName = p.basename(source.path);
  var destPath = p.join(destDir, fileName);

  // 이름 충돌 처리
  var counter = 1;
  while (File(destPath).existsSync()) {
    final nameWithoutExt = p.basenameWithoutExtension(fileName);
    final ext = p.extension(fileName);
    destPath = p.join(destDir, '${nameWithoutExt}_$counter$ext');
    counter++;
  }

  await source.rename(destPath);
  return destPath;
}

OrganizeCommand 완성

파일 유틸리티를 사용해 organize 커맨드를 완성합니다.

// 수정: lib/src/commands/organize_command.dart
import 'dart:io';

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

import '../utils/file_utils.dart';

class OrganizeCommand extends Command<void> {
  OrganizeCommand() {
    argParser
      ..addOption(
        'source',
        abbr: 's',
        help: '정리할 디렉토리 경로.',
        defaultsTo: '.',
      )
      ..addOption(
        'dest',
        abbr: 'd',
        help: '파일을 이동할 대상 디렉토리.',
      )
      ..addFlag(
        'dry-run',
        help: '실제로 이동하지 않고 결과만 출력합니다.',
        negatable: false,
      )
      ..addFlag(
        'recursive',
        abbr: 'r',
        help: '하위 디렉토리까지 탐색합니다.',
        negatable: false,
      );
  }

  @override
  String get name => 'organize';

  @override
  String get description => '파일을 확장자별로 분류합니다.';

  @override
  Future<void> run() async {
    final source = argResults!['source'] as String;
    final dest = argResults!['dest'] as String? ?? source;
    final dryRun = argResults!['dry-run'] as bool;
    final recursive = argResults!['recursive'] as bool;

    final sourceDir = Directory(p.absolute(source));
    if (!sourceDir.existsSync()) {
      usageException('소스 디렉토리가 존재하지 않습니다: $source');
    }

    print('정리 시작: ${sourceDir.path}');
    if (dryRun) print('[dry-run 모드] 실제 파일을 이동하지 않습니다.\n');

    final files = await collectFiles(sourceDir, recursive: recursive);
    if (files.isEmpty) {
      print('처리할 파일이 없습니다.');
      return;
    }

    var moved = 0;
    var skipped = 0;

    for (final fileInfo in files) {
      final category = extensionToCategory(fileInfo.extension);
      final targetDir = p.join(p.absolute(dest), category);
      final targetPath = p.join(targetDir, fileInfo.name);

      print('  ${fileInfo.name} → $category/');

      if (!dryRun) {
        final actualPath = await moveFileSafely(
          File(fileInfo.path),
          targetDir,
        );
        if (actualPath != targetPath) {
          print('    (이름 충돌: ${p.basename(actualPath)}로 저장)');
        }
        moved++;
      } else {
        skipped++;
      }
    }

    print('\n완료: ${dryRun ? '시뮬레이션' : '이동'} ${dryRun ? skipped : moved}개 파일');
  }
}

StatsCommand 완성

// 수정: lib/src/commands/stats_command.dart
import 'dart:io';

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

import '../utils/file_utils.dart';

class StatsCommand extends Command<void> {
  StatsCommand() {
    argParser
      ..addOption(
        'path',
        abbr: 'p',
        help: '통계를 볼 디렉토리 경로.',
        defaultsTo: '.',
      )
      ..addFlag(
        'recursive',
        abbr: 'r',
        help: '하위 디렉토리까지 포함합니다.',
        negatable: false,
      )
      ..addOption(
        'format',
        help: '출력 형식.',
        allowed: ['table', 'json', 'csv'],
        defaultsTo: 'table',
        allowedHelp: {
          'table': '테이블 형식으로 출력합니다.',
          'json': 'JSON 형식으로 출력합니다.',
          'csv': 'CSV 형식으로 출력합니다.',
        },
      );
  }

  @override
  String get name => 'stats';

  @override
  String get description => '디렉토리 파일 통계를 출력합니다.';

  @override
  Future<void> run() async {
    final targetPath = argResults!['path'] as String;
    final recursive = argResults!['recursive'] as bool;
    final format = argResults!['format'] as String;

    final dir = Directory(p.absolute(targetPath));
    if (!dir.existsSync()) {
      usageException('디렉토리가 존재하지 않습니다: $targetPath');
    }

    final files = await collectFiles(dir, recursive: recursive);

    // 카테고리별 집계
    final stats = <String, ({int count, int totalBytes})>{};
    for (final f in files) {
      final category = extensionToCategory(f.extension);
      final current = stats[category] ?? (count: 0, totalBytes: 0);
      stats[category] = (
        count: current.count + 1,
        totalBytes: current.totalBytes + f.sizeBytes,
      );
    }

    switch (format) {
      case 'table':
        _printTable(stats, files.length);
      case 'json':
        _printJson(stats);
      case 'csv':
        _printCsv(stats);
    }
  }

  void _printTable(
    Map<String, ({int count, int totalBytes})> stats,
    int total,
  ) {
    print('카테고리          파일 수    크기');
    print('-' * 40);
    for (final entry in stats.entries) {
      final mb = (entry.value.totalBytes / (1024 * 1024)).toStringAsFixed(1);
      print(
        '${entry.key.padRight(16)} ${entry.value.count.toString().padLeft(6)}    $mb MB',
      );
    }
    print('-' * 40);
    print('합계              ${total.toString().padLeft(6)}');
  }

  void _printJson(Map<String, ({int count, int totalBytes})> stats) {
    final buffer = StringBuffer('{\n');
    final entries = stats.entries.toList();
    for (var i = 0; i < entries.length; i++) {
      final e = entries[i];
      final comma = i < entries.length - 1 ? ',' : '';
      buffer.writeln(
        '  "${e.key}": {"count": ${e.value.count}, "bytes": ${e.value.totalBytes}}$comma',
      );
    }
    buffer.write('}');
    print(buffer);
  }

  void _printCsv(Map<String, ({int count, int totalBytes})> stats) {
    print('category,count,bytes');
    for (final e in stats.entries) {
      print('${e.key},${e.value.count},${e.value.totalBytes}');
    }
  }
}

실행 확인

# 현재 디렉토리 통계dart run bin/file_organizer.dart stats# 특정 디렉토리 재귀 탐색dart run bin/file_organizer.dart stats -p ~/Downloads -r# dry-run으로 organize 미리 보기dart run bin/file_organizer.dart organize -s ~/Downloads --dry-run
정리 시작: /Users/user/Downloads
[dry-run 모드] 실제 파일을 이동하지 않습니다.

  report.pdf → 문서/
  photo.jpg → 이미지/
  video.mp4 → 동영상/
  archive.zip → 압축/

완료: 시뮬레이션 4개 파일

정리

이번 챕터에서는 dart:iopath 패키지로 파일 시스템 작업을 구현했습니다.

  • Directory.list(recursive: true)로 재귀 탐색을 합니다.
  • path 패키지로 크로스 플랫폼 경로를 처리합니다.
  • 이름 충돌을 안전하게 처리하는 moveFileSafely 함수를 만들었습니다.
  • organizestats 커맨드가 실제로 동작합니다.

다음 챕터에서는 rename 커맨드를 완성하고, JSON과 CSV 데이터 처리를 추가합니다.