본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 30. · 20 Views
실전 프로젝트 Todo 앱 만들기 완벽 가이드
Flutter와 Riverpod을 활용하여 실무에서 바로 사용할 수 있는 Todo 앱을 처음부터 끝까지 만들어봅니다. 프로젝트 구조 설계부터 로컬 저장소 연동까지 실전 노하우를 담았습니다.
목차
1. 프로젝트 구조 설계
김개발 씨는 드디어 첫 번째 개인 프로젝트를 시작하기로 결심했습니다. "Todo 앱이면 간단하겠지"라고 생각했지만, 막상 프로젝트를 생성하고 나니 막막해졌습니다.
파일은 어디에 두어야 할까요? 폴더는 어떻게 나눠야 할까요?
프로젝트 구조 설계는 건물의 설계도면과 같습니다. 아무리 작은 집이라도 설계도 없이 짓다 보면 나중에 큰 문제가 생깁니다.
Flutter 프로젝트도 마찬가지입니다. 처음부터 체계적인 구조를 잡아두면 나중에 기능을 추가하거나 수정할 때 훨씬 수월해집니다.
다음 코드를 살펴봅시다.
lib/
├── main.dart // 앱 진입점
├── app.dart // MaterialApp 설정
├── core/ // 공통 유틸리티
│ ├── constants/ // 상수 정의
│ └── utils/ // 유틸 함수
├── features/ // 기능별 모듈
│ └── todo/
│ ├── data/ // 데이터 레이어
│ │ ├── models/ // 데이터 모델
│ │ └── repositories/ // 저장소 구현
│ ├── domain/ // 비즈니스 로직
│ │ └── entities/ // 도메인 엔티티
│ └── presentation/ // UI 레이어
│ ├── providers/ // Riverpod 프로바이더
│ ├── screens/ // 화면
│ └── widgets/ // 위젯
김개발 씨는 입사 3개월 차 주니어 개발자입니다. 회사에서 맡은 첫 프로젝트가 바로 사내 Todo 앱 개발이었습니다.
의욕 넘치게 시작했지만, 막상 코드를 어디에 두어야 할지 몰라 한참을 고민했습니다. 선배 개발자 박시니어 씨가 다가와 물었습니다.
"폴더 구조는 어떻게 잡을 생각이에요?" 김개발 씨는 머뭇거리며 대답했습니다. "그냥 lib 폴더에 다 넣으려고요..." 박시니어 씨는 고개를 저었습니다.
"처음엔 그래도 되지만, 나중에 기능이 늘어나면 정말 힘들어져요. 처음부터 구조를 잘 잡아두는 게 좋습니다." 그렇다면 좋은 프로젝트 구조란 무엇일까요?
쉽게 비유하자면, 프로젝트 구조는 마치 잘 정리된 서랍장과 같습니다. 옷을 아무렇게나 던져두면 나중에 원하는 옷을 찾기 어렵습니다.
하지만 상의, 하의, 속옷으로 서랍을 나눠두면 필요한 것을 금방 찾을 수 있습니다. Clean Architecture는 이런 정리 원칙을 코드에 적용한 것입니다.
크게 세 개의 레이어로 나눕니다. data 레이어는 데이터를 다루는 곳입니다.
서버에서 데이터를 가져오거나, 로컬 저장소에 저장하는 코드가 여기에 들어갑니다. domain 레이어는 비즈니스 로직이 사는 곳입니다.
"할 일을 완료 처리한다"와 같은 핵심 규칙이 여기에 정의됩니다. 이 레이어는 Flutter나 특정 라이브러리에 의존하지 않습니다.
presentation 레이어는 사용자가 보는 화면입니다. 버튼, 리스트, 입력창 같은 UI 코드가 여기에 들어갑니다.
Riverpod의 Provider도 이 레이어에 속합니다. 이렇게 레이어를 나누면 어떤 장점이 있을까요?
가장 큰 장점은 변경의 영향 범위가 제한된다는 것입니다. 예를 들어 서버 API가 바뀌어도 data 레이어만 수정하면 됩니다.
UI를 완전히 바꿔도 domain 레이어는 그대로입니다. features 폴더를 사용하는 이유도 중요합니다.
기능별로 폴더를 나누면 관련 코드가 한곳에 모입니다. Todo 기능을 수정할 때 features/todo 폴더만 보면 됩니다.
다른 기능에 영향을 주지 않으면서 독립적으로 개발할 수 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 세분화하는 것입니다.
파일이 10개도 안 되는 작은 프로젝트에 복잡한 구조를 적용하면 오히려 개발 속도가 느려집니다. 프로젝트 규모에 맞게 적절히 조절해야 합니다.
박시니어 씨의 조언을 들은 김개발 씨는 고개를 끄덕였습니다. "구조를 잘 잡아두면 나중에 편하겠네요!" 이제 본격적으로 Todo 앱 개발을 시작할 준비가 되었습니다.
실전 팁
💡 - 작은 프로젝트라도 최소한 data, domain, presentation 분리는 해두세요
- 파일이 많아지면 기능별로 features 폴더를 나누세요
- 처음부터 완벽한 구조를 고민하지 말고, 리팩토링하면서 개선하세요
2. Todo 모델과 Repository
프로젝트 구조를 잡은 김개발 씨는 이제 실제 코드를 작성하기 시작했습니다. 가장 먼저 해야 할 일은 Todo가 무엇인지 정의하는 것입니다.
"할 일"이라는 개념을 코드로 어떻게 표현해야 할까요?
Model은 데이터의 구조를 정의합니다. Todo 모델은 할 일의 제목, 완료 여부, 생성 시간 등을 담습니다.
Repository는 이 데이터를 저장하고 불러오는 창구 역할을 합니다. 마치 도서관의 사서처럼, 데이터를 찾아주고 정리해주는 것이 Repository의 임무입니다.
다음 코드를 살펴봅시다.
// lib/features/todo/data/models/todo_model.dart
class TodoModel {
final String id;
final String title;
final String? description;
final bool isCompleted;
final DateTime createdAt;
final DateTime? completedAt;
const TodoModel({
required this.id,
required this.title,
this.description,
this.isCompleted = false,
required this.createdAt,
this.completedAt,
});
// 불변 객체를 위한 copyWith 메서드
TodoModel copyWith({
String? title,
String? description,
bool? isCompleted,
DateTime? completedAt,
}) {
return TodoModel(
id: id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
createdAt: createdAt,
completedAt: completedAt ?? this.completedAt,
);
}
}
김개발 씨는 첫 번째 파일을 만들기 시작했습니다. Todo 앱에서 가장 중요한 것은 바로 "할 일" 그 자체입니다.
이것을 코드로 표현하려면 어떻게 해야 할까요? 박시니어 씨가 힌트를 줍니다.
"사람을 표현한다고 생각해보세요. 이름, 나이, 키, 몸무게...
이런 속성들이 있잖아요. Todo도 마찬가지예요." Model은 마치 신분증과 같습니다.
신분증에는 이름, 생년월일, 주소 등 그 사람을 특정할 수 있는 정보가 담겨 있습니다. TodoModel에는 할 일을 특정할 수 있는 정보들이 담깁니다.
id로 각 할 일을 구분하고, title로 무엇을 해야 하는지 알 수 있습니다. 여기서 중요한 개념이 **불변성(Immutability)**입니다.
김개발 씨가 처음에 작성한 코드는 이랬습니다. 잘못된 예시를 떠올려봅시다.
todo.isCompleted = true라고 직접 값을 바꾸면 어떻게 될까요? 여러 곳에서 같은 객체를 참조하고 있다면, 예상치 못한 곳에서 값이 바뀌어 버그가 발생할 수 있습니다.
그래서 copyWith 메서드를 사용합니다. 원본은 그대로 두고, 바꾸고 싶은 값만 달리한 새로운 객체를 만드는 것입니다.
마치 서류를 수정할 때 원본에 펜으로 긋지 않고, 복사본을 만들어 수정하는 것과 같습니다. 이제 Repository에 대해 알아봅시다.
Repository는 도서관의 사서와 같습니다. 여러분이 책을 빌리러 가면 사서가 책을 찾아줍니다.
책이 서고에 있든, 다른 도서관에 있든 사서가 알아서 처리합니다. 여러분은 그저 "이 책 주세요"라고 말하면 됩니다.
Repository도 마찬가지입니다. UI 코드에서는 "Todo 목록 주세요"라고 요청만 하면 됩니다.
데이터가 서버에 있든, 로컬 저장소에 있든, Repository가 알아서 가져다 줍니다. 이런 패턴의 장점은 관심사의 분리입니다.
UI는 화면 그리는 일에만 집중하고, 데이터 처리는 Repository에게 맡깁니다. 나중에 서버 연동을 추가하더라도 UI 코드는 바꿀 필요가 없습니다.
Repository 구현만 바꾸면 됩니다. 김개발 씨는 이제 Model과 Repository의 역할을 이해했습니다.
데이터의 모양을 정의하고, 그 데이터를 다루는 창구를 만드는 것. 이것이 견고한 앱의 기초가 됩니다.
실전 팁
💡 - Model 클래스는 항상 불변으로 만들고 copyWith 메서드를 제공하세요
- Repository는 인터페이스(추상 클래스)를 먼저 정의하고 구현하세요
- id는 UUID를 사용하면 충돌 없이 고유한 값을 만들 수 있습니다
3. TodoNotifier 구현
모델과 Repository를 만든 김개발 씨는 이제 상태 관리로 넘어갑니다. "상태가 바뀌면 화면도 바뀌어야 하는데, 어떻게 연결하지?" Riverpod의 Notifier가 바로 이 문제를 해결해줍니다.
Notifier는 상태를 관리하고 변경을 알리는 역할을 합니다. 마치 학교의 방송실과 같습니다.
방송실에서 공지사항을 말하면 모든 교실 스피커에서 소리가 납니다. Notifier가 상태를 바꾸면 이를 구독하는 모든 위젯이 자동으로 업데이트됩니다.
다음 코드를 살펴봅시다.
// lib/features/todo/presentation/providers/todo_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'todo_provider.g.dart';
@riverpod
class TodoNotifier extends _$TodoNotifier {
@override
List<TodoModel> build() {
// 초기 상태: 빈 리스트
return [];
}
// 할 일 추가
void addTodo(String title, {String? description}) {
final newTodo = TodoModel(
id: const Uuid().v4(),
title: title,
description: description,
createdAt: DateTime.now(),
);
state = [...state, newTodo];
}
// 완료 상태 토글
void toggleComplete(String id) {
state = state.map((todo) {
if (todo.id == id) {
return todo.copyWith(
isCompleted: !todo.isCompleted,
completedAt: !todo.isCompleted ? DateTime.now() : null,
);
}
return todo;
}).toList();
}
// 할 일 삭제
void deleteTodo(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
김개발 씨는 Riverpod 문서를 읽으며 고개를 갸웃거렸습니다. "Notifier가 뭐지?
Provider랑은 뭐가 다른 거야?" 박시니어 씨가 옆에서 설명을 시작합니다. "간단히 말하면, Provider는 읽기 전용이고 Notifier는 읽기와 쓰기가 모두 가능해요.
Todo 앱처럼 데이터를 추가하고 수정해야 한다면 Notifier를 써야 해요." Notifier는 학교의 방송실이라고 생각하면 됩니다. 방송실에서 마이크로 이야기하면 모든 교실의 스피커에서 소리가 납니다.
마찬가지로 Notifier에서 상태를 바꾸면, 이 상태를 watch하고 있는 모든 위젯에서 변화를 감지합니다. 코드를 살펴봅시다.
build 메서드는 초기 상태를 정의합니다. Todo 앱의 경우 처음에는 할 일이 없으므로 빈 리스트를 반환합니다.
이 메서드는 Provider가 처음 접근될 때 한 번 호출됩니다. addTodo 메서드를 보겠습니다.
새로운 TodoModel을 만들고, 기존 리스트에 추가합니다. 여기서 중요한 것은 state = [...state, newTodo]입니다.
기존 리스트에 직접 add하지 않고, 새로운 리스트를 만들어 할당합니다. 왜 이렇게 해야 할까요?
Riverpod은 참조 비교로 상태 변경을 감지합니다. 기존 리스트에 add만 하면 리스트의 참조가 바뀌지 않아서 Riverpod이 변경을 감지하지 못합니다.
새로운 리스트를 만들어야 "아, 상태가 바뀌었구나!"라고 인식합니다. toggleComplete 메서드는 map을 사용합니다.
전체 리스트를 순회하면서 id가 일치하는 항목만 수정합니다. copyWith로 새로운 객체를 만들고, 나머지는 그대로 반환합니다.
이것도 불변성을 지키기 위한 패턴입니다. deleteTodo 메서드는 where를 사용해 해당 id를 가진 항목을 제외한 새 리스트를 만듭니다.
filter와 비슷한 역할입니다. 김개발 씨가 질문합니다.
"@riverpod 어노테이션은 뭐예요?" 박시니어 씨가 답합니다. "코드 생성을 위한 표시예요.
build_runner를 실행하면 _$TodoNotifier 같은 코드가 자동으로 생성돼요. 보일러플레이트를 줄여주는 거죠." 이렇게 Notifier를 구현하면 상태 관리의 핵심 로직이 완성됩니다.
UI에서는 이 Notifier를 watch하거나 read해서 상태를 읽고 메서드를 호출하면 됩니다.
실전 팁
💡 - state를 변경할 때는 항상 새로운 객체나 리스트를 할당하세요
- 비즈니스 로직은 Notifier에, UI 로직은 위젯에 두세요
- @riverpod 어노테이션을 사용하면 보일러플레이트 코드가 줄어듭니다
4. UI 구현
이제 본격적으로 화면을 만들 차례입니다. 김개발 씨는 설레는 마음으로 위젯 코드를 작성하기 시작합니다.
리스트로 할 일을 보여주고, 추가하고, 수정하고, 삭제하는 화면. Todo 앱의 핵심 UI입니다.
Flutter의 UI는 위젯의 조합으로 만들어집니다. Todo 앱의 UI는 크게 리스트 화면, 추가 화면, 상세/수정 화면으로 구성됩니다.
Riverpod의 ConsumerWidget을 사용하면 상태 변화에 반응하는 UI를 쉽게 만들 수 있습니다.
다음 코드를 살펴봅시다.
// lib/features/todo/presentation/screens/todo_list_screen.dart
class TodoListScreen extends ConsumerWidget {
const TodoListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todoNotifierProvider);
return Scaffold(
appBar: AppBar(title: const Text('할 일 목록')),
body: todos.isEmpty
? const Center(child: Text('할 일이 없습니다'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return TodoListTile(todo: todo);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context, ref),
child: const Icon(Icons.add),
),
);
}
void _showAddDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (context) => const AddTodoDialog(),
);
}
}
김개발 씨는 드디어 UI를 만들기 시작했습니다. 그런데 막상 시작하려니 어디서부터 손을 대야 할지 막막합니다.
박시니어 씨가 조언합니다. "일단 가장 기본적인 화면부터 만들어봐요.
할 일 목록을 보여주는 화면이요." ConsumerWidget은 Riverpod에서 제공하는 특별한 위젯입니다. 일반 StatelessWidget과 비슷하지만, ref라는 매개변수가 추가됩니다.
이 ref를 통해 Provider에 접근할 수 있습니다. ref.watch와 ref.read의 차이를 이해하는 것이 중요합니다.
watch는 "구독"입니다. 상태가 바뀌면 위젯이 다시 빌드됩니다.
read는 "한 번 읽기"입니다. 상태가 바뀌어도 위젯이 다시 빌드되지 않습니다.
build 메서드 안에서는 watch를 사용합니다. 화면에 표시되는 데이터는 항상 최신 상태여야 하니까요.
반면 버튼 클릭 같은 이벤트 핸들러에서는 read를 사용합니다. 메서드를 호출하기만 하면 되고, 구독이 필요하지 않습니다.
코드를 살펴보면, todos.isEmpty일 때와 아닐 때를 분기 처리합니다. 할 일이 없을 때 빈 화면을 보여주는 것보다 "할 일이 없습니다"라는 메시지를 보여주는 게 사용자 경험에 좋습니다.
ListView.builder는 리스트 항목을 효율적으로 렌더링합니다. 화면에 보이는 항목만 빌드하기 때문에 수백 개의 할 일이 있어도 성능 문제가 없습니다.
일반 ListView로 모든 항목을 한 번에 빌드하면 메모리 문제가 생길 수 있습니다. TodoListTile은 각 할 일 항목을 표시하는 별도의 위젯입니다.
이렇게 위젯을 분리하면 코드가 깔끔해지고, 재사용도 가능해집니다. FloatingActionButton을 눌렀을 때 다이얼로그를 띄워 할 일을 추가합니다.
다이얼로그 안에서 제목을 입력하고 확인을 누르면 addTodo 메서드가 호출됩니다. 김개발 씨가 실행 버튼을 누릅니다.
화면에 빈 리스트가 표시되고, 플로팅 버튼을 누르면 다이얼로그가 뜹니다. 할 일을 입력하니 리스트에 바로 나타납니다.
"오, 자동으로 업데이트되네요!" 이것이 바로 반응형 UI의 힘입니다. 상태가 바뀌면 UI가 자동으로 따라갑니다.
개발자가 일일이 setState를 호출하거나 화면을 갱신하는 코드를 작성할 필요가 없습니다.
실전 팁
💡 - build 안에서는 ref.watch, 이벤트 핸들러에서는 ref.read를 사용하세요
- 긴 리스트는 ListView.builder로 성능을 최적화하세요
- 위젯은 작은 단위로 분리해서 재사용성을 높이세요
5. 필터링과 검색 기능
할 일이 점점 쌓이기 시작했습니다. 완료된 것, 진행 중인 것, 중요한 것...
김개발 씨는 원하는 할 일만 볼 수 있으면 좋겠다고 생각합니다. 필터링과 검색 기능이 필요한 순간입니다.
필터링은 조건에 맞는 데이터만 걸러내는 것입니다. 마치 체로 모래를 거르듯이, 원하는 조건의 할 일만 화면에 표시합니다.
검색은 키워드로 데이터를 찾는 것입니다. Riverpod의 파생 상태를 활용하면 필터링된 결과를 효율적으로 관리할 수 있습니다.
다음 코드를 살펴봅시다.
// 필터 상태 정의
enum TodoFilter { all, active, completed }
@riverpod
class TodoFilterNotifier extends _$TodoFilterNotifier {
@override
TodoFilter build() => TodoFilter.all;
void setFilter(TodoFilter filter) => state = filter;
}
// 검색어 상태
@riverpod
class SearchQueryNotifier extends _$SearchQueryNotifier {
@override
String build() => '';
void setQuery(String query) => state = query;
}
// 필터링된 Todo 목록 (파생 상태)
@riverpod
List<TodoModel> filteredTodos(Ref ref) {
final todos = ref.watch(todoNotifierProvider);
final filter = ref.watch(todoFilterNotifierProvider);
final query = ref.watch(searchQueryNotifierProvider).toLowerCase();
return todos.where((todo) {
// 필터 조건
final matchesFilter = switch (filter) {
TodoFilter.all => true,
TodoFilter.active => !todo.isCompleted,
TodoFilter.completed => todo.isCompleted,
};
// 검색 조건
final matchesQuery = query.isEmpty ||
todo.title.toLowerCase().contains(query);
return matchesFilter && matchesQuery;
}).toList();
}
김개발 씨의 Todo 앱에 할 일이 50개가 넘어갔습니다. 스크롤을 내리다 지친 김개발 씨가 투덜거립니다.
"완료된 것만 보고 싶은데..." 박시니어 씨가 웃으며 말합니다. "그럼 필터 기능을 추가해야죠!" 필터링의 핵심 아이디어는 간단합니다.
전체 데이터에서 조건에 맞는 것만 추려내는 것입니다. 마치 커피 필터가 커피 찌꺼기는 걸러내고 커피만 통과시키는 것처럼요.
그런데 필터 상태는 어디에 저장해야 할까요? TodoNotifier 안에 넣을 수도 있지만, 그러면 관심사가 섞입니다.
할 일 데이터를 관리하는 것과 필터 상태를 관리하는 것은 별개의 책임입니다. 그래서 TodoFilterNotifier를 별도로 만듭니다.
이 Notifier는 현재 선택된 필터만 관리합니다. all, active, completed 세 가지 상태를 가지며, setFilter로 상태를 바꿀 수 있습니다.
검색어도 마찬가지입니다. SearchQueryNotifier가 현재 검색어를 관리합니다.
사용자가 검색창에 입력하면 setQuery가 호출되어 상태가 업데이트됩니다. 여기서 핵심은 **파생 상태(Derived State)**입니다.
filteredTodos Provider를 보세요. 이 Provider는 자체 상태를 가지지 않습니다.
대신 세 개의 다른 Provider를 watch하고, 그 조합으로 결과를 계산합니다. todos, filter, query 중 하나라도 바뀌면 filteredTodos가 자동으로 다시 계산됩니다.
개발자가 직접 동기화 로직을 작성할 필요가 없습니다. Riverpod이 의존성을 추적하고 알아서 업데이트해줍니다.
switch 표현식은 Dart 3에서 도입된 문법입니다. 기존의 switch 문보다 간결하고, 모든 케이스를 처리했는지 컴파일 타임에 검사해줍니다.
TodoFilter에 새로운 값이 추가되면 컴파일러가 처리하지 않은 케이스가 있다고 알려줍니다. 검색 조건에서 toLowerCase()를 사용하는 이유는 대소문자를 구분하지 않기 위함입니다.
"Flutter"로 검색해도 "flutter"가 포함된 할 일이 검색됩니다. 사용자 경험을 위한 작은 배려입니다.
이제 UI에서는 todoNotifierProvider 대신 filteredTodosProvider를 watch하면 됩니다. 코드 변경이 최소화되면서 필터링 기능이 추가됩니다.
이것이 관심사 분리의 힘입니다.
실전 팁
💡 - 상태는 책임에 따라 분리하세요. 하나의 Notifier가 너무 많은 일을 하면 안 됩니다
- 파생 상태를 적극 활용하면 상태 동기화 버그를 줄일 수 있습니다
- 검색은 디바운싱을 적용하면 성능이 좋아집니다
6. 로컬 저장소 연동
김개발 씨가 앱을 종료하고 다시 켜니 모든 할 일이 사라졌습니다. "아, 저장이 안 되는구나!" 데이터를 영구적으로 저장하려면 로컬 저장소와 연동해야 합니다.
SharedPreferences, Hive, Drift 등 여러 선택지가 있습니다.
로컬 저장소는 앱이 종료되어도 데이터를 유지해주는 기능입니다. 마치 노트에 메모를 적어두면 나중에 다시 볼 수 있는 것처럼요.
Flutter에서는 shared_preferences로 간단한 데이터를, Hive나 Drift로 복잡한 데이터를 저장할 수 있습니다.
다음 코드를 살펴봅시다.
// lib/features/todo/data/repositories/todo_repository.dart
class TodoRepository {
static const _key = 'todos';
final SharedPreferences _prefs;
TodoRepository(this._prefs);
// JSON 직렬화를 위한 toJson 메서드 추가 필요
Future<void> saveTodos(List<TodoModel> todos) async {
final jsonList = todos.map((t) => t.toJson()).toList();
await _prefs.setString(_key, jsonEncode(jsonList));
}
List<TodoModel> loadTodos() {
final jsonString = _prefs.getString(_key);
if (jsonString == null) return [];
final jsonList = jsonDecode(jsonString) as List;
return jsonList
.map((json) => TodoModel.fromJson(json as Map<String, dynamic>))
.toList();
}
}
// Provider에서 Repository 연동
@riverpod
class TodoNotifier extends _$TodoNotifier {
@override
List<TodoModel> build() {
final repository = ref.watch(todoRepositoryProvider);
// 초기 로드
return repository.loadTodos();
}
Future<void> addTodo(String title) async {
final repository = ref.read(todoRepositoryProvider);
final newTodo = TodoModel(/* ... */);
state = [...state, newTodo];
await repository.saveTodos(state); // 저장
}
}
앱을 열심히 만들어도 껐다 켜면 데이터가 사라진다면 쓸모가 없습니다. 김개발 씨도 이 문제에 직면했습니다.
"서버 없이도 데이터를 저장할 수 있나요?" 박시니어 씨가 답합니다. "물론이죠.
로컬 저장소를 사용하면 돼요. 기기 안에 데이터를 저장하는 거예요." SharedPreferences는 가장 간단한 로컬 저장소입니다.
키-값 쌍으로 데이터를 저장합니다. 작은 양의 문자열, 숫자, 불리언 값을 저장하기에 적합합니다.
하지만 Todo 목록처럼 복잡한 데이터는 어떻게 저장할까요? 답은 JSON 직렬화입니다.
객체를 JSON 문자열로 변환해서 저장하고, 불러올 때 다시 객체로 변환합니다. 이를 위해 TodoModel에 toJson과 fromJson 메서드를 추가해야 합니다.
json_serializable 패키지를 사용하면 이 코드를 자동으로 생성할 수 있습니다. @JsonSerializable() 어노테이션을 붙이고 build_runner를 실행하면 됩니다.
Repository에서 saveTodos 메서드는 Todo 리스트를 JSON 문자열로 변환해서 저장합니다. jsonEncode 함수가 이 변환을 담당합니다.
loadTodos 메서드는 그 반대입니다. 저장된 JSON 문자열을 읽어서 Todo 리스트로 변환합니다.
중요한 것은 저장 시점입니다. 언제 saveTodos를 호출해야 할까요?
가장 확실한 방법은 상태가 변경될 때마다 저장하는 것입니다. addTodo, toggleComplete, deleteTodo 등 상태를 바꾸는 모든 메서드에서 저장을 호출합니다.
더 발전된 방법도 있습니다. ref.listenSelf를 사용하면 상태가 바뀔 때마다 콜백이 호출됩니다.
이 콜백에서 저장 로직을 실행하면 각 메서드에서 일일이 저장을 호출하지 않아도 됩니다. 만약 데이터가 많거나 복잡한 쿼리가 필요하다면 Hive나 Drift를 고려하세요.
Hive는 NoSQL 방식으로 빠른 읽기/쓰기를 제공합니다. Drift는 SQLite를 타입 안전하게 사용할 수 있게 해줍니다.
김개발 씨가 앱을 다시 실행합니다. 이전에 추가한 할 일들이 그대로 남아 있습니다.
"오, 진짜 저장됐다!" 앱이 점점 실제 앱다워지고 있습니다.
실전 팁
💡 - 간단한 데이터는 SharedPreferences, 복잡한 데이터는 Hive나 Drift를 사용하세요
- json_serializable로 직렬화 코드를 자동 생성하면 실수를 줄일 수 있습니다
- 저장은 비동기로 처리하되, UI 블로킹이 없도록 하세요
7. 최종 리팩토링
기능이 모두 완성되었습니다. 하지만 김개발 씨는 자신의 코드가 걱정됩니다.
"작동은 하는데, 좀 지저분한 것 같아요." 박시니어 씨가 말합니다. "그럼 리팩토링을 해봅시다.
작동하는 코드를 더 좋은 코드로 만드는 거예요."
리팩토링은 외부 동작은 유지하면서 내부 구조를 개선하는 작업입니다. 마치 방을 청소하는 것과 같습니다.
물건은 그대로지만, 정리를 하면 찾기 쉽고 보기에도 좋아집니다. 코드도 마찬가지입니다.
잘 정리된 코드는 읽기 쉽고, 수정하기도 쉽습니다.
다음 코드를 살펴봅시다.
// 리팩토링 전: 비대한 위젯
class TodoListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 100줄이 넘는 거대한 build 메서드...
}
}
// 리팩토링 후: 분리된 위젯들
class TodoListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: const TodoAppBar(),
body: const TodoListBody(),
floatingActionButton: const AddTodoFab(),
);
}
}
// 독립적인 위젯으로 분리
class TodoListBody extends ConsumerWidget {
const TodoListBody({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(filteredTodosProvider);
if (todos.isEmpty) {
return const EmptyTodoView();
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (_, index) => TodoListTile(todo: todos[index]),
);
}
}
김개발 씨의 TodoListScreen은 어느새 300줄이 넘어갔습니다. build 메서드 안에 온갖 로직이 뒤섞여 있습니다.
버그를 고치려고 코드를 읽는데, 어디서 무엇을 하는지 파악하기 어렵습니다. 박시니어 씨가 말합니다.
"이런 걸 스파게티 코드라고 해요. 면발이 엉킨 것처럼 로직이 꼬여 있는 거죠.
리팩토링이 필요합니다." 리팩토링의 첫 번째 원칙은 작동하는 상태에서 시작하는 것입니다. 버그를 고치면서 리팩토링하면 안 됩니다.
먼저 버그를 고치고, 그다음 리팩토링합니다. 또는 먼저 리팩토링하고, 그다음 버그를 고칩니다.
두 가지를 동시에 하면 문제가 생겼을 때 원인을 찾기 어렵습니다. 위젯 분리부터 시작합니다.
하나의 거대한 위젯을 여러 개의 작은 위젯으로 나눕니다. TodoAppBar, TodoListBody, AddTodoFab처럼요.
각 위젯은 하나의 역할만 담당합니다. 이렇게 나누면 어떤 장점이 있을까요?
첫째, 가독성이 좋아집니다. 30줄짜리 위젯 10개가 300줄짜리 위젯 1개보다 읽기 쉽습니다.
둘째, 재사용이 가능해집니다. EmptyTodoView는 다른 화면에서도 쓸 수 있습니다.
셋째, 테스트가 쉬워집니다. 작은 위젯은 독립적으로 테스트할 수 있습니다.
const 키워드를 적극 활용하세요. const 위젯은 Flutter가 한 번만 생성하고 재사용합니다.
부모 위젯이 다시 빌드되어도 const 자식은 다시 빌드되지 않습니다. 성능 최적화에 도움이 됩니다.
네이밍도 리팩토링의 중요한 부분입니다. buildItem보다 buildTodoListTile이 낫고, data보다 todos가 낫습니다.
코드는 쓰는 시간보다 읽는 시간이 더 많습니다. 미래의 자신과 동료를 위해 명확한 이름을 지으세요.
마지막으로 중복 제거입니다. 같은 코드가 여러 곳에 있다면 함수나 위젯으로 추출하세요.
나중에 수정할 때 한 곳만 고치면 됩니다. 리팩토링을 마친 김개발 씨의 코드는 훨씬 깔끔해졌습니다.
파일 수는 늘어났지만, 각 파일이 무엇을 하는지 이름만 봐도 알 수 있습니다. 박시니어 씨가 코드 리뷰를 하며 말합니다.
"이제 진짜 실무 코드 같네요!" Todo 앱 프로젝트가 완성되었습니다. 단순해 보이는 앱이지만, 그 안에는 프로젝트 구조, 상태 관리, 데이터 영속성, 코드 품질이라는 실무의 핵심 요소가 모두 담겨 있습니다.
실전 팁
💡 - 한 번에 하나만 바꾸세요. 리팩토링 중 새 기능 추가는 금물입니다
- 테스트가 있다면 리팩토링 후 모든 테스트가 통과하는지 확인하세요
- const를 적극 활용하면 성능이 좋아집니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.
Riverpod 3.0 Retry 자동 재시도 완벽 가이드
Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.
Riverpod 3.0 requireValue로 Provider 결합하기
Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.
Flutter 3.0 Offline 데이터 영속화 완벽 가이드
Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.
Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드
Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.