iBetter Books
수정

핵심 기능 구현

데이터 모델과 명령어 타입을 설계했으니 이제 실제로 동작하는 로직을 만들 차례입니다. 할 일을 추가하고, 조회하고, 완료 처리하고, 삭제하는 기능 — 이것이 이 앱의 심장입니다.

TodoRepository 설계

할 일 목록을 관리하는 클래스입니다. 데이터를 어디에 어떻게 저장하는지는 이 클래스가 결정합니다. 지금은 메모리(List)에 저장합니다. 파일 저장은 Ch 03에서 추가합니다.

// 새 파일: lib/todo_repository.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 {
      return null;
    }
  }

  Todo add(String title) {
    final todo = Todo(
      id: _nextId++,
      title: title,
      createdAt: DateTime.now(),
    );
    _todos.add(todo);
    return todo;
  }

  bool toggleDone(int id) {
    final todo = getById(id);
    if (todo == null) return false;
    todo.isDone = !todo.isDone;
    return true;
  }

  bool delete(int id) {
    final index = _todos.indexWhere((t) => t.id == id);
    if (index == -1) return false;
    _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;
    }
  }
}

하나씩 살펴보겠습니다.

_todosList<Todo>입니다. 클래스 바깥에서 직접 수정하지 못하도록 언더스코어로 시작하는 이름을 붙였습니다.

_nextId는 새로운 Todo가 추가될 때마다 1씩 증가합니다. 삭제가 일어나도 ID가 재사용되지 않아 안전합니다.

getAll()List.unmodifiable()로 감싸서 반환합니다. 외부에서 리스트를 직접 추가하거나 삭제하는 것을 막기 위해서입니다.

getById()firstWhere()를 사용합니다. 해당 ID가 없으면 StateError가 발생하는데, try-catch로 잡아서 null을 반환합니다.

loadAll()은 파일에서 불러온 데이터를 저장소에 채울 때 사용합니다. 기존 목록을 비우고 새로 채우며, ID 시퀀스도 맞게 조정합니다.

컬렉션 메서드 활용

TodoRepository에 조금 더 유용한 조회 기능을 추가해봅니다. PART 04에서 배운 컬렉션 메서드가 여기에서 빛을 발합니다.

// 수정: lib/todo_repository.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 {
      return null;
    }
  }

  Todo add(String title) {
    final todo = Todo(
      id: _nextId++,
      title: title,
      createdAt: DateTime.now(),
    );
    _todos.add(todo);
    return todo;
  }

  bool toggleDone(int id) {
    final todo = getById(id);
    if (todo == null) return false;
    todo.isDone = !todo.isDone;
    return true;
  }

  bool delete(int id) {
    final index = _todos.indexWhere((t) => t.id == id);
    if (index == -1) return false;
    _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;
}

getPending()getDone()where()로 미완료, 완료 항목을 걸러냅니다. getSorted()는 스프레드 연산자(...)로 리스트를 복사한 뒤 정렬합니다. 원본 _todos를 건드리지 않기 위해서입니다.

totalCountdoneCount는 getter로 선언했습니다. 메서드처럼 괄호 없이 repo.totalCount로 접근할 수 있습니다.

Command 처리 — switch 표현식

Command 객체를 받아서 TodoRepository에 알맞은 동작을 지시하는 함수입니다. bin/todo_app.dart에 진입점을 만들기 전에 이 처리 로직을 먼저 작성합니다.

// 새 파일: lib/todo_handler.dart

import 'command.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) {
  final todo = repo.add(title);
  return '[${todo.id}] ${todo.title} — 추가되었습니다.';
}

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) {
  if (id <= 0) return '올바른 ID를 입력하세요.';
  final success = repo.toggleDone(id);
  if (!success) return '[$id] — 해당 할 일을 찾을 수 없습니다.';
  final todo = repo.getById(id)!;
  final state = todo.isDone ? '완료 처리' : '미완료로 복원';
  return '[${todo.id}] ${todo.title} — $state했습니다.';
}

String _handleDelete(int id, TodoRepository repo) {
  if (id <= 0) return '올바른 ID를 입력하세요.';
  final todo = repo.getById(id);
  if (todo == null) return '[$id] — 해당 할 일을 찾을 수 없습니다.';
  repo.delete(id);
  return '[${todo.id}] ${todo.title} — 삭제했습니다.';
}

switch 표현식 안에서 PART 09에서 배운 구조 분해 패턴이 사용됩니다. AddCommand(title: final t)AddCommand이면서 그 title 필드를 t라는 변수에 바인딩한다는 뜻입니다.

sealed class를 사용했기 때문에 _ 기본 케이스가 필요 없습니다. 컴파일러가 모든 하위 타입을 처리했는지 확인합니다.

_handleDone()toggleDone()을 사용합니다. 완료 → 미완료, 미완료 → 완료로 토글합니다. 완료 표시를 취소하고 싶을 때도 같은 명령으로 처리됩니다.

간단한 실행 테스트

지금까지 만든 코드가 제대로 동작하는지 간단히 확인합니다. bin/todo_app.dart에 임시 코드를 작성합니다.

// 새 파일: bin/todo_app.dart

import 'package:todo_app/command.dart';
import 'package:todo_app/todo_repository.dart';
import 'package:todo_app/todo_handler.dart';

void main() {
  final repo = TodoRepository();

  final commands = [
    parseCommand('add Dart 교재 읽기')!,
    parseCommand('add 운동하기')!,
    parseCommand('list')!,
    parseCommand('done 1')!,
    parseCommand('list')!,
    parseCommand('delete 2')!,
    parseCommand('list')!,
  ];

  for (final cmd in commands) {
    final result = handleCommand(cmd, repo);
    print(result);
  }
}

실행하면 다음과 같은 출력이 나옵니다.

[1] Dart 교재 읽기 — 추가되었습니다.
[2] 운동하기 — 추가되었습니다.
[1] [ ] Dart 교재 읽기
[2] [ ] 운동하기
[1] Dart 교재 읽기 — 완료 처리했습니다.
[1] [v] Dart 교재 읽기
[2] [ ] 운동하기
[2] 운동하기 — 삭제했습니다.
[1] [v] Dart 교재 읽기

명령어가 순서대로 실행되고 결과가 올바르게 출력됩니다.

이 챕터에서 사용된 개념

이 챕터에서 어떤 개념들이 활용되었는지 정리합니다.

개념 어디에서 배웠나 사용한 곳
클래스와 생성자 PART 05 TodoRepository, Todo
where, map, reduce PART 04 getPending, getSorted, doneCount
getter PART 05 totalCount, doneCount
sealed class PART 09 Command 처리
구조 분해 패턴 PART 09 switch 표현식 내 필드 추출
스프레드 연산자 PART 04 getSorted()의 복사
Null Safety PART 06 getById() 반환값 처리

다음 챕터에서는 파일 저장과 불러오기를 추가합니다. 프로그램을 종료했다가 다시 실행해도 데이터가 유지되도록 만들겠습니다.