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('테마 변경'),
        ),
      ],
    );
  }
}

전역 상태 관리 접근법 선택 시 고려사항

  1. 앱의 복잡성: 작은 앱은 Provider나 GetX가 적합하고, 복잡한 앱은 BLoC, Redux, Riverpod이 더 적합할 수 있습니다.

  2. 팀의 경험: 팀이 이미 익숙한 패턴이나 라이브러리를 선택하면 학습 시간을 줄일 수 있습니다.

  3. 유지보수 용이성: 코드가 명확하고 구조화된 패턴을 선택하면 장기적으로 유지보수가 쉬워집니다.

  4. 성능 요구사항: 대규모 앱이나 빈번한 상태 업데이트가 있는 경우 성능 최적화가 중요합니다.

  5. 상태 복잡성: 단순한 상태와 복잡한 상태에 따라 적절한 솔루션이 달라질 수 있습니다.

전역 상태 관리 모범 사례

  1. 상태 분리: 관련 있는 상태끼리 그룹화하고, 필요한 경우 여러 개의 작은 상태 객체로 분리합니다.

    // 하나의 큰 상태 대신
    class UserState { /* ... */ }
    class ThemeState { /* ... */ }
    class CartState { /* ... */ }
    
  2. 불변성 유지: 상태 객체를 직접 수정하지 말고, 새 객체를 생성하여 대체하는 방식으로 상태를 업데이트합니다.

    // 잘못된 방법
    state.user.name = "새이름"; // 직접 수정
    
    // 올바른 방법
    state = state.copyWith(user: state.user.copyWith(name: "새이름")); // 새 객체 생성
    
  3. 단방향 데이터 흐름: 상태 변경은 명확한 패턴을 따라야 합니다 (이벤트/액션 → 상태 변경 → UI 업데이트).

  4. 낙관적 업데이트: 네트워크 요청 등에서는 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('좋아요 실패');
      });
    }
    
  5. 상태 지속성: 필요한 경우 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,
      );
    }
    
  6. 성능 최적화: 상태 변경 시 필요한 부분만 다시 빌드되도록 최적화합니다.

    // 전체 상태 대신 특정 부분만 리슨
    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 등의 고급 솔루션이 더 적합할 수 있습니다.

results matching ""

    No results matching ""