Flutter 튜토리얼 11편: 임시 상태와 앱 상태

요약#

핵심 요지#

  • 문제 정의: 앱의 모든 상태를 같은 방식으로 관리하면 코드가 복잡해지고 유지보수가 어려워진다.
  • 핵심 주장: 상태를 임시 상태1앱 상태2로 구분하면 적절한 관리 도구를 선택할 수 있다.
  • 주요 근거: 임시 상태는 단일 위젯 내에서 setState로, 앱 상태는 Provider3 같은 상태 관리 도구로 관리한다.
  • 실무 기준: “이 상태가 다른 위젯에서 필요한가?”를 기준으로 상태 유형을 결정한다.
  • 한계: 상태 구분에 절대적인 규칙은 없으며, 앱의 요구사항에 따라 유연하게 판단해야 한다.

문서가 설명하는 범위#

  • 임시 상태와 앱 상태의 정의와 예시
  • 상태 유형을 결정하는 기준
  • Provider 패키지를 사용한 간단한 앱 상태 관리
  • ChangeNotifier, Consumer, Provider.of 사용법

읽는 시간: 16분 | 난이도: 초급


참고 자료#


문제 상황#

앱을 개발하다 보면 다양한 종류의 상태를 관리해야 합니다.
현재 선택된 탭, 사용자 로그인 정보, 장바구니 내용 등 모든 것이 “상태”입니다.

모든 상태를 같은 방식으로 관리하면 생기는 문제#

// 작은 상태도 복잡한 도구로 관리?
// 탭 인덱스 하나를 위해 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});
@override
State<MyHomepage> createState() => _MyHomepageState();
}
class _MyHomepageState extends State<MyHomepage> {
int _selectedIndex = 0; // 임시 상태
@override
Widget 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#

NOTE

Redux 창시자 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 getteritems처럼 읽기 전용 접근 제공
변경 메서드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

ChangeNotifierProviderChangeNotifier 인스턴스를 하위 위젯 트리에 제공하는 위젯입니다.
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:#e1f5fe

Provider는 상태에 접근해야 하는 모든 위젯의 상위에 위치해야 합니다.

Watch out#

WARNING

Provider를 너무 높은 위치에 두면 불필요한 리소스를 사용합니다.
상태가 필요한 서브트리의 바로 위에 두는 것이 좋습니다.

// 나쁜 예: 항상 최상위에 모든 Provider
runApp(
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#

NOTE

Provider로 상태를 제공했으면, 하위 위젯에서 상태를 읽고 변경에 반응해야 합니다.
Consumer6 위젯이 이 역할을 합니다.

// 상태가 변경되면 Consumer의 builder만 재빌드됨

What#

NOTE

Consumer<T>T 타입의 상태에 접근하고, 상태 변경 시 builder를 다시 호출합니다.
상태가 변경될 때 필요한 부분만 재빌드합니다.

How#

TIP

기본 사용법

class CartTotal extends StatelessWidget {
@override
Widget 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#

WARNING

Consumer를 위젯 트리 높은 곳에 두면 불필요한 재빌드가 발생합니다.
가능한 깊은 곳에 배치하세요.

// 문제: 전체 화면이 재빌드됨
@override
Widget 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.of7listen: false를 사용하면 됩니다.

// "장바구니 비우기" 버튼 → 상태 변경만 필요
// 버튼 자체는 재빌드될 필요 없음

What#

NOTE

Provider.of<T>(context)T 타입의 상태에 접근합니다.
listen: false를 설정하면 상태 변경 시 재빌드하지 않습니다.

How#

TIP

상태 변경만 필요한 경우

class ClearCartButton extends StatelessWidget {
@override
Widget 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;

패턴 비교

방법재빌드사용 위치
ConsumerObuild 메서드 내
context.watch()Obuild 메서드 내
Provider.of(listen: true)Obuild 메서드 내
context.read()X이벤트 핸들러
Provider.of(listen: false)X이벤트 핸들러

전체 예시

class ProductItem extends StatelessWidget {
final Item item;
const ProductItem({required this.item});
@override
Widget 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 사용
@override
Widget build(BuildContext context) {
// 상태가 변경되어도 이 위젯은 재빌드되지 않음!
final cart = context.read<CartModel>();
return Text('${cart.totalPrice}'); // 오래된 값 표시
}
// 올바른 사용: build에서는 watch 사용
@override
Widget build(BuildContext context) {
final cart = context.watch<CartModel>();
return Text('${cart.totalPrice}'); // 항상 최신 값 표시
}

결론: 이벤트 핸들러에서는 context.read()로, UI 표시에는 Consumercontext.watch()를 사용합니다.


한계#

이 문서에서 다룬 Provider는 간단한 앱 상태 관리에 적합합니다.

  • 복잡한 비동기 로직: API 호출, 캐싱 등은 추가 패턴이 필요합니다.
  • 대규모 앱: 상태가 많아지면 더 체계적인 아키텍처(BLoC, Riverpod 등)가 필요합니다.
  • 테스트: Provider 테스트는 추가 설정이 필요합니다.

다음 튜토리얼에서 다양한 상태 관리 라이브러리를 비교합니다.

Footnotes#

  1. Ephemeral State(임시 상태): 단일 위젯 내에서만 사용되고 다른 곳에서 접근할 필요가 없는 상태다. UI 상태, 로컬 상태라고도 한다.

  2. App State(앱 상태): 앱의 여러 부분에서 공유하고 세션 간에 유지하고 싶은 상태다. 공유 상태라고도 한다.

  3. Provider: Flutter 팀이 권장하는 상태 관리 패키지로, ChangeNotifier와 함께 사용하여 상태를 위젯 트리에 제공한다.

  4. ChangeNotifier: Flutter SDK에 포함된 클래스로, 리스너 패턴을 구현하여 상태 변경을 알린다.

  5. ChangeNotifierProvider: ChangeNotifier 인스턴스를 위젯 트리에 제공하는 위젯이다.

  6. Consumer: Provider가 제공한 상태를 읽고 상태 변경 시 자동으로 재빌드되는 위젯이다.

  7. Provider.of: Provider가 제공한 상태에 접근하는 메서드로, listen 매개변수로 재빌드 여부를 제어한다.

공유

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

Flutter 튜토리얼 11편: 임시 상태와 앱 상태
https://moodturnpost.net/posts/flutter/flutter-state-types/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차