iBetter Books
수정

에러 처리와 입력 검증

프로그램이 잘 동작할 때만 테스트하면 안 됩니다. 사용자는 예상치 못한 입력을 하고, 파일은 손상되어 있을 수 있으며, 존재하지 않는 ID를 삭제하려 할 수도 있습니다.

견고한 프로그램은 이런 상황에서 죽지 않습니다. 오류를 발견하고, 사용자에게 명확한 메시지를 보여주고, 계속 실행됩니다. 이 챕터에서는 앱에 에러 처리와 입력 검증을 추가합니다.

커스텀 예외 설계

PART 08에서 커스텀 예외를 만드는 방법을 배웠습니다. 이 앱에서 발생할 수 있는 예외를 두 가지로 정의합니다.

  • TodoNotFoundException — 존재하지 않는 ID로 조회/수정/삭제를 시도할 때.
  • InvalidInputException — 빈 제목이나 음수 ID처럼 유효하지 않은 입력일 때.
// 새 파일: lib/exceptions.dart

class TodoNotFoundException implements Exception {
  final int id;
  TodoNotFoundException(this.id);

  @override
  String toString() => '[$id] 번 할 일을 찾을 수 없습니다.';
}

class InvalidInputException implements Exception {
  final String message;
  InvalidInputException(this.message);

  @override
  String toString() => '입력 오류: $message';
}

Exception 인터페이스를 구현(implements)했습니다. toString()을 재정의해서 print(e)처럼 예외를 출력할 때 의미 있는 메시지가 나오도록 합니다.

TodoRepository에 검증 추가

TodoRepositoryadd()getById(), toggleDone(), delete()에 입력 검증을 추가합니다. 커스텀 예외를 던지도록 변경합니다.

// 수정: lib/todo_repository.dart

import 'exceptions.dart';
import 'todo.dart';

class TodoRepository {
  final List<Todo> _todos = [];
  int _nextId = 1;

  List<Todo> getAll() => List.unmodifiable(_todos);

  Todo getById(int id) {
    try {
      return _todos.firstWhere((t) => t.id == id);
    } on StateError {
      throw TodoNotFoundException(id);
    }
  }

  Todo add(String title) {
    final trimmed = title.trim();
    if (trimmed.isEmpty) {
      throw InvalidInputException('할 일 내용을 입력하세요.');
    }
    final todo = Todo(
      id: _nextId++,
      title: trimmed,
      createdAt: DateTime.now(),
    );
    _todos.add(todo);
    return todo;
  }

  bool toggleDone(int id) {
    if (id <= 0) throw InvalidInputException('ID는 1 이상이어야 합니다.');
    final todo = getById(id);   // TodoNotFoundException 던질 수 있음
    todo.isDone = !todo.isDone;
    return true;
  }

  bool delete(int id) {
    if (id <= 0) throw InvalidInputException('ID는 1 이상이어야 합니다.');
    getById(id);   // 존재 확인 — 없으면 TodoNotFoundException
    final index = _todos.indexWhere((t) => t.id == id);
    _todos.removeAt(index);
    return true;
  }

  void loadAll(List<Todo> todos) {
    _todos.clear();
    _todos.addAll(todos);
    if (_todos.isNotEmpty) {
      _nextId = _todos.map((t) => t.id).reduce((a, b) => a > b ? a : b) + 1;
    }
  }

  List<Todo> getPending() =>
      _todos.where((t) => !t.isDone).toList();

  List<Todo> getDone() =>
      _todos.where((t) => t.isDone).toList();

  List<Todo> getSorted() {
    final sorted = [..._todos];
    sorted.sort((a, b) => a.id.compareTo(b.id));
    return sorted;
  }

  int get totalCount => _todos.length;
  int get doneCount => _todos.where((t) => t.isDone).length;
}

getById()의 반환 타입이 Todo?에서 Todo로 바뀌었습니다. 찾지 못하면 null을 반환하는 대신 예외를 던집니다. 덕분에 반환값을 사용하는 쪽에서 null 체크를 할 필요가 없어집니다.

add()는 빈 문자열 검사를 합니다. trim()으로 앞뒤 공백을 제거한 뒤 검사하기 때문에 공백만 입력해도 걸러집니다.

todo_handler.dart에 예외 처리 추가

TodoRepository가 예외를 던지기 시작했으니, todo_handler.dart에서 잡아서 사용자에게 전달해야 합니다.

// 수정: lib/todo_handler.dart

import 'command.dart';
import 'exceptions.dart';
import 'todo_repository.dart';

String handleCommand(Command command, TodoRepository repo) {
  return switch (command) {
    AddCommand(title: final t) => _handleAdd(t, repo),
    ListCommand() => _handleList(repo),
    DoneCommand(id: final id) => _handleDone(id, repo),
    DeleteCommand(id: final id) => _handleDelete(id, repo),
    ExitCommand() => '',
  };
}

String _handleAdd(String title, TodoRepository repo) {
  try {
    final todo = repo.add(title);
    return '[${todo.id}] ${todo.title} — 추가되었습니다.';
  } on InvalidInputException catch (e) {
    return e.toString();
  }
}

String _handleList(TodoRepository repo) {
  final todos = repo.getSorted();
  if (todos.isEmpty) return '할 일이 없습니다.';
  return todos.map((t) => t.toString()).join('\n');
}

String _handleDone(int id, TodoRepository repo) {
  try {
    repo.toggleDone(id);
    final todo = repo.getById(id);
    final state = todo.isDone ? '완료 처리' : '미완료로 복원';
    return '[${todo.id}] ${todo.title} — $state했습니다.';
  } on TodoNotFoundException catch (e) {
    return e.toString();
  } on InvalidInputException catch (e) {
    return e.toString();
  }
}

String _handleDelete(int id, TodoRepository repo) {
  try {
    final todo = repo.getById(id);
    final title = todo.title;
    repo.delete(id);
    return '[$id] $title — 삭제했습니다.';
  } on TodoNotFoundException catch (e) {
    return e.toString();
  } on InvalidInputException catch (e) {
    return e.toString();
  }
}

on TodoNotFoundException catch (e)처럼 예외 타입을 명시하면 해당 타입의 예외만 잡습니다. PART 08에서 배운 on 절입니다.

_handleDelete()는 삭제 전에 getById()로 제목을 미리 가져옵니다. 삭제 후에는 객체에 접근할 수 없기 때문입니다.

파일 I/O 에러 처리

TodoService의 파일 읽기/쓰기도 실패할 수 있습니다. 파일이 손상되었거나, 쓰기 권한이 없거나, 디스크가 가득 찼을 때입니다.

// 수정: 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 [];
    }

    try {
      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();
    } on FormatException catch (e) {
      print('⚠ 저장 파일이 손상되었습니다. 빈 목록으로 시작합니다. ($e)');
      return [];
    } on IOException catch (e) {
      print('⚠ 파일 읽기 오류: $e');
      return [];
    }
  }

  Future<void> save(List<Todo> todos) async {
    try {
      final file = File(filePath);
      final jsonList = todos.map((t) => t.toJson()).toList();
      final content = jsonEncode(jsonList);
      await file.writeAsString(content);
    } on IOException catch (e) {
      print('⚠ 파일 저장 오류: $e');
    }
  }
}

FormatException은 JSON 파싱이 실패할 때 발생합니다. 파일 내용이 유효하지 않은 JSON이면 이 예외가 발생합니다. 빈 목록으로 시작하고 경고 메시지를 출력합니다.

IOException은 파일 읽기/쓰기 자체가 실패할 때 발생합니다. 권한 문제나 디스크 오류가 여기에 해당합니다.

에러 상황 테스트

에러 처리가 잘 동작하는지 확인합니다.

> add
입력 오류: 할 일 내용을 입력하세요.

> add    
입력 오류: 할 일 내용을 입력하세요.

> done 999
[999] 번 할 일을 찾을 수 없습니다.

> delete 0
입력 오류: ID는 1 이상이어야 합니다.

> 모르는명령어
알 수 없는 명령어입니다. (add, list, done, delete, exit)

프로그램이 종료되지 않고 계속 실행됩니다. 오류 메시지를 보여주고 다음 입력을 기다립니다.

Null Safety 재확인

커스텀 예외 도입 이후 Null Safety가 어떻게 달라졌는지 비교합니다.

변경 전 getById()Todo?를 반환했습니다.

// 이전 방식
final todo = repo.getById(id);
if (todo == null) return '찾을 수 없습니다.';
todo.isDone = true;   // 여기서는 non-null로 스마트 캐스트됨

변경 후 getById()Todo를 반환합니다. 없으면 예외를 던집니다.

// 현재 방식
final todo = repo.getById(id);   // 예외 없으면 항상 Todo
todo.isDone = true;   // null 체크 불필요

예외를 던지는 방식이 항상 더 좋은 것은 아닙니다. null을 반환하는 것이 자연스러운 경우도 있습니다. "조회 실패"가 예외적인 상황이라면 예외를 던지고, "없을 수도 있다"는 정상적인 경우라면 null을 반환하는 것이 적절합니다. 이 앱에서 존재하지 않는 ID를 done하거나 delete하려는 것은 잘못된 사용이므로 예외가 적절합니다.

이 챕터에서 사용된 개념

개념 어디에서 배웠나 사용한 곳
커스텀 예외 PART 08 TodoNotFoundException, InvalidInputException
try-catch-on PART 08 _handleAdd, _handleDone, _handleDelete
Null Safety PART 02, 06 getById() 반환 타입 변경
흐름 분석 PART 06 예외 후 non-null 보장

다음 챕터에서는 main() 전체 흐름을 완성하고, 작성한 코드를 되돌아보며 마무리합니다.