파일과 디렉토리 다루기 — dart:io
커맨드 뼈대가 완성됐으니 이제 실제 파일 시스템 작업을 구현합니다. Dart의 dart:io 라이브러리는 파일, 디렉토리, 소켓, 프로세스를 다루는 종합 도구입니다. Flutter에서는 사용할 수 없지만, 서버와 CLI 환경에서는 핵심 라이브러리입니다.
dart:io 핵심 클래스
dart:io의 파일 관련 클래스는 세 가지입니다.
File: 파일 읽기, 쓰기, 복사, 이동, 삭제Directory: 디렉토리 생성, 목록 조회, 삭제FileSystemEntity:File과Directory의 공통 부모
모두 비동기 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:io와 path 패키지로 파일 시스템 작업을 구현했습니다.
Directory.list(recursive: true)로 재귀 탐색을 합니다.path패키지로 크로스 플랫폼 경로를 처리합니다.- 이름 충돌을 안전하게 처리하는
moveFileSafely함수를 만들었습니다. organize와stats커맨드가 실제로 동작합니다.
다음 챕터에서는 rename 커맨드를 완성하고, JSON과 CSV 데이터 처리를 추가합니다.