에러 처리와 입력 검증
프로그램이 잘 동작할 때만 테스트하면 안 됩니다. 사용자는 예상치 못한 입력을 하고, 파일은 손상되어 있을 수 있으며, 존재하지 않는 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에 검증 추가
TodoRepository의 add()와 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() 전체 흐름을 완성하고, 작성한 코드를 되돌아보며 마무리합니다.