Flutter 앱에서 상태를 관리하는 6가지 방법 (코드 예제 포함)
Flutter에서 상태를 관리하는 방법
이 기사에서는 Flutter 앱에서 상태를 관리하는 6가지 인기 있는 방법을 탐구할 것입니다. 실제 예제와 최선의 실천 방법을 포함합니다:
상태 관리는 Flutter 개발에서 가장 중요한 - 그리고 논란이 많은 - 주제 중 하나입니다. 이는 앱이 데이터 변경을 처리하고 UI를 효율적으로 업데이트하는 방식을 결정합니다. Flutter는 상태 관리를 위한 여러 전략을 제공합니다. 간단한 것부터 매우 확장 가능한 것까지 다양합니다.
Flutter에서의 상태 관리 전략은 다음과 같습니다:
- 🧱
setState()
— 기본 제공, 가장 간단한 접근법 - 🏗️ InheritedWidget — Flutter의 상태 전파 기초
- 🪄 Provider — 대부분의 앱에 권장되는 솔루션
- 🔒 Riverpod — Provider의 현대적이고 컴파일 안전한 진화
- 📦 Bloc — 확장성 있고 기업용 앱에 적합한 솔루션
- ⚡ GetX — 가볍고 모든 기능을 갖춘 통합 솔루션
1. setState()
사용 — 기초
Flutter에서 상태를 관리하는 가장 간단한 방법은 StatefulWidget
내부의 setState()
메서드를 사용하는 것입니다.
이 접근법은 로컬 UI 상태에 이상적입니다. 즉, 상태가 하나의 위젯에 속하고 앱 전체에서 공유될 필요가 없는 경우에 적합합니다.
예제: setState()
를 사용한 카운터 앱
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: CounterScreen());
}
}
class CounterScreen extends StatefulWidget {
@override
_CounterScreenState createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _count = 0;
void _increment() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('setState Counter')),
body: Center(
child: Text('Count: $_count', style: TextStyle(fontSize: 24)),
),
floatingActionButton: FloatingActionButton(
onPressed: _increment,
child: Icon(Icons.add),
),
);
}
}
장점
- 매우 간단하게 구현 가능
- 로컬 또는 일시적인 UI 상태에 적합
- 외부 의존성 없음
단점
- 대규모 앱에서는 확장성이 부족
- 위젯 간 상태 공유가 어렵다
- 로직이 UI와 섞여 있음
사용 시기
- 프로토타이핑 또는 작은 위젯 개발
- 고립된 UI 상태 처리 (예: 버튼 토글, 모달 표시)
2. InheritedWidget — Flutter의 기초
InheritedWidget
은 Flutter가 위젯 트리 내에서 데이터를 전파하는 저수준 메커니즘입니다. 대부분의 상태 관리 솔루션(Provider 포함)은 이 위젯 위에 구축됩니다.
InheritedWidget을 이해하면 Flutter의 상태 관리가 어떻게 내부적으로 작동하는지 파악할 수 있습니다.
예제: InheritedWidget을 사용한 테마 관리자
import 'package:flutter/material.dart';
// 테마 데이터를 보유하는 InheritedWidget
class AppTheme extends InheritedWidget {
final bool isDarkMode;
final Function toggleTheme;
const AppTheme({
Key? key,
required this.isDarkMode,
required this.toggleTheme,
required Widget child,
}) : super(key: key, child: child);
// 위젯 트리 상위에서 AppTheme에 접근하는 방법
static AppTheme? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AppTheme>();
}
@override
bool updateShouldNotify(AppTheme oldWidget) {
return isDarkMode != oldWidget.isDarkMode;
}
}
// 상태 변경을 관리하는 Stateful Wrapper
class ThemeManager extends StatefulWidget {
final Widget child;
const ThemeManager({Key? key, required this.child}) : super(key: key);
@override
_ThemeManagerState createState() => _ThemeManagerState();
}
class _ThemeManagerState extends State<ThemeManager> {
bool _isDarkMode = false;
void _toggleTheme() {
setState(() {
_isDarkMode = !_isDarkMode;
});
}
@override
Widget build(BuildContext context) {
return AppTheme(
isDarkMode: _isDarkMode,
toggleTheme: _toggleTheme,
child: widget.child,
);
}
}
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ThemeManager(
child: Builder(
builder: (context) {
final theme = AppTheme.of(context);
return MaterialApp(
theme: theme!.isDarkMode ? ThemeData.dark() : ThemeData.light(),
home: HomeScreen(),
);
},
),
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Scaffold(
appBar: AppBar(title: Text('InheritedWidget Theme')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Current Theme: ${theme!.isDarkMode ? "Dark" : "Light"}',
style: TextStyle(fontSize: 20),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => theme.toggleTheme(),
child: Text('Toggle Theme'),
),
],
),
),
);
}
}
장점
- Flutter에 내장되어 외부 의존성 없음
- 위젯 재빌드 효율적
- 다른 솔루션을 이해하는 기초 제공
- 전파 논리에 대한 직접적인 제어 가능
단점
- 번거로운 보일러플레이트 코드
- Stateful Wrapper 위젯이 필요
- 실수하기 쉬움
- 초보자에게 친화적이지 않음
사용 시기
- Flutter의 상태 관리가 내부적으로 어떻게 작동하는지 학습
- 커스텀 상태 관리 솔루션 구축
- 상태 전파에 대한 매우 구체적인 제어가 필요할 때
3. Provider — Flutter 공식 권장 솔루션
앱의 상태가 여러 위젯 간 공유되어야 할 때, Provider가 구원이 됩니다.
Provider는 제어 역전(Inversion of Control)을 기반으로 합니다. 위젯이 상태를 소유하는 대신, Provider는 다른 위젯이 소비할 수 있도록 상태를 노출합니다. Flutter 팀은 중간 규모 앱에 공식적으로 권장합니다.
설치
pubspec.yaml
에 다음을 추가하세요:
dependencies:
provider: ^6.0.5
예제: Provider를 사용한 카운터 앱
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: MyApp(),
),
);
}
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: CounterScreen());
}
}
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final counter = context.watch<CounterModel>();
return Scaffold(
appBar: AppBar(title: Text('Provider Counter')),
body: Center(
child: Text('Count: ${counter.count}', style: TextStyle(fontSize: 24)),
),
floatingActionButton: FloatingActionButton(
onPressed: context.read<CounterModel>().increment,
child: Icon(Icons.add),
),
);
}
}
장점
- 반응형이고 효율적인 업데이트
- UI와 로직의 깔끔한 분리
- 잘 문서화되고 커뮤니티 지원
단점
setState()
보다 약간 더 많은 보일러플레이트- 중첩된 Provider가 복잡해질 수 있음
사용 시기
- 여러 위젯 간 공유 상태가 필요할 때
- 복잡성이 없으면서 반응형 패턴이 필요할 때
- 프로토타입 규모를 넘어 앱이 성장할 때
4. Riverpod — Provider의 현대적 진화
**Riverpod**는 BuildContext에 대한 의존성을 제거하고 컴파일 시간 안전성을 추가한 Provider의 완전한 재작성입니다. Provider의 한계를 해결하면서 동일한 철학을 유지하도록 설계되었습니다.
설치
pubspec.yaml
에 다음을 추가하세요:
dependencies:
flutter_riverpod: ^2.4.0
예제: Riverpod을 사용한 사용자 프로필
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 모델
class UserProfile {
final String name;
final int age;
UserProfile({required this.name, required this.age});
UserProfile copyWith({String? name, int? age}) {
return UserProfile(
name: name ?? this.name,
age: age ?? this.age,
);
}
}
// State Notifier
class ProfileNotifier extends StateNotifier<UserProfile> {
ProfileNotifier() : super(UserProfile(name: 'Guest', age: 0));
void updateName(String name) {
state = state.copyWith(name: name);
}
void updateAge(int age) {
state = state.copyWith(age: age);
}
}
// Provider
final profileProvider = StateNotifierProvider<ProfileNotifier, UserProfile>((ref) {
return ProfileNotifier();
});
// Computed provider
final greetingProvider = Provider<String>((ref) {
final profile = ref.watch(profileProvider);
return 'Hello, ${profile.name}! You are ${profile.age} years old.';
});
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: ProfileScreen());
}
}
class ProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider);
final greeting = ref.watch(greetingProvider);
return Scaffold(
appBar: AppBar(title: Text('Riverpod Profile')),
body: Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(greeting, style: TextStyle(fontSize: 20)),
SizedBox(height: 30),
TextField(
decoration: InputDecoration(labelText: 'Name'),
onChanged: (value) {
ref.read(profileProvider.notifier).updateName(value);
},
),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Age: ${profile.age}', style: TextStyle(fontSize: 18)),
SizedBox(width: 20),
ElevatedButton(
onPressed: () {
ref.read(profileProvider.notifier).updateAge(profile.age + 1);
},
child: Text('+'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
if (profile.age > 0) {
ref.read(profileProvider.notifier).updateAge(profile.age - 1);
}
},
child: Text('-'),
),
],
),
],
),
),
);
}
}
장점
- BuildContext 의존성 없음 — 어디서든 상태에 접근 가능
- 컴파일 시간 안전성 — 런타임 전에 오류를 잡을 수 있음
- 더 나은 테스트 가능성
- 강력한 상태 조합
- 메모리 누수 없음
단점
- 전통적인 Flutter 패턴과 다른 API
- Provider보다 학습 곡선이 더 가파름
- 커뮤니티가 작지만 빠르게 성장 중
사용 시기
- 새로운 Flutter 프로젝트 시작
- 타입 안전성과 컴파일 시간 보장이 필요할 때
- 복잡한 상태 의존성과 조합이 필요할 때
- 중간 규모에서 대규모 앱 작업
5. BLoC (Business Logic Component) — 기업용 앱에 적합
BLoC 패턴은 스트림을 사용하여 비즈니스 로직을 완전히 UI에서 분리하는 더 고급의 아키텍처입니다.
대규모 또는 기업용 앱에 적합하며, 예측 가능하고 테스트 가능한 상태 전환에 필수적입니다.
설치
프로젝트에 다음 의존성을 추가하세요:
dependencies:
flutter_bloc: ^9.0.0
예제: BLoC를 사용한 카운터 앱
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// 이벤트
abstract class CounterEvent {}
class Increment extends CounterEvent {}
// Bloc
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<Increment>((event, emit) => emit(state + 1));
}
}
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterBloc(),
child: MaterialApp(home: CounterScreen()),
);
}
}
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Bloc Counter')),
body: Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text('Count: $count', style: TextStyle(fontSize: 24));
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(Increment()),
child: Icon(Icons.add),
),
);
}
}
장점
- 복잡한 앱에 확장성과 유지보수가 가능
- 계층 간 명확한 분리
- 단위 테스트가 쉬움
단점
- 더 많은 보일러플레이트
- 학습 곡선이 가파름
사용 시기
- 기업용 또는 장기 프로젝트 개발
- 예측 가능하고 테스트 가능한 로직이 필요할 때
- 여러 개발자가 다른 앱 모듈을 작업할 때
6. GetX — 모든 기능을 갖춘 가벼운 솔루션
**GetX**는 상태 관리, 라우팅, 의존성 주입, 유틸리티를 포함한 최소한의 보일러플레이트를 가진 강력한 상태 관리 솔루션입니다. 모든 솔루션 중 가장 적은 보일러플레이트를 제공합니다.
설치
pubspec.yaml
에 다음을 추가하세요:
dependencies:
get: ^4.6.5
예제: GetX를 사용한 쇼핑 목록
import 'package:flutter/material.dart';
import 'package:get/get.dart';
// 컨트롤러
class ShoppingController extends GetxController {
// 관찰 가능한 리스트
var items = <String>[].obs;
// 관찰 가능한 카운터
var totalItems = 0.obs;
void addItem(String item) {
if (item.isNotEmpty) {
items.add(item);
totalItems.value = items.length;
}
}
void removeItem(int index) {
items.removeAt(index);
totalItems.value = items.length;
}
void clearAll() {
items.clear();
totalItems.value = 0;
}
}
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'GetX Shopping List',
home: ShoppingScreen(),
);
}
}
class ShoppingScreen extends StatelessWidget {
// 컨트롤러 초기화
final ShoppingController controller = Get.put(ShoppingController());
final TextEditingController textController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('GetX Shopping List'),
actions: [
Obx(() => Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text(
'Items: ${controller.totalItems}',
style: TextStyle(fontSize: 18),
),
),
)),
],
),
body: Column(
children: [
Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: textController,
decoration: InputDecoration(
hintText: 'Enter item name',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
controller.addItem(textController.text);
textController.clear();
},
child: Icon(Icons.add),
),
],
),
),
Expanded(
child: Obx(() {
if (controller.items.isEmpty) {
return Center(
child: Text('No items in your list'),
);
}
return ListView.builder(
itemCount: controller.items.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(controller.items[index]),
trailing: IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () => controller.removeItem(index),
),
);
},
);
}),
),
if (controller.items.isNotEmpty)
Padding(
padding: EdgeInsets.all(16),
child: ElevatedButton(
onPressed: () {
Get.defaultDialog(
title: 'Clear List',
middleText: 'Are you sure you want to clear all items?',
textConfirm: 'Yes',
textCancel: 'No',
onConfirm: () {
controller.clearAll();
Get.back();
},
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
minimumSize: Size(double.infinity, 50),
),
child: Text('Clear All'),
),
),
],
),
);
}
}
장점
- 최소한의 보일러플레이트 — 가장 빠른 개발
- 상태, 라우팅, DI, 스낵바, 대화상자 포함된 통합 솔루션
- BuildContext 필요 없음
- 매우 가볍고 빠름
- 쉽게 학습 가능
단점
- Provider/BLoC보다 Flutter 스타일이 덜함
- 주의하지 않으면 강한 결합을 초래할 수 있음
- Provider보다 생태계가 작음
- 일부 개발자는 “너무 매직"이라고 느낄 수 있음
사용 시기
- 빠른 프로토타이핑 또는 MVP 개발
- 소규모에서 중간 규모 앱
- 최소한의 보일러플레이트가 필요할 때
- 팀이 간단함을 선호할 때
적절한 상태 관리 솔루션 선택
솔루션 | 복잡도 | 보일러플레이트 | 학습 곡선 | 최적 사용 사례 |
---|---|---|---|---|
🧱 setState() |
낮음 | 최소 | 쉬움 | 간단한 로컬 상태, 프로토타입 |
🏗️ InheritedWidget |
중간 | 높음 | 중간 | Flutter 내부 메커니즘 학습, 커스텀 솔루션 |
🪄 Provider |
낮음-중간 | 낮음 | 쉬움 | 대부분의 앱, 공유 상태 |
🔒 Riverpod |
중간 | 낮음-중간 | 중간 | 현대 앱, 타입 안전성 |
📦 BLoC |
높음 | 높음 | 가파름 | 기업용 앱, 복잡한 비즈니스 로직 |
⚡ GetX |
낮음 | 최소 | 쉬움 | 빠른 개발, MVP |
빠른 결정 가이드:
앱 복잡도 | 권장 접근법 | 예시 사용 사례 |
---|---|---|
🪶 간단 | setState() 또는 GetX |
버튼 토글, 애니메이션, 작은 위젯 |
⚖️ 중간 | Provider 또는 Riverpod |
공유 테마, 사용자 설정, 데이터 캐싱 |
🏗️ 복잡 | BLoC 또는 Riverpod |
전자상거래, 채팅 앱, 금융 대시보드 |
최종 생각
Flutter에서 상태 관리에 대한 모든 앱에 적합한 방법은 없습니다.
실용적인 접근법:
- 간단한 위젯 및 프로토타입에는
setState()
로 시작하세요 - Flutter 내부 작동 방식을 이해하기 위해 InheritedWidget을 학습하세요
- 앱이 성장하고 공유 상태가 필요해지면 Provider로 이동하세요
- 타입 안전성과 현대 패턴이 중요한 새 프로젝트에는 Riverpod을 고려하세요
- 복잡한 비즈니스 로직이 있는 대규모 기업용 앱에는 BLoC을 채택하세요
- 빠른 개발과 최소한의 보일러플레이트가 우선시되는 경우 GetX를 사용하세요
핵심 요약:
✅ 필요할 때까지 setState()
를 과도하게 설계하지 마세요
✅ 대부분의 앱에 Provider와 Riverpod이 훌륭한 선택입니다
✅ BLoC은 대규모 팀과 복잡한 앱에서 빛납니다
✅ GetX는 속도가 빠르지만 결합에 주의해야 합니다
✅ InheritedWidget을 이해하면 모든 솔루션을 마스터할 수 있습니다
핵심은 간단함, 확장성, 유지보수성 사이의 균형을 맞추는 것입니다. 그리고 특정 요구사항, 팀 전문성, 프로젝트 요구사항에 맞는 적절한 도구를 선택하는 것입니다.