Flutter 튜토리얼 10편: 상태 관리 기초

요약#

핵심 요지#

  • 문제 정의: 여러 위젯이 같은 데이터를 공유하거나 데이터 변경이 UI에 반영되어야 할 때, 상태를 어떻게 관리할지 결정해야 한다.
  • 핵심 주장: Flutter는 선언적 UI1 모델을 사용하며, UI는 상태의 함수(UI = f(state))로 표현된다.
  • 주요 근거: 상태가 변경되면 Flutter가 UI를 다시 빌드하므로, 개발자는 상태만 관리하면 된다.
  • 실무 기준: 단일 위젯의 상태는 setState2로, 여러 위젯이 공유하는 상태는 InheritedWidget3이나 ChangeNotifier4로 관리한다.
  • 한계: 앱이 커지면 기본 도구만으로는 복잡도 관리가 어려워져서 전문 상태 관리 패키지가 필요하다.

문서가 설명하는 범위#

  • 선언적 UI와 명령형 UI의 차이
  • 임시 상태(ephemeral state)와 앱 상태(app state)의 구분
  • setState, InheritedWidget, ChangeNotifier 사용법
  • 콜백을 통한 상태 전달

읽는 시간: 20분 | 난이도: 중급


참고 자료#


문제 상황#

앱을 만들다 보면 데이터가 변경될 때 화면이 업데이트되어야 합니다.
카운터를 누르면 숫자가 바뀌고, 로그인하면 사용자 정보가 표시되어야 합니다.

상태 관리가 필요한 이유#

// 문제: 변수를 바꿔도 화면이 업데이트되지 않는다
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#

NOTE

Flutter의 핵심 공식입니다.

UI = f(state)
  • UI: 화면에 표시되는 레이아웃
  • f: build 메서드
  • state: 앱의 상태

상태가 바뀌면 build 메서드가 다시 호출되고, 새로운 UI가 만들어집니다.

How#

TIP

선언적 UI 예제

class ClickButton extends StatefulWidget {
const ClickButton({super.key});
@override
State<ClickButton> createState() => _ClickButtonState();
}
class _ClickButtonState extends State<ClickButton> {
bool isClicked = false; // 상태
@override
Widget 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#

NOTE

Flutter에서 상태는 두 가지로 구분됩니다.

임시 상태(Ephemeral State)

  • 단일 위젯 내에서만 사용
  • 위젯이 사라지면 함께 사라짐
  • 예: 탭 인덱스, 폼 입력 상태, 애니메이션 진행률

앱 상태(App State)

  • 여러 위젯이 공유
  • 앱 전체에서 유지
  • 예: 로그인 정보, 장바구니, 설정값

How#

TIP

상태 유형 결정 흐름

graph TD A[상태가 필요한가?] -->|예| B{어디서 사용?} B -->|한 위젯만| C[임시 상태] B -->|여러 위젯| D[앱 상태] C --> E[setState] D --> F[상태 관리 도구]

임시 상태 예제

class TabExample extends StatefulWidget {
@override
State<TabExample> createState() => _TabExampleState();
}
class _TabExampleState extends State<TabExample> {
int currentIndex = 0; // 임시 상태: 이 위젯에서만 사용
@override
Widget 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 {
@override
Widget 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

setStateState 클래스의 메서드로, 상태가 변경되었음을 Flutter에 알립니다.
setState 내부에서 상태를 변경하면 build 메서드가 다시 호출됩니다.

How#

TIP

기본 카운터 예제

class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int count = 0;
void increment() {
setState(() {
count++; // 상태 변경
});
// setState 호출 후 build가 다시 실행됨
}
@override
Widget 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;
@override
Widget 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 {
@override
State<ParentWidget> createState() => _ParentWidgetState();
}
class _ParentWidgetState extends State<ParentWidget> {
int count = 0;
@override
Widget 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});
@override
Widget 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});
@override
Widget 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,
});
@override
Widget 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 {
@override
State<ProductReview> createState() => _ProductReviewState();
}
class _ProductReviewState extends State<ProductReview> {
int rating = 0;
@override
Widget 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';
@override
Widget build(BuildContext context) {
return Parent(userName: userName); // 전달만 함
}
}
class Parent extends StatelessWidget {
final String userName;
@override
Widget 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#

TIP

InheritedWidget 정의

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!;
}
@override
bool updateShouldNotify(AppState oldWidget) {
// 데이터가 변경되었을 때만 하위 위젯 업데이트
return userName != oldWidget.userName ||
cartItemCount != oldWidget.cartItemCount;
}
}

InheritedWidget 사용

// 앱 최상위에서 제공
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
String userName = 'Guest';
int cartItemCount = 0;
@override
Widget build(BuildContext context) {
return AppState(
userName: userName,
cartItemCount: cartItemCount,
child: MaterialApp(
home: HomeScreen(),
),
);
}
}
// 하위 위젯에서 접근
class UserProfile extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 중간 위젯을 거치지 않고 직접 접근
final appState = AppState.of(context);
return Text('안녕하세요, ${appState.userName}님!');
}
}
class CartIcon extends StatelessWidget {
@override
Widget 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});
@override
State<AppStateWidget> createState() => _AppStateWidgetState();
}
class _AppStateWidgetState extends State<AppStateWidget> {
int count = 0;
void increment() {
setState(() {
count++;
});
}
@override
Widget 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#

TIP

ChangeNotifier 정의

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 {
@override
State<CounterApp> createState() => _CounterAppState();
}
class _CounterAppState extends State<CounterApp> {
final counterNotifier = CounterNotifier();
@override
void dispose() {
counterNotifier.dispose(); // 리소스 정리
super.dispose();
}
@override
Widget 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 {
@override
State<SimpleCounter> createState() => _SimpleCounterState();
}
class _SimpleCounterState extends State<SimpleCounter> {
final countNotifier = ValueNotifier<int>(0);
@override
void dispose() {
countNotifier.dispose();
super.dispose();
}
@override
Widget 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

기능ChangeNotifierValueNotifier
복잡한 상태OX
여러 속성OX (단일 값)
커스텀 로직O제한적
간단한 사용보통쉬움

Watch out#

WARNING

ChangeNotifier를 사용할 때는 반드시 dispose()를 호출해서 리소스를 정리해야 합니다.
그렇지 않으면 메모리 누수가 발생할 수 있습니다.

@override
void dispose() {
counterNotifier.dispose(); // 필수!
super.dispose();
}

또한 notifyListeners()를 빠뜨리면 UI가 업데이트되지 않습니다.

void increment() {
_count++;
// notifyListeners(); // 이걸 빠뜨리면 UI 업데이트 안 됨
}

결론: ChangeNotifier로 반응형 상태 관리를 구현하고, 간단한 경우 ValueNotifier를 사용합니다.


한계#

Flutter의 기본 상태 관리 도구는 간단한 앱에 적합하지만, 앱이 커지면 한계가 있습니다.

  • 보일러플레이트 코드: InheritedWidget을 직접 구현하면 코드가 길어집니다.
  • 상태 구조화: 여러 종류의 상태를 체계적으로 관리하기 어렵습니다.
  • 테스트: 상태와 UI가 분리되지 않으면 테스트가 어렵습니다.
  • 디버깅: 상태 변화 추적이 쉽지 않습니다.

이런 이유로 실무에서는 provider, riverpod, bloc 같은 상태 관리 패키지를 많이 사용합니다. 다음 튜토리얼에서 이러한 도구들을 다룹니다.

Footnotes#

  1. declarative UI(선언적 UI): 상태에 따라 UI가 어떻게 보여야 하는지 선언하면 프레임워크가 알아서 업데이트하는 방식이다.

  2. setState(세트스테이트): StatefulWidget에서 상태 변경을 Flutter에 알리고 UI 재빌드를 트리거하는 메서드다.

  3. InheritedWidget(인헤리티드위젯): 위젯 트리 하위로 데이터를 효율적으로 전달하는 특수 위젯이다.

  4. ChangeNotifier(체인지노티파이어): 리스너 패턴을 구현해서 상태 변경을 구독자에게 알리는 클래스다. 2

  5. prop drilling(프롭 드릴링): 데이터를 사용하지 않는 중간 컴포넌트를 거쳐 전달해야 하는 안티패턴이다.

  6. ValueNotifier(밸류노티파이어): 단일 값의 변경을 알리는 간단한 ChangeNotifier 구현체다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 10편: 상태 관리 기초
https://moodturnpost.net/posts/flutter/flutter-state-management-basics/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차