크로스플랫폼 To-Do 앱 만들기 Part 3: 상태 관리와 로컬 데이터 저장
이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
들어가며#
Part 2에서는 Flutter 위젯을 활용해 To-Do 앱의 UI를 구성했습니다. 할 일을 추가하고, 완료 처리하고, 삭제하는 기본 기능까지 구현했는데요. 한 가지 치명적인 문제가 있었습니다. 앱을 종료했다가 다시 실행하면 모든 데이터가 사라진다 는 점입니다.
setState로 관리하던 List<Todo> 는 메모리에만 존재하기 때문입니다. 이번 Part 3에서는 Riverpod 을 사용한 상태 관리 리팩토링과 sqflite 를 이용한 로컬 데이터 영구 저장을 구현합니다.
1. setState의 한계#
Part 2에서 사용한 setState는 간단한 프로토타입에는 충분하지만, 앱 규모가 커지면 여러 문제가 생깁니다.
- 상태가 위젯에 묶여 있습니다. 다른 화면에서 같은 데이터에 접근하려면 상태를 상위 위젯으로 끌어올려야 합니다.
- 리빌드 범위를 제어하기 어렵습니다.
setState호출 시 해당 위젯 전체가 다시 빌드됩니다. - 테스트가 까다롭습니다. 비즈니스 로직이 위젯 안에 섞여 있으면 단위 테스트를 작성하기 어렵습니다.
2. 상태 관리 솔루션 비교와 Riverpod 선택#
| 솔루션 | 특징 | 러닝커브 |
|---|---|---|
| Provider | Flutter 팀 권장, 간단한 DI 컨테이너 | 낮음 |
| Riverpod | Provider의 개선판, 타입 안전, 컴파일 타임 검증 | 중간 |
| Bloc | 이벤트 기반, 대규모 앱에 적합 | 높음 |
이번 시리즈에서는 Riverpod 을 선택했습니다. Provider보다 타입 안전성이 뛰어나고, BuildContext 없이도 상태에 접근할 수 있으며, 테스트 작성이 용이하기 때문입니다.
3. 패키지 추가#
pubspec.yaml에 필요한 패키지를 모두 추가합니다. sqflite는 나중에 사용하지만 미리 함께 설치합니다.
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
sqflite: ^2.4.1
path: ^1.9.0
flutter pub get
참고: sqflite는 Android와 iOS에서 동작하는 SQLite 래퍼입니다. 웹 지원이 필요하다면
drift같은 대안을 고려하세요.
4. Todo 모델 정의#
lib/models/todo.dart에 모델 클래스를 정의합니다. DB 저장을 위한 toMap/fromMap 메서드도 함께 추가합니다.
class Todo {
final int? id;
final String title;
final bool isCompleted;
Todo({this.id, required this.title, this.isCompleted = false});
Todo copyWith({int? id, String? title, bool? isCompleted}) {
return Todo(
id: id ?? this.id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'isCompleted': isCompleted ? 1 : 0,
};
}
factory Todo.fromMap(Map<String, dynamic> map) {
return Todo(
id: map['id'] as int,
title: map['title'] as String,
isCompleted: (map['isCompleted'] as int) == 1,
);
}
}
5. DatabaseHelper 작성#
lib/helpers/database_helper.dart를 생성합니다. 싱글턴 패턴으로 앱 전체에서 하나의 DB 인스턴스를 유지합니다.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/todo.dart';
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
DatabaseHelper._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('todos.db');
return _database!;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, fileName);
return await openDatabase(path, version: 1, onCreate: _createDB);
}
Future<void> _createDB(Database db, int version) async {
await db.execute('''
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
isCompleted INTEGER NOT NULL DEFAULT 0
)
''');
}
Future<int> insertTodo(Todo todo) async {
final db = await database;
return await db.insert('todos', todo.toMap());
}
Future<List<Todo>> getAllTodos() async {
final db = await database;
final maps = await db.query('todos', orderBy: 'id ASC');
return maps.map((map) => Todo.fromMap(map)).toList();
}
Future<int> updateTodo(Todo todo) async {
final db = await database;
return await db.update(
'todos', todo.toMap(),
where: 'id = ?', whereArgs: [todo.id],
);
}
Future<int> deleteTodo(int id) async {
final db = await database;
return await db.delete('todos', where: 'id = ?', whereArgs: [id]);
}
}
6. Riverpod으로 상태 관리 구현#
6.1 TodoNotifier + Provider#
lib/providers/todo_provider.dart를 생성합니다. StateNotifier로 상태를 관리하면서, DatabaseHelper를 통해 DB와 연동합니다.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/todo.dart';
import '../helpers/database_helper.dart';
class TodoNotifier extends StateNotifier<List<Todo>> {
final DatabaseHelper _dbHelper;
TodoNotifier(this._dbHelper) : super([]);
Future<void> loadTodos() async {
state = await _dbHelper.getAllTodos();
}
Future<void> add(String title) async {
final todo = Todo(title: title);
final id = await _dbHelper.insertTodo(todo);
state = [...state, todo.copyWith(id: id)];
}
Future<void> toggle(int index) async {
final todo = state[index];
final updated = todo.copyWith(isCompleted: !todo.isCompleted);
await _dbHelper.updateTodo(updated);
state = [
for (int i = 0; i < state.length; i++)
if (i == index) updated else state[i],
];
}
Future<void> delete(int index) async {
final todo = state[index];
if (todo.id != null) {
await _dbHelper.deleteTodo(todo.id!);
}
state = [...state]..removeAt(index);
}
}
final todoProvider =
StateNotifierProvider<TodoNotifier, List<Todo>>((ref) {
final notifier = TodoNotifier(DatabaseHelper.instance);
notifier.loadTodos();
return notifier;
});
state에 새 리스트를 할당하면 Riverpod이 자동으로 UI에 변경을 알립니다. 기존 리스트를 직접 수정하지 않고 항상 새 리스트를 생성 하는 것이 불변 상태 관리의 핵심입니다. Provider 생성 시 loadTodos()를 호출하여 앱 시작과 동시에 DB에서 기존 데이터를 불러옵니다.
6.2 ProviderScope로 앱 감싸기#
lib/main.dart를 수정합니다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'screens/todo_screen.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter To-Do',
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
),
home: const TodoScreen(),
);
}
}
ProviderScope는 Riverpod의 모든 Provider가 동작하기 위한 필수 래퍼입니다.
6.3 ConsumerWidget으로 UI 변경#
lib/screens/todo_screen.dart를 ConsumerWidget으로 변경합니다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_provider.dart';
class TodoScreen extends ConsumerWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todoProvider);
final notifier = ref.read(todoProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('To-Do List')),
body: todos.isEmpty
? const Center(child: Text('할 일을 추가해 보세요!'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => notifier.toggle(index),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => notifier.delete(index),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context, notifier),
child: const Icon(Icons.add),
),
);
}
void _showAddDialog(BuildContext context, TodoNotifier notifier) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('새 할 일'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: '할 일을 입력하세요'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
if (controller.text.isNotEmpty) {
await notifier.add(controller.text);
if (context.mounted) Navigator.pop(context);
}
},
child: const Text('추가'),
),
],
),
);
}
}
ref.watch(todoProvider) 로 상태를 구독하고, ref.read(todoProvider.notifier) 로 상태를 변경합니다. 비즈니스 로직이 TodoNotifier에 분리되어 있어 테스트도 쉬워집니다. onPressed의 async 콜백에서 context.mounted 체크는 비동기 작업 후 BuildContext를 안전하게 사용하기 위한 Flutter의 권장 패턴입니다.
7. 실행 확인#
앱을 실행하여 데이터 영구 저장이 잘 되는지 확인해 봅시다.
flutter run
할 일을 2~3개 추가하고, 일부를 완료 처리한 뒤, 앱을 완전히 종료(핫 리로드가 아닌 프로세스 종료)했다가 다시 실행해 보세요. 이전에 추가한 할 일과 완료 상태가 그대로 유지된다면 성공입니다!
마치며#
이번 Part 3에서는 setState에서 Riverpod 으로 상태 관리를 전환하여 비즈니스 로직과 UI를 분리했고, sqflite 를 도입하여 앱을 종료해도 데이터가 유지되도록 했습니다.
다음 Part 4에서는 앱의 완성도를 높이기 위해 테마 커스터마이징과 최종 빌드 및 배포 과정을 다루겠습니다.