프로젝트 설계와 데이터 모델링
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, createdAt은 final로 선언했습니다. 한 번 정해지면 바뀌지 않는 값들입니다. isDone만 bool로 두어 나중에 완료 처리할 수 있게 합니다.
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 명령은 뒤에 내용이 있어야 하고, done과 delete는 숫자 ID가 있어야 합니다. 조건을 만족하지 않거나 알 수 없는 명령어는 null을 반환합니다.
설계 정리
지금까지 이 챕터에서 한 작업을 정리합니다.
첫째, 프로젝트 폴더 구조를 정했습니다. bin/에 진입점, lib/에 비즈니스 로직을 분리했습니다.
둘째, Todo 클래스를 설계했습니다. 네 개의 필드와 JSON 변환 메서드를 포함했습니다.
셋째, Command sealed class를 설계했습니다. 명령어 종류를 타입으로 표현하고, 파싱 함수로 문자열을 객체로 변환했습니다.
다음 챕터에서는 TodoRepository를 구현해서 할 일을 실제로 추가하고 삭제하고 조회하는 기능을 만들겠습니다.