iBetter Books
수정

파일 저장과 불러오기

앞 챕터에서 만든 앱은 실행 중에는 잘 동작하지만, 종료하면 모든 데이터가 사라집니다. 메모리에만 저장하기 때문입니다. 프로그램을 다시 켜도 할 일 목록이 그대로 남아있으려면 파일에 저장해야 합니다.

이 챕터에서는 dart:iodart: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와 연결

TodoServiceTodoRepository와 연결해서 앱 시작 시 파일에서 불러오고, 종료 시 파일에 저장합니다.

// 새 파일: 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() ?? ''

다음 챕터에서는 사용자가 잘못된 입력을 했을 때 프로그램이 어떻게 반응할지 — 에러 처리와 입력 검증을 다룹니다.