iBetter Books
수정

프로젝트 설계와 데이터 모델링

PART 02부터 PART 09까지 달려왔습니다. 변수와 타입부터 시작해서 Null Safety, 클래스, 비동기, 패턴 매칭까지 — 많은 개념을 배웠습니다. 그런데 배운 것들이 실제로 어떻게 연결되는지 아직 감이 잡히지 않을 수 있습니다.

PART 10에서는 그 모든 것을 하나로 엮어 실제 동작하는 프로그램을 만들어봅니다. 주제는 "할 일 관리 CLI 앱"입니다. 터미널에서 할 일을 추가하고, 목록을 보고, 완료 처리하고, 파일에 저장하는 — 작지만 완전한 프로그램입니다.

코드를 처음부터 끝까지 직접 설계하고 구현하면서 Dart가 어떤 언어인지 온전히 느껴보겠습니다.

무엇을 만드는가

할 일 관리 CLI 앱은 터미널에서 실행되는 프로그램입니다. 사용자가 명령어를 입력하면 그에 맞는 동작을 수행합니다.

지원할 기능 목록입니다.

  • add — 새로운 할 일을 추가합니다.
  • list — 전체 할 일 목록을 출력합니다.
  • done — 특정 할 일을 완료 상태로 표시합니다.
  • delete — 특정 할 일을 삭제합니다.
  • exit — 프로그램을 종료합니다.

실행 화면을 미리 보면 다음과 같습니다.

> add Dart 교재 읽기
[1] Dart 교재 읽기 — 추가되었습니다.

> add 운동하기
[2] 운동하기 — 추가되었습니다.

> list
[1] [ ] Dart 교재 읽기
[2] [ ] 운동하기

> done 1
[1] Dart 교재 읽기 — 완료 처리했습니다.

> list
[1] [v] Dart 교재 읽기
[2] [ ] 운동하기

> delete 2
[2] 운동하기 — 삭제했습니다.

> exit
저장 후 종료합니다.

프로젝트 생성

Dart CLI 프로젝트 템플릿으로 프로젝트를 생성합니다.

dart create -t console todo_appcd todo_app

생성된 기본 구조에서 bin/todo_app.dart는 진입점(entry point)이고, lib/ 폴더에 핵심 로직을 배치합니다.

이 프로젝트에서 사용할 폴더 구조입니다.

todo_app/
├── bin/
│   └── todo_app.dart       ← 진입점. main() 함수
├── lib/
│   ├── todo.dart           ← Todo 데이터 모델
│   ├── command.dart        ← sealed class Command
│   ├── todo_repository.dart ← 할 일 저장소
│   └── todo_service.dart   ← 파일 I/O 서비스
└── pubspec.yaml

bin/에는 실행 진입점만 두고, 모든 비즈니스 로직은 lib/에 분리합니다. 역할이 분명해지면 코드를 읽기도, 수정하기도 쉬워집니다.

Todo 클래스 설계

할 일 하나를 표현하는 데이터 모델입니다. 어떤 필드가 필요한지 생각해봅니다.

  • id — 각 할 일을 구별하는 고유 번호입니다.
  • title — 할 일 내용입니다.
  • isDone — 완료 여부입니다.
  • createdAt — 생성 시각입니다.

이 네 가지 필드로 Todo 클래스를 정의합니다.

// 새 파일: lib/todo.dart

class Todo {
  final int id;
  final String title;
  bool isDone;
  final DateTime createdAt;

  Todo({
    required this.id,
    required this.title,
    this.isDone = false,
    required this.createdAt,
  });
}

id, title, createdAtfinal로 선언했습니다. 한 번 정해지면 바뀌지 않는 값들입니다. isDonebool로 두어 나중에 완료 처리할 수 있게 합니다.

JSON 직렬화 메서드 추가

할 일 데이터를 파일에 저장하려면 JSON으로 변환해야 합니다. toJson()fromJson()을 함께 설계합니다.

// 새 파일: lib/todo.dart

class Todo {
  final int id;
  final String title;
  bool isDone;
  final DateTime createdAt;

  Todo({
    required this.id,
    required this.title,
    this.isDone = false,
    required this.createdAt,
  });

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'isDone': isDone,
      'createdAt': createdAt.toIso8601String(),
    };
  }

  factory Todo.fromJson(Map<String, dynamic> json) {
    return Todo(
      id: json['id'] as int,
      title: json['title'] as String,
      isDone: json['isDone'] as bool,
      createdAt: DateTime.parse(json['createdAt'] as String),
    );
  }

  @override
  String toString() {
    final status = isDone ? '[v]' : '[ ]';
    return '[$id] $status $title';
  }
}

toJson()은 객체를 Map으로, fromJson()은 Map을 객체로 변환합니다. factory 생성자를 사용한 것이 포인트입니다. PART 05에서 배운 팩토리 생성자가 이런 상황에 딱 맞습니다.

toString()은 할 일을 화면에 출력할 때 사용하는 문자열 표현입니다. 완료된 항목은 [v], 미완료는 [ ]로 표시합니다.

Command sealed class 설계

사용자가 입력하는 명령어를 타입으로 표현합니다. 문자열로 처리해도 되지만, 그러면 잘못된 명령어가 런타임까지 내려갈 수 있습니다. sealed class를 사용하면 명령어 종류를 컴파일 시점에 고정할 수 있습니다.

PART 09에서 배운 sealed class와 패턴 매칭이 여기에 쓰입니다.

// 새 파일: lib/command.dart

sealed class Command {}

class AddCommand extends Command {
  final String title;
  AddCommand(this.title);
}

class ListCommand extends Command {}

class DoneCommand extends Command {
  final int id;
  DoneCommand(this.id);
}

class DeleteCommand extends Command {
  final int id;
  DeleteCommand(this.id);
}

class ExitCommand extends Command {}

Command는 sealed class이므로 이 다섯 가지 하위 타입 외에는 존재하지 않습니다. 나중에 switch 표현식으로 명령어를 처리할 때 모든 경우를 빠짐없이 처리하도록 컴파일러가 강제합니다.

명령어 파싱 함수 설계

사용자가 입력한 문자열을 Command 객체로 변환하는 함수입니다. command.dart에 함께 넣겠습니다.

// 수정: lib/command.dart

sealed class Command {}

class AddCommand extends Command {
  final String title;
  AddCommand(this.title);
}

class ListCommand extends Command {}

class DoneCommand extends Command {
  final int id;
  DoneCommand(this.id);
}

class DeleteCommand extends Command {
  final int id;
  DeleteCommand(this.id);
}

class ExitCommand extends Command {}

Command? parseCommand(String input) {
  final parts = input.trim().split(RegExp(r'\s+'));
  if (parts.isEmpty) return null;

  final cmd = parts[0].toLowerCase();

  return switch (cmd) {
    'add' when parts.length >= 2 =>
      AddCommand(parts.sublist(1).join(' ')),
    'list' => ListCommand(),
    'done' when parts.length == 2 =>
      DoneCommand(int.tryParse(parts[1]) ?? -1),
    'delete' when parts.length == 2 =>
      DeleteCommand(int.tryParse(parts[1]) ?? -1),
    'exit' => ExitCommand(),
    _ => null,
  };
}

switch 표현식에 when 가드 절을 결합했습니다. add 명령은 뒤에 내용이 있어야 하고, donedelete는 숫자 ID가 있어야 합니다. 조건을 만족하지 않거나 알 수 없는 명령어는 null을 반환합니다.

설계 정리

지금까지 이 챕터에서 한 작업을 정리합니다.

첫째, 프로젝트 폴더 구조를 정했습니다. bin/에 진입점, lib/에 비즈니스 로직을 분리했습니다.

둘째, Todo 클래스를 설계했습니다. 네 개의 필드와 JSON 변환 메서드를 포함했습니다.

셋째, Command sealed class를 설계했습니다. 명령어 종류를 타입으로 표현하고, 파싱 함수로 문자열을 객체로 변환했습니다.

다음 챕터에서는 TodoRepository를 구현해서 할 일을 실제로 추가하고 삭제하고 조회하는 기능을 만들겠습니다.