iBetter Books
수정

JSON과 CSV 데이터 처리

stats 커맨드가 JSON과 CSV 형식으로 출력하는 기능을 뼈대만 만들어 뒀습니다. 이번 챕터에서는 dart:convert로 JSON을 제대로 처리하고, CSV 파싱 로직도 구현합니다. 그리고 rename 커맨드가 규칙을 CSV 파일에서 읽어올 수 있도록 만듭니다.

dart:convert — JSON 처리

dart:convert는 JSON, UTF-8, Base64 등 다양한 인코딩/디코딩 기능을 제공합니다. 외부 패키지 없이 JSON을 처리할 수 있습니다.

import 'dart:convert';

// 인코딩 (Dart 객체 → JSON 문자열)
final data = {
  'name': 'file_organizer',
  'version': '1.0.0',
  'files': [1, 2, 3],
};
final json = jsonEncode(data);
// → '{"name":"file_organizer","version":"1.0.0","files":[1,2,3]}'

// 들여쓰기 있는 JSON
const encoder = JsonEncoder.withIndent('  ');
final pretty = encoder.convert(data);

// 디코딩 (JSON 문자열 → Dart 객체)
final decoded = jsonDecode(json) as Map<String, dynamic>;
print(decoded['name']); // → file_organizer

jsonDecode가 반환하는 타입은 JSON 구조에 따라 다릅니다.

JSON 타입 Dart 타입
{} 객체 Map<String, dynamic>
[] 배열 List<dynamic>
문자열 String
숫자 int 또는 double
불리언 bool
null null

JSON 직렬화 패턴

단순히 Map으로 다루면 타입 안전성이 낮습니다. 모델 클래스에 fromJson/toJson을 추가하는 것이 실무 패턴입니다.

// 새 파일: lib/src/models/rename_rule.dart

/// 이름 변경 규칙을 나타내는 값 객체.
class RenameRule {
  const RenameRule({
    required this.pattern,
    required this.replacement,
    this.description,
  });

  /// 변경 전 파일명 패턴 (정규식).
  final String pattern;

  /// 변경 후 파일명 템플릿.
  final String replacement;

  /// 규칙 설명 (선택).
  final String? description;

  /// JSON Map에서 [RenameRule]을 생성합니다.
  factory RenameRule.fromJson(Map<String, dynamic> json) {
    return RenameRule(
      pattern: json['pattern'] as String,
      replacement: json['replacement'] as String,
      description: json['description'] as String?,
    );
  }

  /// [RenameRule]을 JSON Map으로 변환합니다.
  Map<String, dynamic> toJson() {
    return {
      'pattern': pattern,
      'replacement': replacement,
      if (description != null) 'description': description,
    };
  }

  @override
  String toString() => 'RenameRule($pattern → $replacement)';
}

JSON 파일 읽기 / 쓰기

이름 변경 규칙을 JSON 파일로 관리하는 유틸리티를 만듭니다.

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

import '../models/rename_rule.dart';

/// JSON 파일에서 이름 변경 규칙 목록을 읽습니다.
Future<List<RenameRule>> loadRulesFromJson(String filePath) async {
  final file = File(filePath);
  if (!file.existsSync()) {
    throw FileSystemException('파일을 찾을 수 없습니다', filePath);
  }

  final content = await file.readAsString();
  final dynamic raw = jsonDecode(content);

  if (raw is! List) {
    throw FormatException('최상위 요소는 배열이어야 합니다: $filePath');
  }

  return raw
      .cast<Map<String, dynamic>>()
      .map(RenameRule.fromJson)
      .toList();
}

/// 이름 변경 규칙 목록을 JSON 파일로 저장합니다.
Future<void> saveRulesToJson(List<RenameRule> rules, String filePath) async {
  const encoder = JsonEncoder.withIndent('  ');
  final content = encoder.convert(rules.map((r) => r.toJson()).toList());
  await File(filePath).writeAsString(content);
}

/// JSON 규칙 파일의 예제를 생성합니다.
Future<void> createSampleRulesFile(String filePath) async {
  final sample = [
    RenameRule(
      pattern: r'^IMG_(\d+)',
      replacement: 'photo_\$1',
      description: 'iPhone 사진 파일명 변환',
    ),
    RenameRule(
      pattern: r'\s+',
      replacement: '_',
      description: '공백을 언더스코어로 변환',
    ),
  ];

  await saveRulesToJson(sample, filePath);
  print('예제 규칙 파일 생성: $filePath');
}

CSV 파싱 구현

Dart 생태계에 CSV 파싱 패키지가 있지만, 간단한 경우에는 직접 구현하는 것도 좋습니다. ", ,, 줄바꿈을 올바르게 처리하는 파서를 만듭니다.

// 새 파일: lib/src/utils/csv_utils.dart

/// CSV 한 줄을 필드 목록으로 파싱합니다.
///
/// RFC 4180 규격을 기본으로 지원합니다.
/// - 필드를 큰따옴표로 감쌀 수 있습니다.
/// - 큰따옴표 안의 쉼표는 구분자로 처리하지 않습니다.
/// - 큰따옴표를 이스케이프하려면 `""` 를 사용합니다.
List<String> parseCsvLine(String line) {
  final fields = <String>[];
  final buffer = StringBuffer();
  var inQuotes = false;

  for (var i = 0; i < line.length; i++) {
    final char = line[i];

    if (char == '"') {
      if (inQuotes && i + 1 < line.length && line[i + 1] == '"') {
        // 이스케이프된 따옴표 ("") → 따옴표 하나로 변환
        buffer.write('"');
        i++; // 다음 따옴표 건너뜀
      } else {
        inQuotes = !inQuotes;
      }
    } else if (char == ',' && !inQuotes) {
      fields.add(buffer.toString());
      buffer.clear();
    } else {
      buffer.write(char);
    }
  }

  fields.add(buffer.toString());
  return fields;
}

/// CSV 문자열 전체를 파싱합니다. 첫 줄은 헤더로 처리합니다.
///
/// 반환: 헤더 키를 키로 갖는 Map 목록.
List<Map<String, String>> parseCsv(String content) {
  final lines = content.split('\n').where((l) => l.trim().isNotEmpty).toList();

  if (lines.isEmpty) return [];

  final headers = parseCsvLine(lines.first);
  final result = <Map<String, String>>[];

  for (final line in lines.skip(1)) {
    final fields = parseCsvLine(line);
    final row = <String, String>{};
    for (var i = 0; i < headers.length; i++) {
      row[headers[i]] = i < fields.length ? fields[i] : '';
    }
    result.add(row);
  }

  return result;
}

/// CSV 파일에서 이름 변경 규칙을 읽습니다.
///
/// CSV 형식 (헤더 필수):
/// ```
/// pattern,replacement,description
/// ^IMG_(\d+),photo_$1,iPhone 사진 변환
/// ```
List<({String pattern, String replacement, String? description})>
    parseRulesFromCsv(String csvContent) {
  final rows = parseCsv(csvContent);

  return rows.map((row) {
    final description = row['description'];
    return (
      pattern: row['pattern'] ?? '',
      replacement: row['replacement'] ?? '',
      description: (description?.isEmpty ?? true) ? null : description,
    );
  }).where((r) => r.pattern.isNotEmpty).toList();
}

/// 데이터를 CSV 형식으로 직렬화합니다.
String toCsv(List<Map<String, dynamic>> data) {
  if (data.isEmpty) return '';

  final headers = data.first.keys.toList();
  final buffer = StringBuffer();

  // 헤더 행
  buffer.writeln(headers.map(_escapeCsvField).join(','));

  // 데이터 행
  for (final row in data) {
    buffer.writeln(
      headers.map((h) => _escapeCsvField(row[h]?.toString() ?? '')).join(','),
    );
  }

  return buffer.toString();
}

String _escapeCsvField(String field) {
  // 쉼표, 큰따옴표, 줄바꿈이 있으면 큰따옴표로 감쌈
  if (field.contains(',') || field.contains('"') || field.contains('\n')) {
    return '"${field.replaceAll('"', '""')}"';
  }
  return field;
}

RenameCommand 완성

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

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

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

class RenameCommand extends Command<void> {
  RenameCommand() {
    argParser
      ..addOption(
        'path',
        abbr: 'p',
        help: '대상 디렉토리 경로.',
        defaultsTo: '.',
      )
      ..addOption(
        'pattern',
        help: '변경 전 파일명 패턴 (정규식).',
      )
      ..addOption(
        'replacement',
        help: '변경 후 파일명.',
      )
      ..addOption(
        'rules-csv',
        help: '규칙이 담긴 CSV 파일 경로.',
      )
      ..addFlag(
        'dry-run',
        help: '실제로 변경하지 않고 결과만 출력합니다.',
        negatable: false,
      );
  }

  @override
  String get name => 'rename';

  @override
  String get description => '정규식 패턴으로 파일을 일괄 이름 변경합니다.';

  @override
  Future<void> run() async {
    final targetPath = argResults!['path'] as String;
    final pattern = argResults!['pattern'] as String?;
    final replacement = argResults!['replacement'] as String?;
    final rulesCsvPath = argResults!['rules-csv'] as String?;
    final dryRun = argResults!['dry-run'] as bool;

    // 규칙 수집
    final rules = <({String pattern, String replacement})>[];

    if (rulesCsvPath != null) {
      final csvContent = await File(rulesCsvPath).readAsString();
      final csvRules = parseRulesFromCsv(csvContent);
      rules.addAll(csvRules.map((r) => (pattern: r.pattern, replacement: r.replacement)));
    }

    if (pattern != null && replacement != null) {
      rules.add((pattern: pattern, replacement: replacement));
    }

    if (rules.isEmpty) {
      usageException('--pattern/--replacement 또는 --rules-csv 중 하나를 지정해야 합니다.');
    }

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

    if (dryRun) print('[dry-run 모드]\n');

    final files = await collectFiles(dir);
    var renamed = 0;

    for (final fileInfo in files) {
      var newName = fileInfo.name;

      for (final rule in rules) {
        final regex = RegExp(rule.pattern);
        newName = newName.replaceAll(regex, rule.replacement);
      }

      if (newName == fileInfo.name) continue;

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

      if (!dryRun) {
        final newPath = p.join(p.dirname(fileInfo.path), newName);
        await File(fileInfo.path).rename(newPath);
        renamed++;
      }
    }

    print('\n완료: ${dryRun ? "시뮬레이션" : "변경"} ${dryRun ? 0 : renamed}개 파일');
  }
}

데이터 변환 파이프라인

stats 결과를 JSON 파일로 저장하는 --output 옵션을 추가합니다.

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

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

import '../utils/csv_utils.dart';
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 형식으로 출력합니다.',
        },
      )
      ..addOption(
        'output',
        abbr: 'o',
        help: '결과를 저장할 파일 경로 (생략 시 stdout).',
      );
  }

  @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 outputPath = argResults!['output'] 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,
      );
    }

    String output;
    switch (format) {
      case 'json':
        output = _toJson(stats);
      case 'csv':
        output = _toCsv(stats);
      default:
        output = _toTable(stats, files.length);
    }

    if (outputPath != null) {
      await File(outputPath).writeAsString(output);
      print('결과 저장: $outputPath');
    } else {
      print(output);
    }
  }

  String _toJson(Map<String, ({int count, int totalBytes})> stats) {
    const encoder = JsonEncoder.withIndent('  ');
    return encoder.convert({
      for (final e in stats.entries)
        e.key: {'count': e.value.count, 'bytes': e.value.totalBytes},
    });
  }

  String _toCsv(Map<String, ({int count, int totalBytes})> stats) {
    return toCsv([
      for (final e in stats.entries)
        {'category': e.key, 'count': e.value.count, 'bytes': e.value.totalBytes},
    ]);
  }

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

실행 확인

# JSON으로 출력하고 파일로 저장dart run bin/file_organizer.dart stats --format json -o stats.json# CSV로 출력dart run bin/file_organizer.dart stats --format csv# CSV 규칙 파일로 이름 변경dart run bin/file_organizer.dart rename --rules-csv rules.csv --dry-run

정리

이번 챕터에서는 데이터 처리 능력을 갖췄습니다.

  • dart:convertjsonEncode/jsonDecode로 JSON을 처리했습니다.
  • fromJson/toJson 패턴으로 타입 안전한 모델을 만들었습니다.
  • RFC 4180 기반 CSV 파서를 직접 구현했습니다.
  • rename 커맨드가 CSV 파일에서 규칙을 읽어 일괄 처리합니다.

다음 챕터에서는 ANSI 이스케이프 코드로 터미널 출력을 꾸미고 진행률 표시를 구현합니다.