Flutter 튜토리얼 11편: 임시 상태와 앱 상태
요약
핵심 요지
문서가 설명하는 범위
- 임시 상태와 앱 상태의 정의와 예시
- 상태 유형을 결정하는 기준
- Provider 패키지를 사용한 간단한 앱 상태 관리
- ChangeNotifier, Consumer, Provider.of 사용법
읽는 시간: 16분 | 난이도: 초급
참고 자료
- Differentiate between ephemeral state and app state - 상태 유형 구분
- Simple app state management - Provider를 사용한 상태 관리
문제 상황
앱을 개발하다 보면 다양한 종류의 상태를 관리해야 합니다.
현재 선택된 탭, 사용자 로그인 정보, 장바구니 내용 등 모든 것이 “상태”입니다.
모든 상태를 같은 방식으로 관리하면 생기는 문제
// 작은 상태도 복잡한 도구로 관리?// 탭 인덱스 하나를 위해 Redux를 쓰는 건 과하다class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider<AppState>( // Redux Store store: store, child: MaterialApp(...), ); }}
// 또는 모든 상태를 setState로만 관리?// 여러 위젯이 공유하는 상태를 위해 prop drilling이 발생class Parent extends StatefulWidget { ... }class Child extends StatelessWidget { final int cartCount; // 부모에서 전달받음 final VoidCallback onAdd; // 부모에서 전달받음}문제는 다음과 같습니다.
- 작은 상태에 복잡한 도구를 사용하면 불필요한 코드가 늘어난다.
- 모든 상태를 setState로 관리하면 prop drilling이 발생한다.
- 상태의 범위와 특성을 고려하지 않으면 유지보수가 어려워진다.
해결 방법
상태를 임시 상태와 앱 상태로 구분하면 각각에 맞는 관리 방식을 선택할 수 있습니다.
챕터 1: 임시 상태(Ephemeral State) 이해하기
Why
NOTE단일 위젯 내에서만 사용되는 상태는 복잡한 상태 관리 도구가 필요 없습니다.
setState만으로 충분히 관리할 수 있습니다.// 탭 인덱스는 이 위젯에서만 사용됨int _currentIndex = 0;
What
NOTE임시 상태(UI 상태, 로컬 상태라고도 함)는 단일 위젯 내에 깔끔하게 포함될 수 있는 상태입니다.
특징 설명 범위 단일 위젯 내부 접근 다른 위젯이 접근할 필요 없음 지속성 앱 재시작 시 초기화되어도 됨 복잡도 단순한 변경 패턴
How
TIP임시 상태 예시
class MyHomepage extends StatefulWidget {const MyHomepage({super.key});@overrideState<MyHomepage> createState() => _MyHomepageState();}class _MyHomepageState extends State<MyHomepage> {int _selectedIndex = 0; // 임시 상태@overrideWidget build(BuildContext context) {return Scaffold(body: _pages[_selectedIndex],bottomNavigationBar: BottomNavigationBar(currentIndex: _selectedIndex,onTap: (index) {setState(() {_selectedIndex = index; // 상태 변경});},items: const [BottomNavigationBarItem(icon: Icon(Icons.home), label: '홈'),BottomNavigationBarItem(icon: Icon(Icons.person), label: '프로필'),],),);}final List<Widget> _pages = [const Center(child: Text('홈 화면')),const Center(child: Text('프로필 화면')),];}임시 상태의 대표적인 예
상태 설명 PageView의 현재 페이지 스와이프로 변경되는 페이지 인덱스 애니메이션 진행률 현재 애니메이션의 진행 상태 BottomNavigationBar 선택 탭 현재 선택된 탭 인덱스 폼 필드 입력값 제출 전까지의 텍스트 필드 값
Watch out
WARNING임시 상태는 앱 재시작 시 초기화됩니다.
사용자가 기대하는 지속성이 있다면 앱 상태로 관리해야 합니다.// 문제: 앱을 재시작하면 탭 선택이 초기화됨// 사용자가 마지막 탭 위치를 기억하길 원한다면?int _selectedIndex = 0; // 앱 상태로 승격 필요
결론: 단일 위젯 내에서만 사용되는 상태는 setState로 관리합니다.
챕터 2: 앱 상태(App State) 이해하기
Why
NOTE여러 위젯이 공유하거나 세션 간에 유지해야 하는 상태는 더 체계적인 관리가 필요합니다.
setState만으로는 여러 위젯에 상태를 전파하기 어렵습니다.// 장바구니는 여러 화면에서 접근해야 함// 카탈로그 화면, 장바구니 화면, 결제 화면...
What
NOTE앱 상태는 앱의 여러 부분에서 공유하고 세션 간에 유지하고 싶은 상태입니다.
특징 설명 범위 여러 위젯에서 공유 접근 앱의 다양한 부분에서 필요 지속성 세션 간 유지 필요 복잡도 복잡한 변경 패턴 가능
How
TIP앱 상태 예시
상태 이유 사용자 로그인 정보 모든 화면에서 접근, 세션 유지 장바구니 내용 여러 화면에서 수정, 결제까지 유지 알림 목록 여러 곳에서 표시, 서버와 동기화 사용자 설정 앱 전체에 영향, 영구 저장 읽음/안읽음 상태 여러 화면에서 표시, 서버와 동기화 상태 유형 결정 흐름
flowchart TD A[상태가 필요함] --> B{다른 위젯에서<br/>필요한가?} B -->|아니오| C[임시 상태<br/>setState 사용] B -->|예| D{세션 간<br/>유지 필요?} D -->|아니오| E[앱 상태<br/>Provider 등 사용] D -->|예| F[앱 상태 +<br/>영구 저장]
Watch out
WARNING모든 상태를 앱 상태로 만들면 불필요한 복잡도가 생깁니다.
상태의 범위를 가능한 작게 유지하세요.// 나쁜 예: 모든 상태를 전역으로 관리class GlobalState extends ChangeNotifier {int tabIndex = 0; // 임시 상태인데 전역으로?bool isPlaying = false; // 임시 상태인데 전역으로?User? user; // 이건 앱 상태가 맞음}
결론: 여러 위젯이 공유하거나 지속성이 필요한 상태만 앱 상태로 관리합니다.
챕터 3: 상태 유형 결정하기
Why
NOTE상태 유형에 절대적인 규칙은 없습니다.
앱의 요구사항에 따라 같은 상태도 다르게 분류될 수 있습니다.// 탭 인덱스가 임시 상태인 경우int _tabIndex = 0; // 앱 재시작 시 초기화 OK// 탭 인덱스가 앱 상태인 경우// 사용자가 마지막 탭 위치를 기억하길 원할 때
What
NOTERedux 창시자 Dan Abramov의 조언:
“The rule of thumb is: Do whatever is less awkward.” (경험 법칙: 덜 어색한 방법을 선택하라.)
상태 관리 방법은 앱의 요구사항에 따라 유연하게 결정합니다.
How
TIP결정 기준 질문
질문 예 → 앱 상태 아니오 → 임시 상태 여러 위젯이 이 상태에 접근하나? Provider 등 사용 setState 사용 세션 간에 유지해야 하나? 영구 저장 추가 메모리만 사용 외부 위젯에서 변경해야 하나? 상태 끌어올리기 로컬 관리 직렬화해서 저장/전송해야 하나? 모델 클래스 정의 단순 변수 상태 구분 요약
측면 임시 상태 앱 상태 관리 도구 State+setState()Provider, Riverpod 등 범위 단일 위젯 여러 위젯 지속성 불필요 필요한 경우 많음 직렬화 불필요 종종 필요 예시 현재 탭, 애니메이션 로그인, 장바구니
Watch out
WARNING처음에는 임시 상태로 시작하고, 필요할 때 앱 상태로 승격하는 것이 좋습니다.
미리 모든 상태를 앱 상태로 설계하면 오버엔지니어링이 됩니다.// 좋은 접근법// 1. 처음에는 로컬 상태로 시작class ProductPage extends StatefulWidget {// isFavorite을 로컬로 관리}// 2. 나중에 다른 화면에서도 필요해지면 승격class FavoritesProvider extends ChangeNotifier {// isFavorite을 앱 상태로 관리}
결론: 임시 상태로 시작하고, 공유 필요성이 생기면 앱 상태로 승격합니다.
챕터 4: ChangeNotifier로 앱 상태 정의하기
Why
NOTE앱 상태를 관리하려면 상태 변경을 위젯에 알릴 수 있어야 합니다.
ChangeNotifier4는 Flutter SDK에 내장된 간단한 옵저버 패턴 구현체입니다.// 상태가 변경되면 → 위젯에 알림 → 위젯 재빌드
What
NOTE
ChangeNotifier는 상태를 캡슐화하고notifyListeners()로 변경을 알립니다.
flutter:foundation에 포함되어 있어 외부 의존성이 없습니다.
How
TIP장바구니 모델 예시
import 'dart:collection';import 'package:flutter/foundation.dart';class CartModel extends ChangeNotifier {// 내부 상태 (private)final List<Item> _items = [];// 외부 접근용 getter (수정 불가능한 뷰)UnmodifiableListView<Item> get items => UnmodifiableListView(_items);// 계산된 속성int get totalPrice => _items.length * 42;// 상태 변경 메서드void add(Item item) {_items.add(item);notifyListeners(); // 리스너에게 변경 알림}void removeAll() {_items.clear();notifyListeners(); // 리스너에게 변경 알림}}ChangeNotifier 테스트
test('아이템 추가 시 총 가격 증가', () {final cart = CartModel();final startingPrice = cart.totalPrice;var notifyCount = 0;cart.addListener(() {expect(cart.totalPrice, greaterThan(startingPrice));notifyCount++;});cart.add(Item('Dash'));expect(notifyCount, 1); // 알림이 1번 발생했는지 확인});핵심 패턴
요소 설명 Private 필드 _items처럼 외부에서 직접 수정 불가Public getter items처럼 읽기 전용 접근 제공변경 메서드 add(),removeAll()처럼 상태 변경notifyListeners()상태 변경 후 반드시 호출
Watch out
WARNING
notifyListeners()를 빠뜨리면 UI가 업데이트되지 않습니다.
상태를 변경하는 모든 메서드에서 호출해야 합니다.void add(Item item) {_items.add(item);// notifyListeners(); // 빠뜨리면 UI 업데이트 안 됨!}또한 불필요한
notifyListeners()호출은 성능에 영향을 줍니다.void updateMultiple(List<Item> newItems) {// 나쁜 예: 매번 알림for (var item in newItems) {_items.add(item);notifyListeners(); // N번 알림 → N번 재빌드}// 좋은 예: 한 번만 알림_items.addAll(newItems);notifyListeners(); // 1번 알림 → 1번 재빌드}
결론: ChangeNotifier로 상태를 캡슐화하고 notifyListeners()로 변경을 알립니다.
챕터 5: Provider로 상태 제공하기
Why
NOTE
ChangeNotifier를 만들었으면 위젯 트리에서 접근할 수 있게 해야 합니다.
ChangeNotifierProvider5가 이 역할을 합니다.// Provider가 상태를 "제공"하면// 하위 위젯들이 "소비"할 수 있음
What
NOTE
ChangeNotifierProvider는ChangeNotifier인스턴스를 하위 위젯 트리에 제공하는 위젯입니다.
provider패키지를 설치해야 사용할 수 있습니다.
How
TIP패키지 설치
Terminal window flutter pub add provider단일 Provider 설정
import 'package:provider/provider.dart';void main() {runApp(ChangeNotifierProvider(create: (context) => CartModel(),child: const MyApp(),),);}여러 Provider 설정
void main() {runApp(MultiProvider(providers: [ChangeNotifierProvider(create: (context) => CartModel()),ChangeNotifierProvider(create: (context) => UserModel()),Provider(create: (context) => AnalyticsService()),],child: const MyApp(),),);}Provider 위치
graph TD A[ChangeNotifierProvider] --> B[MaterialApp] B --> C[HomePage] B --> D[CartPage] B --> E[CheckoutPage] style A fill:#e1f5feProvider는 상태에 접근해야 하는 모든 위젯의 상위에 위치해야 합니다.
Watch out
WARNINGProvider를 너무 높은 위치에 두면 불필요한 리소스를 사용합니다.
상태가 필요한 서브트리의 바로 위에 두는 것이 좋습니다.// 나쁜 예: 항상 최상위에 모든 ProviderrunApp(MultiProvider(providers: [// 모든 Provider를 최상위에...ChangeNotifierProvider(create: (_) => FeatureAModel()),ChangeNotifierProvider(create: (_) => FeatureBModel()),],child: MyApp(),),);// 좋은 예: 필요한 범위에만Navigator.push(context,MaterialPageRoute(builder: (context) => ChangeNotifierProvider(create: (_) => FeatureAModel(), // FeatureA 화면에서만 필요child: FeatureAScreen(),),),);
결론: ChangeNotifierProvider로 상태를 필요한 범위의 위젯 트리에 제공합니다.
챕터 6: Consumer로 상태 사용하기
Why
NOTEProvider로 상태를 제공했으면, 하위 위젯에서 상태를 읽고 변경에 반응해야 합니다.
Consumer6 위젯이 이 역할을 합니다.// 상태가 변경되면 Consumer의 builder만 재빌드됨
What
NOTE
Consumer<T>는T타입의 상태에 접근하고, 상태 변경 시builder를 다시 호출합니다.
상태가 변경될 때 필요한 부분만 재빌드합니다.
How
TIP기본 사용법
class CartTotal extends StatelessWidget {@overrideWidget build(BuildContext context) {return Consumer<CartModel>(builder: (context, cart, child) {return Text('총 가격: ${cart.totalPrice}원');},);}}builder 매개변수
매개변수 설명 context빌드 컨텍스트 cartCartModel인스턴스 (상태)child재빌드되지 않는 정적 위젯 child를 활용한 최적화
Consumer<CartModel>(builder: (context, cart, child) {return Column(children: [child!, // 재빌드되지 않음Text('총 가격: ${cart.totalPrice}원'), // 재빌드됨],);},child: const ExpensiveWidget(), // 한 번만 빌드)Consumer 위치 최적화
// 나쁜 예: Consumer가 너무 높음Consumer<CartModel>(builder: (context, cart, child) {return Column(children: [const Header(), // 재빌드 불필요const ProductList(), // 재빌드 불필요Text('${cart.totalPrice}'), // 이것만 필요],);},)// 좋은 예: Consumer가 필요한 곳에만Column(children: [const Header(),const ProductList(),Consumer<CartModel>(builder: (context, cart, child) {return Text('${cart.totalPrice}');},),],)
Watch out
WARNINGConsumer를 위젯 트리 높은 곳에 두면 불필요한 재빌드가 발생합니다.
가능한 깊은 곳에 배치하세요.// 문제: 전체 화면이 재빌드됨@overrideWidget build(BuildContext context) {return Consumer<CartModel>(builder: (context, cart, child) {return Scaffold(appBar: AppBar(...), // 재빌드 불필요body: ListView(...), // 재빌드 불필요bottomSheet: Text('${cart.totalPrice}'), // 이것만 필요);},);}
결론: Consumer를 가능한 깊은 곳에 배치해서 필요한 부분만 재빌드합니다.
챕터 7: Provider.of로 상태 변경하기
Why
NOTE버튼 클릭 시 상태를 변경해야 할 때, UI 업데이트 없이 메서드만 호출하고 싶을 수 있습니다.
Provider.of7에listen: false를 사용하면 됩니다.// "장바구니 비우기" 버튼 → 상태 변경만 필요// 버튼 자체는 재빌드될 필요 없음
What
NOTE
Provider.of<T>(context)는T타입의 상태에 접근합니다.
listen: false를 설정하면 상태 변경 시 재빌드하지 않습니다.
How
TIP상태 변경만 필요한 경우
class ClearCartButton extends StatelessWidget {@overrideWidget build(BuildContext context) {return ElevatedButton(onPressed: () {// listen: false → 이 위젯은 재빌드되지 않음Provider.of<CartModel>(context, listen: false).removeAll();},child: const Text('장바구니 비우기'),);}}context.read vs context.watch
// 상태 변경만 (재빌드 안 함) - 이벤트 핸들러에서 사용context.read<CartModel>().add(item);// 상태 읽기 + 구독 (재빌드 함) - build 메서드에서 사용final totalPrice = context.watch<CartModel>().totalPrice;패턴 비교
방법 재빌드 사용 위치 ConsumerO build 메서드 내 context.watch()O build 메서드 내 Provider.of(listen: true)O build 메서드 내 context.read()X 이벤트 핸들러 Provider.of(listen: false)X 이벤트 핸들러 전체 예시
class ProductItem extends StatelessWidget {final Item item;const ProductItem({required this.item});@overrideWidget build(BuildContext context) {return ListTile(title: Text(item.name),trailing: IconButton(icon: const Icon(Icons.add_shopping_cart),onPressed: () {// 상태 변경: listen: false 사용context.read<CartModel>().add(item);ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${item.name} 추가됨')),);},),);}}
Watch out
WARNING
context.read()나Provider.of(listen: false)를build메서드에서 사용하면 상태 변경 시 UI가 업데이트되지 않습니다.// 잘못된 사용: build에서 read 사용@overrideWidget build(BuildContext context) {// 상태가 변경되어도 이 위젯은 재빌드되지 않음!final cart = context.read<CartModel>();return Text('${cart.totalPrice}'); // 오래된 값 표시}// 올바른 사용: build에서는 watch 사용@overrideWidget build(BuildContext context) {final cart = context.watch<CartModel>();return Text('${cart.totalPrice}'); // 항상 최신 값 표시}
결론: 이벤트 핸들러에서는 context.read()로, UI 표시에는 Consumer나 context.watch()를 사용합니다.
한계
이 문서에서 다룬 Provider는 간단한 앱 상태 관리에 적합합니다.
- 복잡한 비동기 로직: API 호출, 캐싱 등은 추가 패턴이 필요합니다.
- 대규모 앱: 상태가 많아지면 더 체계적인 아키텍처(BLoC, Riverpod 등)가 필요합니다.
- 테스트: Provider 테스트는 추가 설정이 필요합니다.
다음 튜토리얼에서 다양한 상태 관리 라이브러리를 비교합니다.
Footnotes
-
Ephemeral State(임시 상태): 단일 위젯 내에서만 사용되고 다른 곳에서 접근할 필요가 없는 상태다. UI 상태, 로컬 상태라고도 한다. ↩
-
App State(앱 상태): 앱의 여러 부분에서 공유하고 세션 간에 유지하고 싶은 상태다. 공유 상태라고도 한다. ↩
-
Provider: Flutter 팀이 권장하는 상태 관리 패키지로, ChangeNotifier와 함께 사용하여 상태를 위젯 트리에 제공한다. ↩
-
ChangeNotifier: Flutter SDK에 포함된 클래스로, 리스너 패턴을 구현하여 상태 변경을 알린다. ↩
-
ChangeNotifierProvider: ChangeNotifier 인스턴스를 위젯 트리에 제공하는 위젯이다. ↩
-
Consumer: Provider가 제공한 상태를 읽고 상태 변경 시 자동으로 재빌드되는 위젯이다. ↩
-
Provider.of: Provider가 제공한 상태에 접근하는 메서드로, listen 매개변수로 재빌드 여부를 제어한다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!