Flutter 애플리케이션에서 전역 상태를 어떻게 관리하나요?
질문
Flutter 애플리케이션에서 전역 상태를 어떻게 관리하나요?
답변
Flutter 애플리케이션에서 전역 상태 관리는 앱 전체에서 공유되는 데이터를 효율적으로 관리하는 방법을 의미합니다. 여러 화면이나 위젯에서 접근해야 하는 데이터(사용자 정보, 앱 설정, 테마 등)를 효과적으로 다루기 위한 다양한 방법이 있습니다.
전역 상태 관리 방법
1. Provider 사용
Provider는 Flutter 팀이 권장하는 상태 관리 솔루션으로, 전역 상태 관리에 적합합니다.
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => UserModel()),
ChangeNotifierProvider(create: (_) => ThemeModel()),
ChangeNotifierProvider(create: (_) => CartModel()),
],
child: MyApp(),
),
);
}
// 앱의 어느 위젯에서든 상태에 접근 가능
final userModel = context.watch<UserModel>();
final themeModel = context.read<ThemeModel>();
2. GetX 사용
GetX는 상태 관리, 라우팅, 종속성 주입을 모두 제공하는 경량 패키지입니다.
// 전역 상태 정의
class UserController extends GetxController {
var user = User().obs;
void updateUser(User newUser) {
user.value = newUser;
}
}
// 초기화 및 등록
void main() {
Get.put(UserController()); // 전역 컨트롤러 등록
runApp(GetMaterialApp(home: HomeScreen()));
}
// 어디서든 접근 가능
final userController = Get.find<UserController>();
final user = userController.user.value;
// UI에서 사용
class ProfileWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(() => Text('${Get.find<UserController>().user.value.name}'));
}
}
3. BLoC 패턴 사용
BLoC 패턴은 복잡한 앱에서 전역 상태를 관리하는 데 유용합니다.
void main() {
runApp(
MultiBlocProvider(
providers: [
BlocProvider(create: (context) => AuthBloc()),
BlocProvider(create: (context) => ThemeBloc()),
BlocProvider(create: (context) => CartBloc()),
],
child: MyApp(),
),
);
}
// 어느 위젯에서든 BLoC에 접근
final authState = context.watch<AuthBloc>().state;
context.read<ThemeBloc>().add(ToggleThemeEvent());
4. Riverpod 사용
Provider의 개선 버전으로, 타입 안전성이 향상되었습니다.
// 프로바이더 정의
final userProvider = StateNotifierProvider<UserNotifier, User>((ref) {
return UserNotifier();
});
final themeProvider = StateProvider<ThemeMode>((ref) {
return ThemeMode.system;
});
// main.dart
void main() {
runApp(ProviderScope(child: MyApp()));
}
// Consumer 위젯에서 상태 사용
Consumer(
builder: (context, ref, child) {
final user = ref.watch(userProvider);
final themeMode = ref.watch(themeProvider);
return SomeWidget(user: user, themeMode: themeMode);
},
)
5. Redux 사용
Redux는 예측 가능한 상태 컨테이너를 제공합니다.
// 상태 정의
class AppState {
final User user;
final ThemeData theme;
AppState({required this.user, required this.theme});
}
// 액션 정의
class UpdateUserAction {
final User user;
UpdateUserAction(this.user);
}
class ToggleThemeAction {}
// 리듀서
AppState appReducer(AppState state, dynamic action) {
if (action is UpdateUserAction) {
return AppState(user: action.user, theme: state.theme);
} else if (action is ToggleThemeAction) {
final newTheme = state.theme.brightness == Brightness.light
? ThemeData.dark()
: ThemeData.light();
return AppState(user: state.user, theme: newTheme);
}
return state;
}
// 스토어 설정
final store = Store<AppState>(
appReducer,
initialState: AppState(
user: User.empty(),
theme: ThemeData.light(),
),
);
void main() {
runApp(
StoreProvider<AppState>(
store: store,
child: MyApp(),
),
);
}
// 위젯에서 상태 사용
StoreConnector<AppState, User>(
converter: (store) => store.state.user,
builder: (context, user) {
return UserProfile(user: user);
},
)
6. MobX 사용
MobX는 관찰 가능한 상태 패턴을 제공합니다.
// store 클래스
part 'app_store.g.dart';
class AppStore = _AppStore with _$AppStore;
abstract class _AppStore with Store {
@observable
User user = User.empty();
@observable
ThemeData theme = ThemeData.light();
@action
void updateUser(User newUser) {
user = newUser;
}
@action
void toggleTheme() {
theme = theme.brightness == Brightness.light
? ThemeData.dark()
: ThemeData.light();
}
}
// 전역 인스턴스 생성
final appStore = AppStore();
// 메인 앱
void main() {
runApp(MyApp());
}
// 위젯에서 사용
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) => MaterialApp(
theme: appStore.theme,
home: HomeScreen(),
),
);
}
}
// 다른 위젯에서 사용
class UserProfile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Observer(
builder: (_) => Text(appStore.user.name),
);
}
}
7. InheritedWidget 직접 사용
Flutter의 기본 기능을 활용한 방법입니다.
class AppStateWidget extends InheritedWidget {
final AppState state;
final Function(User) updateUser;
final Function() toggleTheme;
AppStateWidget({
Key? key,
required this.state,
required this.updateUser,
required this.toggleTheme,
required Widget child,
}) : super(key: key, child: child);
static AppStateWidget of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AppStateWidget>()!;
}
@override
bool updateShouldNotify(AppStateWidget oldWidget) {
return state != oldWidget.state;
}
}
// 상태 관리 위젯
class AppStateContainer extends StatefulWidget {
final Widget child;
AppStateContainer({required this.child});
@override
_AppStateContainerState createState() => _AppStateContainerState();
}
class _AppStateContainerState extends State<AppStateContainer> {
AppState _state = AppState(
user: User.empty(),
theme: ThemeData.light(),
);
void _updateUser(User user) {
setState(() {
_state = AppState(user: user, theme: _state.theme);
});
}
void _toggleTheme() {
setState(() {
final newTheme = _state.theme.brightness == Brightness.light
? ThemeData.dark()
: ThemeData.light();
_state = AppState(user: _state.user, theme: newTheme);
});
}
@override
Widget build(BuildContext context) {
return AppStateWidget(
state: _state,
updateUser: _updateUser,
toggleTheme: _toggleTheme,
child: widget.child,
);
}
}
// 메인 앱
void main() {
runApp(
AppStateContainer(
child: MyApp(),
),
);
}
// 위젯에서 사용
class ThemedWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = AppStateWidget.of(context);
return Theme(
data: appState.state.theme,
child: Text('테마 적용된 위젯'),
);
}
}
class UserWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = AppStateWidget.of(context);
return Text('사용자: ${appState.state.user.name}');
}
}
8. 싱글톤 패턴 사용
단순한 전역 상태의 경우 싱글톤 패턴을 사용할 수 있습니다.
class GlobalState {
// 싱글톤 인스턴스
static final GlobalState _instance = GlobalState._internal();
// 팩토리 생성자
factory GlobalState() => _instance;
// 내부 생성자
GlobalState._internal();
// 상태 변수들
User? currentUser;
ThemeMode themeMode = ThemeMode.system;
bool isNetworkAvailable = true;
// 리스너 관리
final List<Function()> _listeners = [];
// 리스너 추가
void addListener(Function() listener) {
_listeners.add(listener);
}
// 리스너 제거
void removeListener(Function() listener) {
_listeners.remove(listener);
}
// 상태 업데이트 및 알림
void updateUser(User user) {
currentUser = user;
_notifyListeners();
}
void toggleTheme() {
themeMode = themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
_notifyListeners();
}
void setNetworkStatus(bool isAvailable) {
isNetworkAvailable = isAvailable;
_notifyListeners();
}
// 리스너에게 알림
void _notifyListeners() {
for (var listener in _listeners) {
listener();
}
}
}
// 사용 예시
class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
final globalState = GlobalState();
@override
void initState() {
super.initState();
// 상태 변경 리슨
globalState.addListener(_onStateChanged);
}
@override
void dispose() {
globalState.removeListener(_onStateChanged);
super.dispose();
}
void _onStateChanged() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('사용자: ${globalState.currentUser?.name ?? "로그인 필요"}'),
Text('테마: ${globalState.themeMode.toString()}'),
ElevatedButton(
onPressed: () => globalState.toggleTheme(),
child: Text('테마 변경'),
),
],
);
}
}
전역 상태 관리 접근법 선택 시 고려사항
앱의 복잡성: 작은 앱은 Provider나 GetX가 적합하고, 복잡한 앱은 BLoC, Redux, Riverpod이 더 적합할 수 있습니다.
팀의 경험: 팀이 이미 익숙한 패턴이나 라이브러리를 선택하면 학습 시간을 줄일 수 있습니다.
유지보수 용이성: 코드가 명확하고 구조화된 패턴을 선택하면 장기적으로 유지보수가 쉬워집니다.
성능 요구사항: 대규모 앱이나 빈번한 상태 업데이트가 있는 경우 성능 최적화가 중요합니다.
상태 복잡성: 단순한 상태와 복잡한 상태에 따라 적절한 솔루션이 달라질 수 있습니다.
전역 상태 관리 모범 사례
상태 분리: 관련 있는 상태끼리 그룹화하고, 필요한 경우 여러 개의 작은 상태 객체로 분리합니다.
// 하나의 큰 상태 대신 class UserState { /* ... */ } class ThemeState { /* ... */ } class CartState { /* ... */ }
불변성 유지: 상태 객체를 직접 수정하지 말고, 새 객체를 생성하여 대체하는 방식으로 상태를 업데이트합니다.
// 잘못된 방법 state.user.name = "새이름"; // 직접 수정 // 올바른 방법 state = state.copyWith(user: state.user.copyWith(name: "새이름")); // 새 객체 생성
단방향 데이터 흐름: 상태 변경은 명확한 패턴을 따라야 합니다 (이벤트/액션 → 상태 변경 → UI 업데이트).
낙관적 업데이트: 네트워크 요청 등에서는 UI를 즉시 업데이트하고, 실패 시 롤백하는 패턴을 고려합니다.
// 낙관적 업데이트 예시 void likePost(Post post) { // 즉시 UI 업데이트 final updatedPost = post.copyWith(isLiked: true, likeCount: post.likeCount + 1); updatePostState(updatedPost); // 서버 요청 api.likePost(post.id).catchError((error) { // 실패 시 롤백 updatePostState(post); showError('좋아요 실패'); }); }
상태 지속성: 필요한 경우
shared_preferences
,hive
,sqflite
등을 사용하여 상태를 지속적으로 저장합니다.// 상태 저장 예시 Future<void> saveUserPreferences(UserPreferences prefs) async { final sharedPrefs = await SharedPreferences.getInstance(); await sharedPrefs.setString('theme', prefs.theme.toString()); await sharedPrefs.setBool('notifications', prefs.notificationsEnabled); } // 상태 복원 예시 Future<UserPreferences> loadUserPreferences() async { final sharedPrefs = await SharedPreferences.getInstance(); return UserPreferences( theme: ThemeMode.values.firstWhere( (t) => t.toString() == sharedPrefs.getString('theme'), orElse: () => ThemeMode.system, ), notificationsEnabled: sharedPrefs.getBool('notifications') ?? true, ); }
성능 최적화: 상태 변경 시 필요한 부분만 다시 빌드되도록 최적화합니다.
// 전체 상태 대신 특정 부분만 리슨 final username = context.select((UserState state) => state.user.name);
실제 구현 예시: Provider를 사용한 전역 테마 및 언어 설정
다음은 Provider를 사용하여 테마와 언어 설정을 전역적으로 관리하는 예시입니다:
// 앱 설정 모델
class AppSettings extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
Locale _locale = Locale('ko', 'KR');
ThemeMode get themeMode => _themeMode;
Locale get locale => _locale;
void setThemeMode(ThemeMode mode) {
if (_themeMode != mode) {
_themeMode = mode;
notifyListeners();
_saveSettings();
}
}
void setLocale(Locale locale) {
if (_locale != locale) {
_locale = locale;
notifyListeners();
_saveSettings();
}
}
// 설정 로드
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final themeModeIndex = prefs.getInt('themeMode') ?? 0;
_themeMode = ThemeMode.values[themeModeIndex];
final languageCode = prefs.getString('languageCode') ?? 'ko';
final countryCode = prefs.getString('countryCode') ?? 'KR';
_locale = Locale(languageCode, countryCode);
notifyListeners();
}
// 설정 저장
Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('themeMode', _themeMode.index);
await prefs.setString('languageCode', _locale.languageCode);
await prefs.setString('countryCode', _locale.countryCode ?? '');
}
}
// 메인 앱
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final settings = AppSettings();
await settings.loadSettings();
runApp(
ChangeNotifierProvider.value(
value: settings,
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 변경되면 앱을 다시 빌드
final settings = context.watch<AppSettings>();
return MaterialApp(
title: 'Flutter 다국어 & 테마 앱',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: settings.themeMode,
locale: settings.locale,
supportedLocales: [
Locale('ko', 'KR'),
Locale('en', 'US'),
],
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: HomeScreen(),
);
}
}
// 설정 화면
class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 변경되어도 위젯 전체를 다시 빌드하지 않음
final settings = context.read<AppSettings>();
return Scaffold(
appBar: AppBar(title: Text('설정')),
body: ListView(
children: [
ListTile(
title: Text('테마'),
trailing: Consumer<AppSettings>(
builder: (context, settings, _) {
return DropdownButton<ThemeMode>(
value: settings.themeMode,
onChanged: (mode) {
if (mode != null) settings.setThemeMode(mode);
},
items: [
DropdownMenuItem(
value: ThemeMode.system,
child: Text('시스템'),
),
DropdownMenuItem(
value: ThemeMode.light,
child: Text('라이트'),
),
DropdownMenuItem(
value: ThemeMode.dark,
child: Text('다크'),
),
],
);
},
),
),
ListTile(
title: Text('언어'),
trailing: Consumer<AppSettings>(
builder: (context, settings, _) {
return DropdownButton<Locale>(
value: settings.locale,
onChanged: (locale) {
if (locale != null) settings.setLocale(locale);
},
items: [
DropdownMenuItem(
value: Locale('ko', 'KR'),
child: Text('한국어'),
),
DropdownMenuItem(
value: Locale('en', 'US'),
child: Text('English'),
),
],
);
},
),
),
],
),
);
}
}
전역 상태 관리 방법은 앱의 복잡성, 팀의 경험, 유지보수 요구사항 등을 고려하여 선택해야 합니다. 단순한 앱은 Provider나 GetX로 충분할 수 있지만, 복잡한 앱은 BLoC, Riverpod, Redux 등의 고급 솔루션이 더 적합할 수 있습니다.