이 글은 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.dartConsumerWidget으로 변경합니다.

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에 분리되어 있어 테스트도 쉬워집니다. onPressedasync 콜백에서 context.mounted 체크는 비동기 작업 후 BuildContext를 안전하게 사용하기 위한 Flutter의 권장 패턴입니다.

7. 실행 확인#

앱을 실행하여 데이터 영구 저장이 잘 되는지 확인해 봅시다.

flutter run

할 일을 2~3개 추가하고, 일부를 완료 처리한 뒤, 앱을 완전히 종료(핫 리로드가 아닌 프로세스 종료)했다가 다시 실행해 보세요. 이전에 추가한 할 일과 완료 상태가 그대로 유지된다면 성공입니다!

마치며#

이번 Part 3에서는 setState에서 Riverpod 으로 상태 관리를 전환하여 비즈니스 로직과 UI를 분리했고, sqflite 를 도입하여 앱을 종료해도 데이터가 유지되도록 했습니다.

다음 Part 4에서는 앱의 완성도를 높이기 위해 테마 커스터마이징과 최종 빌드 및 배포 과정을 다루겠습니다.