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:convert의jsonEncode/jsonDecode로 JSON을 처리했습니다.fromJson/toJson패턴으로 타입 안전한 모델을 만들었습니다.- RFC 4180 기반 CSV 파서를 직접 구현했습니다.
rename커맨드가 CSV 파일에서 규칙을 읽어 일괄 처리합니다.
다음 챕터에서는 ANSI 이스케이프 코드로 터미널 출력을 꾸미고 진행률 표시를 구현합니다.