파일 저장과 불러오기
앞 챕터에서 만든 앱은 실행 중에는 잘 동작하지만, 종료하면 모든 데이터가 사라집니다. 메모리에만 저장하기 때문입니다. 프로그램을 다시 켜도 할 일 목록이 그대로 남아있으려면 파일에 저장해야 합니다.
이 챕터에서는 dart:io와 dart:convert를 사용해 JSON 파일에 데이터를 저장하고 불러옵니다. PART 08에서 배운 async/await가 여기에서도 활약합니다.
dart:io와 dart:convert
Dart는 파일 I/O를 위한 표준 라이브러리를 기본으로 제공합니다.
dart:io— 파일, 소켓, 프로세스 등 운영체제 자원을 다루는 라이브러리입니다.dart:convert— JSON, UTF-8 등 인코딩 변환을 담당합니다.
두 라이브러리 모두 별도 패키지 설치 없이 import만으로 사용할 수 있습니다.
TodoService 클래스 설계
파일 I/O를 담당하는 클래스를 별도로 분리합니다. TodoRepository는 데이터를 메모리에서 관리하는 역할에만 집중하고, 파일 저장/불러오기는 TodoService가 맡습니다.
역할을 분리하면 나중에 저장 방식을 파일에서 데이터베이스로 바꿀 때 TodoService만 수정하면 됩니다. TodoRepository는 건드릴 필요가 없습니다.
// 새 파일: lib/todo_service.dart
import 'dart:convert';
import 'dart:io';
import 'todo.dart';
class TodoService {
final String filePath;
TodoService({this.filePath = 'todos.json'});
Future<List<Todo>> load() async {
final file = File(filePath);
if (!await file.exists()) {
return [];
}
final content = await file.readAsString();
if (content.trim().isEmpty) return [];
final List<dynamic> jsonList = jsonDecode(content) as List<dynamic>;
return jsonList
.map((e) => Todo.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> save(List<Todo> todos) async {
final file = File(filePath);
final jsonList = todos.map((t) => t.toJson()).toList();
final content = jsonEncode(jsonList);
await file.writeAsString(content);
}
}
load()를 먼저 살펴봅니다.
File(filePath)는 파일 경로를 가리키는 객체입니다. 이 시점에 파일을 열거나 읽지는 않습니다.
file.exists()는 파일이 실제로 존재하는지 확인합니다. 처음 실행할 때는 파일이 없습니다. 없으면 빈 리스트를 반환합니다.
file.readAsString()은 파일 전체 내용을 문자열로 읽습니다. 비동기 메서드이므로 await을 붙입니다.
jsonDecode()는 JSON 문자열을 Dart 객체로 변환합니다. 최상위는 배열이므로 List<dynamic>으로 캐스팅합니다. 각 원소를 Map<String, dynamic>으로 변환한 뒤 Todo.fromJson()에 넘깁니다.
save()는 todos 리스트를 JSON으로 직렬화한 뒤 파일에 씁니다.
jsonEncode()는 Dart 객체를 JSON 문자열로 변환합니다. writeAsString()으로 파일에 저장합니다.
파일이 없을 때 처리
load()에서 파일이 없으면 빈 리스트를 반환합니다. 처음 실행하는 사용자에게 오류가 발생하면 좋지 않습니다. 파일이 없는 것은 "아직 저장한 데이터가 없다"는 정상적인 상태입니다.
파일이 있더라도 내용이 빈 경우가 있을 수 있습니다. content.trim().isEmpty 확인으로 이 경우도 처리합니다.
TodoRepository와 연결
TodoService를 TodoRepository와 연결해서 앱 시작 시 파일에서 불러오고, 종료 시 파일에 저장합니다.
// 새 파일: bin/todo_app.dart
import 'dart:io';
import 'package:todo_app/command.dart';
import 'package:todo_app/todo_handler.dart';
import 'package:todo_app/todo_repository.dart';
import 'package:todo_app/todo_service.dart';
Future<void> main() async {
final service = TodoService();
final repo = TodoRepository();
// 파일에서 데이터 불러오기
final savedTodos = await service.load();
repo.loadAll(savedTodos);
print('할 일 관리 앱에 오신 것을 환영합니다.');
print('명령어: add <내용> | list | done <id> | delete <id> | exit');
print('');
// 사용자 입력 루프
while (true) {
stdout.write('> ');
final input = stdin.readLineSync() ?? '';
final command = parseCommand(input);
if (command == null) {
print('알 수 없는 명령어입니다. (add, list, done, delete, exit)');
continue;
}
if (command is ExitCommand) {
await service.save(repo.getAll().toList());
print('저장 후 종료합니다.');
break;
}
final result = handleCommand(command, repo);
if (result.isNotEmpty) print(result);
}
}
main()이 async가 되었습니다. await를 사용하려면 함수가 비동기여야 합니다.
stdin.readLineSync()는 사용자가 엔터를 누를 때까지 입력을 기다립니다. ??로 null이면 빈 문자열로 대체합니다. PART 06의 Null Safety가 활용되는 자연스러운 예입니다.
ExitCommand일 때만 저장합니다. 매 명령마다 저장하면 성능이 떨어지기 때문입니다. 앱을 정상 종료할 때 한 번만 저장합니다.
자동 저장 옵션 추가
add, done, delete 명령 후 자동으로 저장하고 싶다면 handleCommand가 저장 여부를 알려주면 됩니다. 이 경우 반환 타입을 레코드로 바꿀 수 있습니다.
// 참고 코드 (선택적 리팩터링)
(String message, bool shouldSave) handleCommandWithSave(
Command command, TodoRepository repo) {
return switch (command) {
AddCommand(title: final t) => (_handleAdd(t, repo), true),
ListCommand() => (_handleList(repo), false),
DoneCommand(id: final id) => (_handleDone(id, repo), true),
DeleteCommand(id: final id) => (_handleDelete(id, repo), true),
ExitCommand() => ('', false),
};
}
반환 타입 (String, bool)은 PART 09에서 배운 레코드입니다. 메시지와 "저장 필요 여부"를 함께 반환합니다. 이 방식을 선택하면 데이터가 변경되는 명령 후 즉시 저장해서 갑작스러운 종료에도 데이터를 보호할 수 있습니다.
이 교재에서는 간결함을 위해 종료 시 한 번 저장하는 방식을 유지합니다.
실행 확인
앱을 두 번 실행해 데이터가 유지되는지 확인합니다.
첫 번째 실행입니다.
할 일 관리 앱에 오신 것을 환영합니다.
명령어: add <내용> | list | done <id> | delete <id> | exit
> add Dart 공부
[1] Dart 공부 — 추가되었습니다.
> add 산책하기
[2] 산책하기 — 추가되었습니다.
> exit
저장 후 종료합니다.
종료 후 todos.json 파일이 생성되어 있습니다. 내용을 확인하면 다음과 같습니다.
[{"id":1,"title":"Dart 공부","isDone":false,"createdAt":"2026-04-26T09:00:00.000"},{"id":2,"title":"산책하기","isDone":false,"createdAt":"2026-04-26T09:00:01.000"}]
두 번째 실행입니다.
할 일 관리 앱에 오신 것을 환영합니다.
명령어: add <내용> | list | done <id> | delete <id> | exit
> list
[1] [ ] Dart 공부
[2] [ ] 산책하기
이전에 입력한 데이터가 그대로 남아 있습니다.
이 챕터에서 사용된 개념
| 개념 | 어디에서 배웠나 | 사용한 곳 |
|---|---|---|
dart:io 파일 읽기/쓰기 |
PART 08 | TodoService.load, save |
dart:convert JSON |
PART 08 | jsonEncode, jsonDecode |
| async/await | PART 08 | main(), load(), save() |
| factory 생성자 | PART 05 | Todo.fromJson() |
| 레코드 | PART 09 | 자동 저장 리팩터링 예시 |
Null Safety (??) |
PART 02, 06 | stdin.readLineSync() ?? '' |
다음 챕터에서는 사용자가 잘못된 입력을 했을 때 프로그램이 어떻게 반응할지 — 에러 처리와 입력 검증을 다룹니다.