Ch 03. 상태 관리 기초
Flutter에서 가장 자주 논의되는 주제 중 하나가 상태 관리입니다. 상태(State)란 UI를 결정하는 데이터입니다. 할 일 목록이 비어 있는지, 로딩 중인지, 로그인 상태인지 — 이 모든 것이 상태입니다.
상태가 바뀌면 UI도 바뀌어야 합니다. Flutter는 이를 setState() 또는 외부 상태 관리 라이브러리로 처리합니다.
StatefulWidget과 setState
StatefulWidget은 상태를 가질 수 있는 위젯입니다. 상태가 바뀔 때 setState()를 호출하면 Flutter가 해당 위젯을 다시 빌드합니다.
간단한 카운터 예제로 동작 원리를 봅니다.
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _count = 0;
void _increment() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('$_count', style: const TextStyle(fontSize: 48)),
ElevatedButton(
onPressed: _increment,
child: const Text('+1'),
),
],
);
}
}
setState() 안에서 상태를 변경하면 Flutter 프레임워크에게 "이 위젯을 다시 그려야 해"라고 알립니다. 다음 프레임에서 build() 메서드가 다시 실행됩니다.
그런데 setState()에는 한계가 있습니다. 상태를 필요로 하는 위젯들이 서로 다른 위치에 있을 때 공유가 어렵습니다. 부모에서 자식으로 데이터를 계속 내려보내야 하는 "prop drilling" 문제가 생깁니다.
ChangeNotifier — 관찰 가능한 상태
ChangeNotifier는 상태가 바뀌면 구독자들에게 알리는 클래스입니다. Provider 패키지와 함께 사용합니다.
// 새 파일: lib/providers/todo_provider.dart
import 'package:flutter/foundation.dart';
import '../models/todo.dart';
enum LoadingState { idle, loading, success, error }
class TodoProvider extends ChangeNotifier {
List<Todo> _todos = [];
LoadingState _state = LoadingState.idle;
String? _errorMessage;
String? _token;
List<Todo> get todos => List.unmodifiable(_todos);
LoadingState get state => _state;
String? get errorMessage => _errorMessage;
bool get isLoggedIn => _token != null;
String? get token => _token;
void setToken(String token) {
_token = token;
notifyListeners();
}
void logout() {
_token = null;
_todos = [];
notifyListeners();
}
void setTodos(List<Todo> todos) {
_todos = todos;
_state = LoadingState.success;
notifyListeners();
}
void setLoading() {
_state = LoadingState.loading;
_errorMessage = null;
notifyListeners();
}
void setError(String message) {
_state = LoadingState.error;
_errorMessage = message;
notifyListeners();
}
void addTodo(Todo todo) {
_todos = [..._todos, todo];
notifyListeners();
}
void updateTodo(Todo updated) {
_todos = _todos.map((t) => t.id == updated.id ? updated : t).toList();
notifyListeners();
}
void removeTodo(int id) {
_todos = _todos.where((t) => t.id != id).toList();
notifyListeners();
}
}
notifyListeners()를 호출하면 이 ChangeNotifier를 구독하는 모든 위젯이 다시 빌드됩니다. 상태 변경 메서드마다 notifyListeners()를 마지막에 호출합니다.
Provider 설정
Provider는 ChangeNotifier를 위젯 트리에 공급하고, 하위 위젯에서 접근할 수 있게 합니다.
// 수정: lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/todo_provider.dart';
import 'screens/login_screen.dart';
import 'screens/todo_screen.dart';
void main() {
runApp(const TodoApp());
}
class TodoApp extends StatelessWidget {
const TodoApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => TodoProvider(),
child: MaterialApp(
title: 'Todo App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
),
useMaterial3: true,
),
home: Consumer<TodoProvider>(
builder: (context, provider, _) {
if (provider.isLoggedIn) {
return const TodoScreen();
}
return const LoginScreen();
},
),
),
);
}
}
ChangeNotifierProvider는 TodoProvider 인스턴스를 위젯 트리 전체에 공급합니다. Consumer<TodoProvider>는 TodoProvider의 변경을 구독하며, isLoggedIn 상태에 따라 로그인 화면과 할 일 화면을 전환합니다.
위젯에서 Provider 접근
하위 위젯에서 Provider에 접근하는 방법은 세 가지입니다.
// 방법 1: Consumer (변경 시 해당 위젯만 리빌드)
Consumer<TodoProvider>(
builder: (context, provider, child) {
return Text('할 일: ${provider.todos.length}개');
},
)
// 방법 2: context.watch (변경 시 빌드 메서드 전체 리빌드)
final provider = context.watch<TodoProvider>();
// 방법 3: context.read (상태 읽기 없이 메서드만 호출할 때)
context.read<TodoProvider>().logout();
context.read()는 버튼의 onPressed처럼 빌드 메서드 밖에서 사용합니다. build() 안에서 read()를 사용하면 상태 변경을 감지하지 못합니다. build() 안에서는 watch() 또는 Consumer를 사용합니다.
로딩/에러 상태 처리
네트워크 요청 중에는 로딩 인디케이터를, 오류가 발생하면 오류 메시지를 보여주는 것이 좋은 UX입니다.
// lib/screens/todo_screen.dart에서 사용 예시
Widget _buildBody(BuildContext context, TodoProvider provider) {
switch (provider.state) {
case LoadingState.loading:
return const Center(child: CircularProgressIndicator());
case LoadingState.error:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(provider.errorMessage ?? '오류가 발생했습니다.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 재시도 로직
},
child: const Text('다시 시도'),
),
],
),
);
case LoadingState.success:
case LoadingState.idle:
return TodoListView(
todos: provider.todos,
onToggle: (todo) => context.read<TodoProvider>().updateTodo(
todo.copyWith(completed: !todo.completed),
),
onDelete: (todo) => context.read<TodoProvider>().removeTodo(todo.id),
);
}
}
enum을 사용하여 로딩 상태를 표현하면 switch 문에서 모든 경우를 처리하도록 컴파일러가 강제합니다. bool isLoading 하나보다 훨씬 명확합니다.
이번 챕터 정리
StatefulWidget과setState()로 로컬 상태를 관리했습니다.ChangeNotifier로 관찰 가능한 상태 객체TodoProvider를 구현했습니다.Provider패키지로 위젯 트리에 상태를 공급하고,Consumer와context.watch/read로 접근했습니다.LoadingStateenum으로 로딩/성공/오류 상태를 명확하게 모델링했습니다.
다음 챕터에서는 실제로 PART 06의 todo_server와 HTTP 통신을 연결합니다.