iBetter Books
수정

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 설정

ProviderChangeNotifier를 위젯 트리에 공급하고, 하위 위젯에서 접근할 수 있게 합니다.

// 수정: 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();
          },
        ),
      ),
    );
  }
}

ChangeNotifierProviderTodoProvider 인스턴스를 위젯 트리 전체에 공급합니다. 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 하나보다 훨씬 명확합니다.

이번 챕터 정리

  • StatefulWidgetsetState()로 로컬 상태를 관리했습니다.
  • ChangeNotifier로 관찰 가능한 상태 객체 TodoProvider를 구현했습니다.
  • Provider 패키지로 위젯 트리에 상태를 공급하고, Consumercontext.watch/read로 접근했습니다.
  • LoadingState enum으로 로딩/성공/오류 상태를 명확하게 모델링했습니다.

다음 챕터에서는 실제로 PART 06의 todo_server와 HTTP 통신을 연결합니다.