Flutter 튜토리얼 10편: 상태 관리 기초
요약
핵심 요지
- 문제 정의: 여러 위젯이 같은 데이터를 공유하거나 데이터 변경이 UI에 반영되어야 할 때, 상태를 어떻게 관리할지 결정해야 한다.
- 핵심 주장: Flutter는
선언적 UI1 모델을 사용하며, UI는 상태의 함수(UI = f(state))로 표현된다. - 주요 근거: 상태가 변경되면 Flutter가 UI를 다시 빌드하므로, 개발자는 상태만 관리하면 된다.
- 실무 기준: 단일 위젯의 상태는
setState2로, 여러 위젯이 공유하는 상태는InheritedWidget3이나ChangeNotifier4로 관리한다. - 한계: 앱이 커지면 기본 도구만으로는 복잡도 관리가 어려워져서 전문 상태 관리 패키지가 필요하다.
문서가 설명하는 범위
- 선언적 UI와 명령형 UI의 차이
- 임시 상태(ephemeral state)와 앱 상태(app state)의 구분
- setState, InheritedWidget, ChangeNotifier 사용법
- 콜백을 통한 상태 전달
읽는 시간: 20분 | 난이도: 중급
참고 자료
- State management - 상태 관리 기초
- Start thinking declaratively - 선언적 프로그래밍
- Differentiate between ephemeral state and app state - 상태 유형 구분
문제 상황
앱을 만들다 보면 데이터가 변경될 때 화면이 업데이트되어야 합니다.
카운터를 누르면 숫자가 바뀌고, 로그인하면 사용자 정보가 표시되어야 합니다.
상태 관리가 필요한 이유
// 문제: 변수를 바꿔도 화면이 업데이트되지 않는다class CounterWidget extends StatelessWidget { int count = 0; // 이 변수를 바꿔도 화면은 그대로
@override Widget build(BuildContext context) { return TextButton( onPressed: () { count++; // 값은 바뀌지만 화면은 업데이트 안 됨 print('count: $count'); }, child: Text('Count: $count'), ); }}문제는 다음과 같습니다.
StatelessWidget은 상태 변경을 추적하지 않아서 UI가 업데이트되지 않습니다.- 여러 위젯이 같은 데이터를 사용할 때 어디서 데이터를 관리해야 할지 불명확합니다.
- 데이터 변경을 UI에 반영하는 방법이 명확하지 않습니다.
해결 방법
Flutter는 선언적 UI 모델을 사용합니다.
상태가 변경되면 Flutter가 UI를 다시 빌드하므로, 개발자는 “어떤 상태일 때 어떤 UI를 보여줄지”만 정의하면 됩니다.
챕터 1: 선언적 UI 이해하기
Why
NOTE전통적인 UI 프레임워크는 명령형(imperative) 방식입니다.
”버튼 텍스트를 바꿔라”, “색상을 변경해라”처럼 UI를 직접 수정하는 명령을 내립니다.// 명령형 방식 (Android)button.setText("Clicked");button.setBackgroundColor(Color.BLUE);Flutter는 선언적(declarative) 방식입니다.
”이 상태일 때 UI는 이렇게 생겼다”고 선언하면 Flutter가 알아서 업데이트합니다.
What
NOTEFlutter의 핵심 공식입니다.
UI = f(state)
- UI: 화면에 표시되는 레이아웃
- f: build 메서드
- state: 앱의 상태
상태가 바뀌면 build 메서드가 다시 호출되고, 새로운 UI가 만들어집니다.
How
TIP선언적 UI 예제
class ClickButton extends StatefulWidget {const ClickButton({super.key});@overrideState<ClickButton> createState() => _ClickButtonState();}class _ClickButtonState extends State<ClickButton> {bool isClicked = false; // 상태@overrideWidget build(BuildContext context) {// 상태에 따라 UI를 선언return ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: isClicked ? Colors.blue : Colors.grey,),onPressed: () {setState(() {isClicked = true;});},child: Text(isClicked ? 'Clicked!' : 'Click me'),);}}명령형 vs 선언적 비교
graph LR subgraph "명령형" A[이벤트] --> B[UI 수정 명령] B --> C[UI 변경] end subgraph "선언적" D[이벤트] --> E[상태 변경] E --> F[UI 재빌드] end
방식 코드 작성 UI 업데이트 명령형 UI를 직접 수정 개발자가 관리 선언적 상태별 UI 정의 Flutter가 자동 처리
Watch out
WARNING“매번 UI를 다시 빌드하면 느리지 않나?”라고 생각할 수 있습니다.
Flutter는 매 프레임마다 UI를 다시 빌드해도 충분히 빠르게 설계되었습니다.실제로 변경된 부분만 다시 그리는 최적화가 내부적으로 이루어지므로, 성능 걱정 없이 선언적 방식을 사용하면 됩니다.
결론: Flutter에서는 UI를 직접 수정하지 않고, 상태를 변경하면 UI가 자동으로 업데이트됩니다.
챕터 2: 상태의 두 가지 유형
Why
NOTE모든 상태를 같은 방식으로 관리하면 코드가 복잡해집니다.
상태의 범위에 따라 적절한 관리 방법을 선택해야 합니다.애니메이션 진행률 → 위젯 내부에서만 필요로그인 정보 → 앱 전체에서 필요
What
NOTEFlutter에서 상태는 두 가지로 구분됩니다.
임시 상태(Ephemeral State)
- 단일 위젯 내에서만 사용
- 위젯이 사라지면 함께 사라짐
- 예: 탭 인덱스, 폼 입력 상태, 애니메이션 진행률
앱 상태(App State)
- 여러 위젯이 공유
- 앱 전체에서 유지
- 예: 로그인 정보, 장바구니, 설정값
How
TIP상태 유형 결정 흐름
graph TD A[상태가 필요한가?] -->|예| B{어디서 사용?} B -->|한 위젯만| C[임시 상태] B -->|여러 위젯| D[앱 상태] C --> E[setState] D --> F[상태 관리 도구]임시 상태 예제
class TabExample extends StatefulWidget {@overrideState<TabExample> createState() => _TabExampleState();}class _TabExampleState extends State<TabExample> {int currentIndex = 0; // 임시 상태: 이 위젯에서만 사용@overrideWidget build(BuildContext context) {return BottomNavigationBar(currentIndex: currentIndex,onTap: (index) {setState(() {currentIndex = index;});},items: [BottomNavigationBarItem(icon: Icon(Icons.home), label: '홈'),BottomNavigationBarItem(icon: Icon(Icons.person), label: '프로필'),],);}}앱 상태가 필요한 경우
// 장바구니는 여러 화면에서 접근해야 함class CartScreen extends StatelessWidget {@overrideWidget build(BuildContext context) {// 앱 상태에서 장바구니 데이터 가져오기final cart = AppState.of(context).cart;return ListView.builder(itemCount: cart.items.length,itemBuilder: (context, index) => CartItem(cart.items[index]),);}}상태 유형별 관리 방법
상태 유형 특징 관리 방법 임시 상태 위젯 로컬 StatefulWidget+setState앱 상태 전역 공유 InheritedWidget,ChangeNotifier, 상태 관리 패키지
Watch out
WARNING처음에는 경계가 불분명할 수 있습니다.
”이 상태가 다른 곳에서 필요할까?”를 기준으로 판단하세요.시작은 임시 상태로 하고, 나중에 공유가 필요해지면 앱 상태로 올리는 것이 일반적인 패턴입니다.
// 처음: 임시 상태로 시작class ProductPage extends StatefulWidget {// isFavorite을 여기서 관리}// 나중: 앱 상태로 이동// 다른 화면에서도 즐겨찾기 목록이 필요해짐class FavoritesProvider extends ChangeNotifier {final List<Product> favorites = [];}
결론: 상태의 범위에 따라 임시 상태와 앱 상태를 구분하고 적절한 도구를 선택합니다.
챕터 3: setState로 임시 상태 관리하기
Why
NOTE단일 위젯 내에서만 사용하는 상태는
setState가 가장 간단한 해결책입니다.
setState를 호출하면 Flutter가 해당 위젯을 다시 빌드합니다.
What
NOTE
setState는State클래스의 메서드로, 상태가 변경되었음을 Flutter에 알립니다.
setState내부에서 상태를 변경하면build메서드가 다시 호출됩니다.
How
TIP기본 카운터 예제
class Counter extends StatefulWidget {const Counter({super.key});@overrideState<Counter> createState() => _CounterState();}class _CounterState extends State<Counter> {int count = 0;void increment() {setState(() {count++; // 상태 변경});// setState 호출 후 build가 다시 실행됨}@overrideWidget build(BuildContext context) {return Column(mainAxisAlignment: MainAxisAlignment.center,children: [Text('Count: $count', style: TextStyle(fontSize: 24)),SizedBox(height: 16),ElevatedButton(onPressed: increment,child: Text('증가'),),],);}}setState 동작 과정
sequenceDiagram participant U as 사용자 participant W as Widget participant F as Flutter U->>W: 버튼 클릭 W->>W: setState 호출 W->>F: 상태 변경 알림 F->>W: build 메서드 호출 W->>F: 새 위젯 트리 반환 F->>U: 화면 업데이트여러 상태 변수
class _FormState extends State<MyForm> {String name = '';String email = '';bool isAgreed = false;@overrideWidget build(BuildContext context) {return Column(children: [TextField(decoration: InputDecoration(labelText: '이름'),onChanged: (value) {setState(() {name = value;});},),TextField(decoration: InputDecoration(labelText: '이메일'),onChanged: (value) {setState(() {email = value;});},),CheckboxListTile(title: Text('약관에 동의합니다'),value: isAgreed,onChanged: (value) {setState(() {isAgreed = value ?? false;});},),ElevatedButton(onPressed: isAgreed ? () => submit() : null,child: Text('제출'),),],);}}
Watch out
WARNING
setState는 비동기 작업 완료 후에 호출할 때 주의가 필요합니다.
위젯이 이미 dispose된 상태에서setState를 호출하면 오류가 발생합니다.// 나쁜 예: 위젯이 dispose된 후 setState 호출 가능void loadData() async {final data = await fetchData();setState(() { // 오류 가능!this.data = data;});}// 좋은 예: mounted 체크void loadData() async {final data = await fetchData();if (mounted) { // 위젯이 아직 존재하는지 확인setState(() {this.data = data;});}}
결론: 단일 위젯의 상태는 setState로 간단하게 관리할 수 있습니다.
챕터 4: 위젯 간 상태 전달하기
Why
NOTE자식 위젯에서 상태를 표시하거나 변경해야 할 때가 있습니다.
Flutter에서는 생성자를 통해 데이터를 전달하고, 콜백을 통해 변경을 알립니다.
What
NOTE상태 전달의 두 가지 방향입니다.
- 아래로 전달: 생성자 매개변수로 데이터 전달
- 위로 전달: 콜백 함수로 변경 알림
How
TIP생성자를 통한 데이터 전달
// 부모 위젯class ParentWidget extends StatefulWidget {@overrideState<ParentWidget> createState() => _ParentWidgetState();}class _ParentWidgetState extends State<ParentWidget> {int count = 0;@overrideWidget build(BuildContext context) {return Column(children: [// 자식에게 데이터 전달CounterDisplay(count: count),CounterButton(onPressed: () {setState(() {count++;});},),],);}}// 자식 위젯: 데이터 표시class CounterDisplay extends StatelessWidget {final int count;const CounterDisplay({super.key, required this.count});@overrideWidget build(BuildContext context) {return Text('Count: $count', style: TextStyle(fontSize: 24));}}// 자식 위젯: 액션 전달class CounterButton extends StatelessWidget {final VoidCallback onPressed;const CounterButton({super.key, required this.onPressed});@overrideWidget build(BuildContext context) {return ElevatedButton(onPressed: onPressed,child: Text('증가'),);}}콜백을 통한 값 전달
// 자식에서 부모로 값 전달class RatingWidget extends StatelessWidget {final int rating;final ValueChanged<int> onRatingChanged;const RatingWidget({super.key,required this.rating,required this.onRatingChanged,});@overrideWidget build(BuildContext context) {return Row(children: List.generate(5, (index) {return IconButton(icon: Icon(index < rating ? Icons.star : Icons.star_border,color: Colors.amber,),onPressed: () => onRatingChanged(index + 1),);}),);}}// 부모에서 사용class ProductReview extends StatefulWidget {@overrideState<ProductReview> createState() => _ProductReviewState();}class _ProductReviewState extends State<ProductReview> {int rating = 0;@overrideWidget build(BuildContext context) {return Column(children: [Text('평점: $rating'),RatingWidget(rating: rating,onRatingChanged: (newRating) {setState(() {rating = newRating;});},),],);}}데이터 흐름 다이어그램
graph TB subgraph "부모 위젯" A[상태: count = 0] end subgraph "자식 위젯들" B[CounterDisplay] C[CounterButton] end A -->|count 전달| B A -->|onPressed 전달| C C -->|콜백 호출| A
Watch out
WARNING위젯 트리가 깊어지면 데이터를 여러 단계로 전달해야 하는 “prop drilling”5 문제가 발생합니다.
// 문제: 중간 위젯들이 불필요하게 데이터를 전달class GrandParent extends StatelessWidget {final String userName = 'Alice';@overrideWidget build(BuildContext context) {return Parent(userName: userName); // 전달만 함}}class Parent extends StatelessWidget {final String userName;@overrideWidget build(BuildContext context) {return Child(userName: userName); // 전달만 함}}class Child extends StatelessWidget {final String userName; // 실제 사용// ...}이런 경우
InheritedWidget이나 상태 관리 도구를 사용하는 것이 좋습니다.
결론: 간단한 경우 생성자와 콜백으로 상태를 전달하고, 복잡해지면 다른 방법을 고려합니다.
챕터 5: InheritedWidget으로 상태 공유하기
Why
NOTE위젯 트리가 깊을 때 매번 생성자로 데이터를 전달하면 코드가 복잡해집니다.
InheritedWidget은 위젯 트리의 어느 위치에서든 상위의 데이터에 직접 접근할 수 있게 합니다.
What
NOTE
InheritedWidget은 하위 위젯 트리에 데이터를 효율적으로 전달하는 특수한 위젯입니다.
자식 위젯에서context.dependOnInheritedWidgetOfExactType<T>()로 접근합니다.
How
TIPInheritedWidget 정의
class AppState extends InheritedWidget {final String userName;final int cartItemCount;const AppState({super.key,required this.userName,required this.cartItemCount,required super.child,});// 편의 메서드: 하위 위젯에서 쉽게 접근static AppState of(BuildContext context) {final result = context.dependOnInheritedWidgetOfExactType<AppState>();assert(result != null, 'No AppState found in context');return result!;}@overridebool updateShouldNotify(AppState oldWidget) {// 데이터가 변경되었을 때만 하위 위젯 업데이트return userName != oldWidget.userName ||cartItemCount != oldWidget.cartItemCount;}}InheritedWidget 사용
// 앱 최상위에서 제공class MyApp extends StatefulWidget {@overrideState<MyApp> createState() => _MyAppState();}class _MyAppState extends State<MyApp> {String userName = 'Guest';int cartItemCount = 0;@overrideWidget build(BuildContext context) {return AppState(userName: userName,cartItemCount: cartItemCount,child: MaterialApp(home: HomeScreen(),),);}}// 하위 위젯에서 접근class UserProfile extends StatelessWidget {@overrideWidget build(BuildContext context) {// 중간 위젯을 거치지 않고 직접 접근final appState = AppState.of(context);return Text('안녕하세요, ${appState.userName}님!');}}class CartIcon extends StatelessWidget {@overrideWidget build(BuildContext context) {final appState = AppState.of(context);return Badge(label: Text('${appState.cartItemCount}'),child: Icon(Icons.shopping_cart),);}}InheritedWidget vs 생성자 전달
graph TB subgraph "생성자 전달" A1[App] -->|data| B1[Screen] B1 -->|data| C1[Section] C1 -->|data| D1[Widget] end subgraph "InheritedWidget" A2[App + InheritedWidget] --> B2[Screen] B2 --> C2[Section] C2 --> D2[Widget] A2 -.->|직접 접근| D2 end
Watch out
WARNING
InheritedWidget만으로는 상태를 변경할 수 없습니다.
변경 가능한 상태가 필요하면StatefulWidget과 함께 사용해야 합니다.// InheritedWidget + StatefulWidget 조합class AppStateWidget extends StatefulWidget {final Widget child;const AppStateWidget({required this.child});@overrideState<AppStateWidget> createState() => _AppStateWidgetState();}class _AppStateWidgetState extends State<AppStateWidget> {int count = 0;void increment() {setState(() {count++;});}@overrideWidget build(BuildContext context) {return AppState(count: count,increment: increment, // 변경 메서드도 전달child: widget.child,);}}이런 패턴이 복잡하다면
provider패키지 사용을 권장합니다.
결론: InheritedWidget으로 깊은 위젯 트리에서도 데이터에 쉽게 접근할 수 있습니다.
챕터 6: ChangeNotifier로 반응형 상태 관리하기
Why
NOTE상태가 변경될 때 관련된 위젯만 자동으로 업데이트되면 편리합니다.
ChangeNotifier4는 상태 변경을 리스너에게 알려서 반응형 업데이트를 가능하게 합니다.
What
NOTE
ChangeNotifier는 리스너 패턴을 구현한 클래스입니다.
상태가 변경되면notifyListeners()를 호출하고,ListenableBuilder로 UI를 업데이트합니다.
How
TIPChangeNotifier 정의
class CounterNotifier extends ChangeNotifier {int _count = 0;int get count => _count;void increment() {_count++;notifyListeners(); // 리스너에게 변경 알림}void decrement() {_count--;notifyListeners();}void reset() {_count = 0;notifyListeners();}}ListenableBuilder로 UI 연결
class CounterApp extends StatefulWidget {@overrideState<CounterApp> createState() => _CounterAppState();}class _CounterAppState extends State<CounterApp> {final counterNotifier = CounterNotifier();@overridevoid dispose() {counterNotifier.dispose(); // 리소스 정리super.dispose();}@overrideWidget build(BuildContext context) {return Scaffold(body: Center(// ListenableBuilder가 변경을 감지하고 UI 업데이트child: ListenableBuilder(listenable: counterNotifier,builder: (context, child) {return Text('Count: ${counterNotifier.count}',style: TextStyle(fontSize: 24),);},),),floatingActionButton: Column(mainAxisAlignment: MainAxisAlignment.end,children: [FloatingActionButton(onPressed: counterNotifier.increment,child: Icon(Icons.add),),SizedBox(height: 8),FloatingActionButton(onPressed: counterNotifier.decrement,child: Icon(Icons.remove),),],),);}}ValueNotifier: 단순 값용
단일 값만 관리할 때는
ValueNotifier6가 더 간단합니다.class SimpleCounter extends StatefulWidget {@overrideState<SimpleCounter> createState() => _SimpleCounterState();}class _SimpleCounterState extends State<SimpleCounter> {final countNotifier = ValueNotifier<int>(0);@overridevoid dispose() {countNotifier.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Column(children: [ValueListenableBuilder<int>(valueListenable: countNotifier,builder: (context, value, child) {return Text('Count: $value');},),ElevatedButton(onPressed: () => countNotifier.value++,child: Text('증가'),),],);}}ChangeNotifier vs ValueNotifier
기능 ChangeNotifier ValueNotifier 복잡한 상태 O X 여러 속성 O X (단일 값) 커스텀 로직 O 제한적 간단한 사용 보통 쉬움
Watch out
WARNING
ChangeNotifier를 사용할 때는 반드시dispose()를 호출해서 리소스를 정리해야 합니다.
그렇지 않으면 메모리 누수가 발생할 수 있습니다.@overridevoid dispose() {counterNotifier.dispose(); // 필수!super.dispose();}또한
notifyListeners()를 빠뜨리면 UI가 업데이트되지 않습니다.void increment() {_count++;// notifyListeners(); // 이걸 빠뜨리면 UI 업데이트 안 됨}
결론: ChangeNotifier로 반응형 상태 관리를 구현하고, 간단한 경우 ValueNotifier를 사용합니다.
한계
Flutter의 기본 상태 관리 도구는 간단한 앱에 적합하지만, 앱이 커지면 한계가 있습니다.
- 보일러플레이트 코드:
InheritedWidget을 직접 구현하면 코드가 길어집니다. - 상태 구조화: 여러 종류의 상태를 체계적으로 관리하기 어렵습니다.
- 테스트: 상태와 UI가 분리되지 않으면 테스트가 어렵습니다.
- 디버깅: 상태 변화 추적이 쉽지 않습니다.
이런 이유로 실무에서는 provider, riverpod, bloc 같은 상태 관리 패키지를 많이 사용합니다.
다음 튜토리얼에서 이러한 도구들을 다룹니다.
Footnotes
-
declarative UI(선언적 UI): 상태에 따라 UI가 어떻게 보여야 하는지 선언하면 프레임워크가 알아서 업데이트하는 방식이다. ↩
-
setState(세트스테이트): StatefulWidget에서 상태 변경을 Flutter에 알리고 UI 재빌드를 트리거하는 메서드다. ↩
-
InheritedWidget(인헤리티드위젯): 위젯 트리 하위로 데이터를 효율적으로 전달하는 특수 위젯이다. ↩
-
ChangeNotifier(체인지노티파이어): 리스너 패턴을 구현해서 상태 변경을 구독자에게 알리는 클래스다. ↩ ↩2
-
prop drilling(프롭 드릴링): 데이터를 사용하지 않는 중간 컴포넌트를 거쳐 전달해야 하는 안티패턴이다. ↩
-
ValueNotifier(밸류노티파이어): 단일 값의 변경을 알리는 간단한 ChangeNotifier 구현체다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!