Flutter 앱에서 상태를 관리하는 6가지 방법 (코드 예제 포함)

Flutter에서 상태를 관리하는 방법

Page content

이 기사에서는 Flutter 앱에서 상태를 관리하는 6가지 인기 있는 방법을 탐구할 것입니다. 실제 예제와 최선의 실천 방법을 포함합니다:

상태 관리는 Flutter 개발에서 가장 중요한 - 그리고 논란이 많은 - 주제 중 하나입니다. 이는 앱이 데이터 변경을 처리하고 UI를 효율적으로 업데이트하는 방식을 결정합니다. Flutter는 상태 관리를 위한 여러 전략을 제공합니다. 간단한 것부터 매우 확장 가능한 것까지 다양합니다.

Flutter에서의 상태 관리 전략은 다음과 같습니다:

  1. 🧱 setState() — 기본 제공, 가장 간단한 접근법
  2. 🏗️ InheritedWidget — Flutter의 상태 전파 기초
  3. 🪄 Provider — 대부분의 앱에 권장되는 솔루션
  4. 🔒 Riverpod — Provider의 현대적이고 컴파일 안전한 진화
  5. 📦 Bloc — 확장성 있고 기업용 앱에 적합한 솔루션
  6. GetX — 가볍고 모든 기능을 갖춘 통합 솔루션

flutter troubles mega tracktor


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을 이해하면 모든 솔루션을 마스터할 수 있습니다

핵심은 간단함, 확장성, 유지보수성 사이의 균형을 맞추는 것입니다. 그리고 특정 요구사항, 팀 전문성, 프로젝트 요구사항에 맞는 적절한 도구를 선택하는 것입니다.

Flutter 상태 관리 라이브러리 링크

기타 유용한 링크