Skip to main content

상태관리 입문

Views 7약 11분생성형 AI 활용

상태관리는 왜 어렵게 느껴질까

Flutter를 배우다 보면 어느 순간 이런 질문을 만나게 됩니다.

"이 값은 어디에 둬야 하지?"

처음에는 답이 쉬워 보입니다. 버튼을 누르면 숫자가 올라가는 카운터 앱에서는 StatefulWidget 안에 int count = 0을 두고 setState()를 호출하면 됩니다. 그런데 앱이 조금만 커져도 이야기가 달라집니다. 로그인한 사용자 정보는 어디에 둘까요? 여러 화면에서 쓰는 테마 설정은요? 서버에서 불러온 노트 목록은 어떤 위젯이 가지고 있어야 할까요? 노트 작성 화면에서 입력 중인 제목은 앱 전체 상태일까요, 아니면 그 화면만의 상태일까요?

상태관리에서 중요한 것은 "Provider를 쓸까, Riverpod을 쓸까, Bloc을 쓸까?"를 먼저 고르는 일이 아닙니다. 그보다 먼저 해야 할 질문은 다음입니다.

이 데이터는 어디에서 만들어지는가? 누가 바꾸는가? 누가 읽는가? 데이터가 바뀌었을 때 어떤 위젯만 다시 그려져야 하는가?

이 질문에 답할 수 있으면 상태관리 패키지는 도구가 됩니다. 반대로 이 질문 없이 패키지부터 고르면 작은 못을 박는 데 굴착기를 끌고 오거나, 빌딩을 짓는데 주머니칼 하나로 버티는 일이 생깁니다.

이번 장에서는 Flutter 상태관리의 입구를 탄탄하게 다집니다. 먼저 상태를 두 종류로 나누고, setState()가 잘 맞는 경우와 한계를 살펴봅니다. 이어서 Flutter 내부에서 데이터를 아래쪽 위젯으로 전달하는 기본 메커니즘인 InheritedWidget을 맛보고, 마지막으로 그것을 더 쉽게 쓰게 해주는 Provider와 ChangeNotifier를 배웁니다.

상태관리 입문

상태란 무엇인가

앱의 상태는 화면을 다시 그리는 데 필요한 데이터입니다. 조금 더 실감 나게 말하면, "이 값이 바뀌면 UI도 달라져야 하는 것"이 상태입니다.

예를 들어 로그인 버튼을 누른 뒤 isLoggedIn 값이 true가 되면 로그인 화면 대신 홈 화면을 보여줘야 합니다. 장바구니에 상품을 담으면 장바구니 아이콘의 숫자가 올라가야 합니다. 검색창에 글자를 입력하면 검색 결과가 바뀔 수 있습니다. 이처럼 UI의 모양, 내용, 동작을 결정하는 데이터가 상태입니다.

Flutter 공식 문서에서는 직접 관리해야 하는 상태를 크게 ephemeral state와 app state로 나누어 설명합니다. ephemeral은 "잠깐 존재하는", "일시적인"이라는 뜻입니다. 한국어로는 보통 로컬 상태, UI 상태, 임시 상태라고 부릅니다. 반면 app state는 앱의 여러 부분에서 공유되거나 더 오래 유지되어야 하는 상태입니다.1

이 구분은 절대적인 법칙이 아니라 설계를 돕는 기준입니다. 같은 값이라도 앱의 요구사항에 따라 로컬 상태가 될 수도 있고 앱 상태가 될 수도 있습니다. 예를 들어 하단 탭의 선택 인덱스는 보통 한 화면 안에서만 의미 있으므로 로컬 상태입니다. 하지만 앱의 다른 화면에서 특정 탭으로 이동시켜야 하거나, 앱을 다시 열었을 때 마지막 탭을 복원해야 한다면 앱 상태에 가까워집니다.

로컬 상태: 한 화면 안에서 조용히 끝나는 값

로컬 상태는 한 위젯이나 한 화면 안에서만 의미 있는 상태입니다. 다른 화면이 이 값을 알 필요가 없고, 앱을 껐다 켰을 때 초기화되어도 문제가 없다면 대부분 로컬 상태로 볼 수 있습니다.

대표적인 예는 다음과 같습니다.

  • 현재 선택된 탭 인덱스

  • 비밀번호 입력칸의 보이기/숨기기 여부

  • 폼 입력 중 임시 에러 메시지

  • 애니메이션 진행 상태

  • 드롭다운 또는 바텀시트의 열림 여부

  • 한 화면 안에서만 쓰는 필터 선택값

개인 지식 노트 앱을 만든다고 생각해 봅시다. 노트 작성 화면에서 "태그 선택 패널이 열려 있는가?"라는 값은 그 화면의 UI를 잠깐 제어할 뿐입니다. 다른 화면에서 알 필요가 없습니다. 이런 값까지 전역 상태관리로 올리면 앱이 과하게 복잡해집니다.

이때 가장 자연스러운 도구가 StatefulWidget과 setState()입니다.

class PasswordField extends StatefulWidget {
  const PasswordField({super.key});

  @override
  State<PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  bool _obscureText = true;

  @override
  Widget build(BuildContext context) {
    return TextField(
      obscureText: _obscureText,
      decoration: InputDecoration(
        labelText: '비밀번호',
        suffixIcon: IconButton(
          icon: Icon(
            _obscureText ? Icons.visibility_off : Icons.visibility,
          ),
          onPressed: () {
            setState(() {
              _obscureText = !_obscureText;
            });
          },
        ),
      ),
    );
  }
}

여기서 _obscureText는 PasswordField 내부에서만 읽고 바뀝니다. 앱의 다른 화면이 이 값을 몰라도 됩니다. 사용자가 앱을 종료했다가 다시 실행했을 때 비밀번호 입력칸이 다시 숨김 상태로 돌아가도 괜찮습니다. 이런 값에 Provider나 Bloc을 붙이면 오히려 코드가 더 멀어지고 읽기 어려워집니다.

상태관리는 "무조건 큰 도구를 쓰는 것"이 아니라 "상태의 크기에 맞는 그릇을 고르는 것"입니다. 컵 한 잔의 물을 옮기는 데 탱크로리가 필요하지 않은 것처럼, 작은 UI 상태에는 setState()가 가장 명쾌합니다.

앱 상태: 여러 화면이 함께 바라보는 값

앱 상태는 여러 화면이나 여러 기능에서 함께 사용하는 상태입니다. 사용자가 앱을 사용하는 동안 계속 유지되어야 하거나, 서버·로컬 저장소·인증 정보와 연결되는 경우가 많습니다.

개인 지식 노트 앱에서는 다음 값들이 앱 상태에 가깝습니다.

  • 현재 로그인한 사용자 정보

  • 사용자의 테마, 언어, 글꼴 크기 설정

  • 서버에서 불러온 노트 목록

  • 선택된 워크스페이스 또는 프로젝트

  • 오프라인 캐시에 저장된 데이터

  • 읽지 않은 알림 개수

앱 상태는 보통 한 화면의 생명주기보다 오래 삽니다. 노트 목록 화면에서 불러온 사용자 정보가 설정 화면에서도 필요할 수 있고, 로그인 상태는 라우팅에도 영향을 줍니다. "로그인했으면 홈으로, 아니면 로그인 화면으로" 같은 흐름은 한 위젯 내부의 문제가 아니라 앱 전체의 문제입니다.

이런 상태를 계속 생성자로 전달하면 금세 피곤해집니다.

MyApp(
  user: user,
  child: HomeShell(
    user: user,
    child: NoteListPage(
      user: user,
      child: NoteCard(user: user),
    ),
  ),
)

위 코드는 실제 Flutter 문법이라기보다 상황을 보여주는 그림에 가깝습니다. NoteCard 하나가 사용자 정보를 필요로 한다는 이유로 중간 위젯들이 모두 user를 받아서 다시 넘겨야 합니다. 이런 현상을 흔히 prop drilling이라고 부릅니다. 위에서 아래로 계속 소품을 전달하듯 값을 넘기는 모양이라 그렇게 부릅니다.

처음에는 괜찮지만 화면이 늘어나면 중간 위젯이 "자신은 쓰지도 않는 데이터"를 계속 전달하게 됩니다. 코드의 결합도가 올라가고, 어디서 값이 바뀌는지 추적하기 어려워집니다. 이때 상태를 위젯 트리의 적절한 상위에 두고, 필요한 하위 위젯들이 직접 읽을 수 있게 하는 구조가 필요합니다.

Flutter 공식 문서의 간단한 앱 상태관리 설명도 같은 방향을 제시합니다. 상태는 그것을 사용하는 위젯들보다 위에 두는 것이 자연스럽고, 여러 곳에서 접근해야 하는 상태는 콜백을 계속 전달하기보다 Provider 같은 도구를 사용할 수 있습니다.2

setState를 써도 되는 경우

setState()는 초보자용 임시 도구가 아닙니다. Flutter의 가장 기본적이고 강력한 상태 변경 방법입니다. 문제는 setState() 자체가 아니라, 모든 문제를 setState() 하나로 해결하려고 할 때 생깁니다.

setState()가 잘 맞는 경우는 단순합니다. 상태가 현재 StatefulWidget 내부에 있고, 그 위젯 안에서만 바뀌며, 다른 화면이나 멀리 떨어진 위젯이 그 값을 몰라도 되는 경우입니다.

예를 들어 하단 내비게이션의 현재 탭 인덱스를 생각해 봅시다.

class MainShell extends StatefulWidget {
  const MainShell({super.key});

  @override
  State<MainShell> createState() => _MainShellState();
}

class _MainShellState extends State<MainShell> {
  int _currentIndex = 0;

  final _pages = const [
    NoteListPage(),
    SearchPage(),
    SettingsPage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _pages[_currentIndex],
      bottomNavigationBar: NavigationBar(
        selectedIndex: _currentIndex,
        onDestinationSelected: (index) {
          setState(() {
            _currentIndex = index;
          });
        },
        destinations: const [
          NavigationDestination(
            icon: Icon(Icons.note_outlined),
            selectedIcon: Icon(Icons.note),
            label: '노트',
          ),
          NavigationDestination(
            icon: Icon(Icons.search),
            label: '검색',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            selectedIcon: Icon(Icons.settings),
            label: '설정',
          ),
        ],
      ),
    );
  }
}

이 코드는 충분히 좋습니다. _currentIndex는 MainShell이 소유하고, NavigationBar가 바꾸며, Scaffold의 body가 읽습니다. 모두 한 위젯 안에서 해결됩니다.

하지만 다음 신호가 보이기 시작하면 setState()만으로 버티는 것이 불편해집니다.

  • 같은 상태를 여러 화면에서 읽어야 한다.

  • 값을 부모에서 자식, 다시 손자 위젯으로 계속 전달하고 있다.

  • 상태 변경 로직이 UI 코드 안에 길게 섞인다.

  • 서버 요청, 로딩, 에러, 캐시 처리가 상태와 함께 엉킨다.

  • 특정 상태를 테스트하려면 위젯 전체를 띄워야 한다.

  • 한 값을 바꿨는데 너무 넓은 UI가 다시 빌드된다.

이런 상황에서는 상태를 별도 객체로 분리하고, 필요한 위젯들이 그 객체를 읽고 구독하는 방식이 더 적합합니다. 그 입구에 있는 도구가 InheritedWidget과 Provider입니다.

상태의 소유자와 관찰자 정하기

상태관리 설계는 탐정 놀이와 비슷합니다. 범인을 찾는 대신 상태의 소유자를 찾습니다.

"이 상태는 누가 책임져야 하지?"

상태의 소유자는 값을 만들고, 변경 규칙을 알고, 필요할 때 값을 폐기할 책임이 있는 곳입니다. 관찰자는 그 값을 읽고 UI를 그리는 위젯입니다. 변경자는 값을 바꾸는 액션을 발생시키는 곳입니다. 버튼일 수도 있고, 서버 응답일 수도 있고, 타이머일 수도 있습니다.

노트 즐겨찾기 기능을 예로 들어 봅시다. 사용자가 별 아이콘을 누르면 노트가 즐겨찾기에 추가되거나 제거됩니다. 이때 isFavorite 값을 NoteCard 안에만 두면 당장 별 아이콘은 바뀝니다. 하지만 검색 화면, 즐겨찾기 목록 화면, 노트 상세 화면이 모두 같은 즐겨찾기 상태를 보여줘야 한다면 NoteCard가 소유자가 되어서는 안 됩니다.

이 경우 즐겨찾기 상태의 소유자는 FavoriteNotesModel 같은 별도 객체가 되는 편이 자연스럽습니다. NoteCard, FavoritePage, NoteDetailPage는 관찰자가 됩니다. 별 아이콘 버튼은 변경을 요청하는 역할을 합니다.

좋은 상태관리 구조는 "누구나 아무 값이나 마음대로 바꾸는 구조"가 아닙니다. 오히려 반대입니다. 상태를 바꾸는 통로를 명확히 제한합니다. 그래서 버그가 생겼을 때 "이 값이 어디서 바뀌었지?"를 따라갈 수 있습니다.

InheritedWidget: Flutter의 아래쪽 데이터 통로

InheritedWidget은 Flutter 위젯 트리에서 데이터를 아래쪽 자손 위젯에게 효율적으로 전달하기 위한 기본 클래스입니다. Flutter API 문서에서도 InheritedWidget을 "트리 아래로 정보를 효율적으로 전파하는 위젯의 기본 클래스"라고 설명합니다. 하위 위젯이 BuildContext.dependOnInheritedWidgetOfExactType으로 특정 InheritedWidget에 의존하면, 해당 InheritedWidget이 바뀔 때 그 하위 위젯이 다시 빌드될 수 있습니다.3

어려운 말처럼 들리지만, 이미 우리는 비슷한 개념을 계속 사용해 왔습니다.

Theme.of(context)
MediaQuery.of(context)
Navigator.of(context)
ScaffoldMessenger.of(context)

이런 of(context) 패턴은 "현재 위치의 context에서 위쪽 트리를 찾아 필요한 객체를 가져온다"는 느낌으로 이해하면 됩니다. Theme.of(context)는 현재 위젯 위쪽 어딘가에 있는 테마 정보를 찾습니다. 그래서 매번 모든 버튼에 색상과 글꼴을 생성자로 넘기지 않아도 됩니다.

아주 단순한 InheritedWidget을 직접 만들어 보겠습니다.

class UserScope extends InheritedWidget {
  const UserScope({
    super.key,
    required this.userName,
    required super.child,
  });

  final String userName;

  static UserScope of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<UserScope>();
    assert(result != null, 'UserScope를 찾을 수 없습니다.');
    return result!;
  }

  @override
  bool updateShouldNotify(UserScope oldWidget) {
    return userName != oldWidget.userName;
  }
}

이 위젯은 userName을 가지고 있고, 아래쪽 위젯들이 UserScope.of(context)로 값을 읽을 수 있게 합니다. updateShouldNotify는 이전 값과 새 값을 비교해서, 의존 중인 하위 위젯에게 변경을 알려야 하는지 결정합니다.

사용하는 쪽은 다음과 같습니다.

class UserNameText extends StatelessWidget {
  const UserNameText({super.key});

  @override
  Widget build(BuildContext context) {
    final userName = UserScope.of(context).userName;

    return Text('안녕하세요, $userName님');
  }
}

그리고 이 위젯보다 위쪽에 UserScope를 배치합니다.

UserScope(
  userName: '다혜',
  child: const UserNameText(),
)

중요한 점은 context의 위치입니다. UserScope.of(context)를 호출하는 위젯은 반드시 UserScope 아래에 있어야 합니다. 위쪽이나 같은 위치의 잘못된 context로 찾으려고 하면 실패합니다. BuildContext는 단순한 전역 열쇠가 아니라 "위젯 트리에서 현재 위치를 나타내는 좌표"에 가깝습니다.

InheritedWidget만으로 충분하지 않은 이유

InheritedWidget은 Flutter 상태관리의 중요한 기초입니다. 하지만 직접 사용하려면 생각보다 손이 많이 갑니다. 단순히 데이터를 내려보내는 데는 좋지만, 상태 객체 생성, 변경 알림, 해제, 여러 객체 조합 같은 일을 직접 챙겨야 합니다.

예를 들어 카운터 값을 바꾸려면 InheritedWidget 자체만으로는 부족합니다. InheritedWidget은 불변 위젯이므로 값을 직접 바꾸는 객체가 아닙니다. 보통 그 위에 StatefulWidget을 하나 더 두고, 그 안에서 setState()로 값을 바꾼 뒤 새 InheritedWidget을 다시 만들어야 합니다.

class CounterScope extends InheritedWidget {
  const CounterScope({
    super.key,
    required this.count,
    required this.increment,
    required super.child,
  });

  final int count;
  final VoidCallback increment;

  static CounterScope of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<CounterScope>();
    assert(result != null, 'CounterScope를 찾을 수 없습니다.');
    return result!;
  }

  @override
  bool updateShouldNotify(CounterScope oldWidget) {
    return count != oldWidget.count;
  }
}

class CounterScopeHost extends StatefulWidget {
  const CounterScopeHost({
    super.key,
    required this.child,
  });

  final Widget child;

  @override
  State<CounterScopeHost> createState() => _CounterScopeHostState();
}

class _CounterScopeHostState extends State<CounterScopeHost> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return CounterScope(
      count: _count,
      increment: _increment,
      child: widget.child,
    );
  }
}

원리를 이해하기에는 좋은 예제입니다. 하지만 앱에서 상태가 여러 개로 늘어나면 이 패턴을 매번 직접 작성하기가 번거롭습니다. 그래서 등장하는 것이 Provider입니다.

Provider: InheritedWidget을 쓰기 쉽게 감싼 도구

Provider는 InheritedWidget을 더 쉽게 사용하고 재사용하기 좋게 감싼 패키지입니다. Provider 패키지 문서는 Provider를 사용하면 객체 생성과 해제, 지연 로딩, 반복 코드 감소, DevTools 친화성, Provider.of·Consumer·Selector 같은 공통 소비 방식을 얻을 수 있다고 설명합니다.4

초보자 입장에서는 이렇게 이해하면 충분합니다.

InheritedWidget이 Flutter의 기본 배관이라면, Provider는 수도꼭지와 밸브까지 달아 놓은 사용하기 쉬운 배관 세트입니다.

Provider를 쓰기 위해 먼저 패키지를 추가합니다.

flutter pub add provider

그리고 상태를 담을 클래스를 만듭니다. 가장 흔한 시작점은 ChangeNotifier입니다.

import 'package:flutter/foundation.dart';

class CounterModel extends ChangeNotifier {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }

  void reset() {
    _count = 0;
    notifyListeners();
  }
}

ChangeNotifier는 이름 그대로 "변경을 알릴 수 있는 객체"입니다. 내부 값이 바뀐 뒤 notifyListeners()를 호출하면, 이 객체를 구독하고 있는 UI가 다시 빌드됩니다. 여기서 중요한 설계 포인트는 _count를 private 필드로 숨기고, 외부에서는 increment()나 reset() 같은 메서드를 통해서만 바꾸게 했다는 점입니다.

이제 이 모델을 위젯 트리에 공급합니다.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: const MyApp(),
    ),
  );
}

ChangeNotifierProvider는 CounterModel을 생성하고, 그 아래 위젯들이 이 객체에 접근할 수 있게 합니다. 또한 Provider가 관리하는 객체는 적절한 시점에 해제되므로, 상태 객체의 생명주기를 위젯 트리와 연결하기 좋습니다.

여러 상태 객체나 서비스 객체를 공급해야 한다면 MultiProvider를 사용할 수 있습니다.

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => CounterModel()),
        ChangeNotifierProvider(create: (_) => AuthModel()),
        Provider(create: (_) => ApiClient()),
      ],
      child: const MyApp(),
    ),
  );
}

MultiProvider는 중첩된 Provider를 보기 좋게 펼쳐 쓰게 해줍니다. 동작 방식이 완전히 새로운 것은 아니고, 코드를 읽기 쉽게 만드는 장치에 가깝습니다.

context.watch와 context.read

Provider로 공급한 값을 읽는 가장 쉬운 방법은 BuildContext 확장 메서드를 사용하는 것입니다. Provider 문서는 context.watch<T>(), context.read<T>(), context.select<T, R>()를 주요 읽기 방법으로 설명합니다.4

먼저 watch를 보겠습니다.

class CounterText extends StatelessWidget {
  const CounterText({super.key});

  @override
  Widget build(BuildContext context) {
    final count = context.watch<CounterModel>().count;

    return Text(
      '$count',
      style: Theme.of(context).textTheme.displayMedium,
    );
  }
}

context.watch<CounterModel>()은 CounterModel을 읽으면서 변경을 구독합니다. 즉 CounterModel에서 notifyListeners()가 호출되면 이 위젯은 다시 빌드됩니다. 화면에 값을 보여줘야 한다면 watch를 사용합니다.

반면 버튼을 눌렀을 때 메서드만 호출하면 되는 경우에는 read를 사용합니다.

class CounterButtons extends StatelessWidget {
  const CounterButtons({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        FilledButton(
          onPressed: () {
            context.read<CounterModel>().increment();
          },
          child: const Text('증가'),
        ),
        const SizedBox(width: 12),
        OutlinedButton(
          onPressed: () {
            context.read<CounterModel>().reset();
          },
          child: const Text('초기화'),
        ),
      ],
    );
  }
}

read는 값을 한 번 가져올 뿐 변경을 구독하지 않습니다. 따라서 CounterModel이 바뀌어도 CounterButtons는 그 이유만으로 다시 빌드되지 않습니다. 버튼은 현재 숫자를 보여주는 위젯이 아니라 "증가하라", "초기화하라"는 명령을 보내는 위젯이기 때문입니다.

간단히 정리하면, 화면에 표시할 값이면 watch, 이벤트 핸들러에서 동작만 호출할 때는 read입니다.

주의할 점도 있습니다. read로 가져온 값을 build 안에서 UI를 결정하는 데 사용하면 상태가 바뀌어도 화면이 갱신되지 않습니다. 예를 들어 final count = context.read<CounterModel>().count;로 읽어서 Text('$count')를 그리면 처음 값은 보이지만 이후 변경에 반응하지 않습니다. UI가 값의 변화를 따라가야 한다면 watch를 써야 합니다.

Consumer로 필요한 부분만 다시 그리기

context.watch는 간단하고 읽기 좋습니다. 다만 watch를 호출한 위젯 전체가 다시 빌드된다는 점을 기억해야 합니다. 작은 위젯에서는 문제가 되지 않지만, 큰 화면의 맨 위에서 watch를 호출하면 필요 이상으로 넓은 영역이 다시 빌드될 수 있습니다.

이때 Consumer를 사용할 수 있습니다.

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Provider 카운터')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('현재 숫자'),
            Consumer<CounterModel>(
              builder: (context, counter, child) {
                return Text(
                  '${counter.count}',
                  style: Theme.of(context).textTheme.displayMedium,
                );
              },
            ),
            const SizedBox(height: 24),
            const CounterButtons(),
          ],
        ),
      ),
    );
  }
}

Consumer<CounterModel>의 builder 안쪽만 CounterModel 변경에 반응합니다. Scaffold, AppBar, 변하지 않는 설명 텍스트까지 매번 다시 그릴 필요가 줄어듭니다.

Consumer에는 child 매개변수도 있습니다. 상태가 바뀌어도 변하지 않는 무거운 위젯이 있다면 child로 빼서 불필요한 재빌드를 줄일 수 있습니다.

Consumer<CounterModel>(
  child: const Icon(Icons.calculate, size: 48),
  builder: (context, counter, child) {
    return Column(
      children: [
        if (child != null) child,
        Text('결과: ${counter.count}'),
      ],
    );
  },
)

입문 단계에서 모든 곳에 Consumer를 강박적으로 쓸 필요는 없습니다. 먼저 watch로 명확하게 작성하고, 실제로 다시 빌드 범위를 줄이고 싶은 지점에서 Consumer를 적용하면 됩니다.

context.select로 필요한 값만 구독하기

상태 객체가 여러 값을 가지고 있을 때는 context.select가 유용합니다. select는 객체 전체가 아니라 그중 필요한 일부 값만 구독합니다.

예를 들어 사용자 설정 모델이 있다고 해봅시다.

class SettingsModel extends ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.system;
  double _fontSize = 16;

  ThemeMode get themeMode => _themeMode;
  double get fontSize => _fontSize;

  void changeThemeMode(ThemeMode value) {
    _themeMode = value;
    notifyListeners();
  }

  void changeFontSize(double value) {
    _fontSize = value;
    notifyListeners();
  }
}

어떤 위젯은 글꼴 크기만 필요합니다.

class PreviewText extends StatelessWidget {
  const PreviewText({super.key});

  @override
  Widget build(BuildContext context) {
    final fontSize = context.select<SettingsModel, double>(
      (settings) => settings.fontSize,
    );

    return Text(
      '미리보기 문장입니다.',
      style: TextStyle(fontSize: fontSize),
    );
  }
}

이 위젯은 SettingsModel 전체가 아니라 fontSize 값에 관심이 있습니다. 테마 모드만 바뀌고 글꼴 크기가 그대로라면 PreviewText는 다시 빌드될 필요가 없습니다. select는 이런 의도를 코드로 표현합니다.

물론 select도 남용하면 코드가 잘게 쪼개져 읽기 어려워질 수 있습니다. 성능 최적화는 언제나 "필요한 만큼"이 좋습니다. 처음부터 모든 값을 select로 잘라내기보다, 상태 객체가 커지고 재빌드 비용이 눈에 보일 때 적용해도 늦지 않습니다.

작은 노트 앱 상태를 Provider로 설계해 보기

이번에는 개인 지식 노트 앱의 일부를 상상해 보겠습니다. 사용자는 노트를 만들고, 즐겨찾기에 추가할 수 있습니다. 아직 서버나 Firebase는 연결하지 않고, 메모리 안에서만 동작하는 간단한 모델로 시작합니다.

먼저 노트 모델을 만듭니다.

class Note {
  const Note({
    required this.id,
    required this.title,
    required this.content,
    required this.isFavorite,
  });

  final String id;
  final String title;
  final String content;
  final bool isFavorite;

  Note copyWith({
    String? id,
    String? title,
    String? content,
    bool? isFavorite,
  }) {
    return Note(
      id: id ?? this.id,
      title: title ?? this.title,
      content: content ?? this.content,
      isFavorite: isFavorite ?? this.isFavorite,
    );
  }
}

그리고 노트 목록 상태를 관리하는 ChangeNotifier를 만듭니다.

class NotesModel extends ChangeNotifier {
  final List<Note> _notes = [
    const Note(
      id: '1',
      title: 'Flutter 상태관리 메모',
      content: '상태는 소유자와 관찰자를 먼저 정한다.',
      isFavorite: false,
    ),
    const Note(
      id: '2',
      title: 'Material 3 아이디어',
      content: '카드, 색상, 여백을 일관되게 설계하기.',
      isFavorite: true,
    ),
  ];

  List<Note> get notes => List.unmodifiable(_notes);

  int get favoriteCount {
    return _notes.where((note) => note.isFavorite).length;
  }

  void toggleFavorite(String noteId) {
    final index = _notes.indexWhere((note) => note.id == noteId);
    if (index == -1) return;

    final note = _notes[index];
    _notes[index] = note.copyWith(isFavorite: !note.isFavorite);

    notifyListeners();
  }
}

여기서 _notes는 외부에서 직접 수정할 수 없게 숨겼습니다. 외부에는 List.unmodifiable로 읽기 전용 목록을 제공합니다. 즐겨찾기 변경은 반드시 toggleFavorite을 통해서만 일어납니다. 이렇게 해야 상태 변경의 입구가 명확해집니다.

이제 앱 상위에 공급합니다.

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => NotesModel(),
      child: const MyApp(),
    ),
  );
}

노트 목록 화면은 notes를 구독합니다.

class NoteListPage extends StatelessWidget {
  const NoteListPage({super.key});

  @override
  Widget build(BuildContext context) {
    final notes = context.watch<NotesModel>().notes;

    return Scaffold(
      appBar: AppBar(
        title: const Text('내 노트'),
        actions: const [FavoriteCountBadge()],
      ),
      body: ListView.separated(
        itemCount: notes.length,
        separatorBuilder: (_, __) => const Divider(height: 1),
        itemBuilder: (context, index) {
          final note = notes[index];
          return ListTile(
            title: Text(note.title),
            subtitle: Text(
              note.content,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
            ),
            trailing: IconButton(
              icon: Icon(
                note.isFavorite ? Icons.star : Icons.star_border,
              ),
              onPressed: () {
                context.read<NotesModel>().toggleFavorite(note.id);
              },
            ),
          );
        },
      ),
    );
  }
}

앱바 오른쪽에는 즐겨찾기 개수만 표시하는 작은 위젯을 둡니다.

class FavoriteCountBadge extends StatelessWidget {
  const FavoriteCountBadge({super.key});

  @override
  Widget build(BuildContext context) {
    final count = context.select<NotesModel, int>(
      (model) => model.favoriteCount,
    );

    return Padding(
      padding: const EdgeInsets.only(right: 16),
      child: Center(
        child: Badge(
          label: Text('$count'),
          child: const Icon(Icons.star),
        ),
      ),
    );
  }
}

이 예제에서 상태의 역할을 다시 확인해 봅시다. NotesModel은 노트 목록 상태의 소유자입니다. NoteListPage와 FavoriteCountBadge는 관찰자입니다. 별 아이콘 버튼은 상태 변경을 요청하는 이벤트 발생자입니다. UI는 상태를 직접 조작하지 않고 toggleFavorite이라는 의도를 전달합니다.

이 정도 구조만 갖춰도 작은 앱에서는 꽤 깔끔하게 상태를 관리할 수 있습니다.

Provider를 어디에 둘 것인가

Provider를 배치할 때는 "이 상태를 필요로 하는 가장 가까운 공통 조상"을 찾으면 됩니다.

앱 전체에서 로그인 사용자를 읽어야 한다면 MaterialApp보다 위 또는 앱 루트 근처에 둘 수 있습니다. 특정 탭 안에서만 쓰는 필터 상태라면 그 탭 화면 위에 두면 됩니다. 노트 상세 화면 내부에서만 쓰는 편집 모드 상태라면 굳이 앱 루트까지 올릴 필요가 없습니다.

상태를 너무 아래에 두면 필요한 위젯이 접근하지 못합니다. 반대로 너무 위에 두면 상태의 범위가 불필요하게 넓어지고, 앱 전체가 그 상태에 의존하는 것처럼 보입니다. 상태는 필요한 곳보다 약간 위에, 하지만 필요 이상으로 높지 않게 두는 것이 좋습니다.

이 원칙은 나중에 Riverpod, Bloc, 클린 아키텍처를 배울 때도 계속 유효합니다. 도구가 바뀌어도 "상태의 범위"를 정하는 감각은 그대로 쓰입니다.

setState, InheritedWidget, Provider의 역할 비교

이제 세 도구의 역할을 한눈에 정리해 봅시다.

setState()는 현재 StatefulWidget 안에서 상태를 바꾸고 그 위젯을 다시 빌드하라고 Flutter에 알립니다. 로컬 UI 상태에 적합합니다. 단순하고 빠르며 별도 패키지가 필요 없습니다.

InheritedWidget은 위젯 트리 아래쪽으로 데이터를 전달하고, 그 데이터에 의존한 하위 위젯들이 변경을 감지할 수 있게 하는 Flutter의 기본 메커니즘입니다. 직접 사용하면 원리를 이해하기 좋지만, 매번 구현하기에는 반복 코드가 많습니다.

Provider는 InheritedWidget의 원리를 더 사용하기 쉬운 API로 감싼 패키지입니다. 상태 객체를 생성하고 공급하며, 필요한 위젯이 구독하거나 읽을 수 있게 해줍니다. ChangeNotifier와 함께 쓰면 작은 앱부터 중간 규모 앱까지 충분히 실용적인 상태관리 구조를 만들 수 있습니다.

중요한 것은 셋 중 하나만 고집하지 않는 것입니다. 실제 앱에서는 셋이 함께 등장할 수 있습니다. 비밀번호 보기 토글은 setState()로 처리하고, 로그인 사용자 정보는 Provider로 공유하며, Provider 내부에서는 Flutter의 InheritedWidget 메커니즘이 동작합니다. 이것은 모순이 아니라 적재적소입니다.

흔한 실수와 피하는 법

상태관리를 처음 배울 때 가장 흔한 실수는 모든 상태를 전역으로 올리는 것입니다. "나중에 필요할지도 모르니까"라는 이유로 입력창의 임시 값, 선택된 칩, 로딩 스피너 여부까지 모두 앱 루트 Provider에 넣으면 상태 객체가 금세 잡동사니 창고가 됩니다. 로컬에서 끝나는 값은 로컬에 두는 편이 좋습니다.

반대 실수도 있습니다. 앱 상태를 끝까지 setState()와 생성자 전달로만 버티는 것입니다. 처음에는 간단해 보여도, 화면이 늘어날수록 중간 위젯들이 의미 없는 매개변수와 콜백으로 가득 찹니다. 여러 화면에서 함께 쓰는 상태라면 적절한 상위에 올리고 Provider 같은 도구로 공유하는 편이 낫습니다.

또 하나의 실수는 notifyListeners()를 너무 넓게 호출하는 구조입니다. ChangeNotifier 하나에 로그인, 노트 목록, 설정, 알림, 검색 상태를 모두 넣으면 어떤 값 하나가 바뀌어도 그 모델을 구독하는 위젯들이 함께 반응할 수 있습니다. 상태 객체는 기능 단위로 적당히 나누는 것이 좋습니다. 예를 들어 AuthModel, NotesModel, SettingsModel처럼 역할이 보이는 이름으로 분리하면 코드가 읽기 쉬워집니다.

마지막으로 watch와 read를 혼동하기 쉽습니다. UI에 표시되는 값은 watch 또는 Consumer로 구독해야 합니다. 버튼 클릭처럼 동작만 호출하는 곳에서는 read가 적합합니다. "값을 보여주는가, 명령만 보내는가"를 기준으로 고르면 대부분 맞습니다.

다음 장으로 가기 전에

이번 장에서 배운 내용은 앞으로의 상태관리 학습에서 기준점이 됩니다. Riverpod을 배우든 Bloc을 배우든 핵심 질문은 변하지 않습니다.

상태가 어디에서 생성되는가? 누가 변경하는가? 누가 관찰하는가? 변경되었을 때 어떤 UI만 다시 그려져야 하는가?

setState()는 작은 로컬 상태의 좋은 친구입니다. InheritedWidget은 Flutter가 위젯 트리 아래로 데이터를 전달하는 기본 원리입니다. Provider는 그 원리를 실전에서 더 편하게 쓰도록 도와주는 도구입니다. ChangeNotifier는 상태 객체가 "나 바뀌었어!"라고 UI에 알리는 간단한 방법입니다.

다음 장에서는 이 기초 위에서 Riverpod을 중심으로 더 현대적인 상태관리 방식을 살펴봅니다. Provider가 BuildContext를 통해 상태를 찾는 방식이라면, Riverpod은 상태를 위젯 트리 바깥에서도 더 명확하게 다루도록 도와줍니다. 하지만 도구가 달라져도 오늘 배운 감각, 즉 상태의 소유자와 관찰자를 먼저 정하는 습관은 그대로 가져가면 됩니다.

참고

1Flutter 문서: Differentiate between ephemeral state and app state

2Flutter 문서: Simple app state management

3Flutter API 문서: InheritedWidget class

4provider 패키지 문서

책 목록

처음부터 배포까지 Flutter 앱 개발 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기