[Flutter] 상태관리 라이브러리 - GetX, Provider, BLoC, Riverpod

조만간 새로운 플러터 프로젝트를 시작할 예정인데, 상태관리나 디자인 패턴 등을 어떻게 가져가야할지 고민하면서 찾아본 것들을 정리하려 한다!

프론트엔드에서의 상태 관리는 웹이나 모바일 애플리케이션의 UI의 동적인 상태를 관리하고 추적하는 매주 중요한 프로세스이다. 플러터에서도 상태 관리를 할 땐 간단한 로컬 상태부터 앱 전체에 걸쳐 공유되는 복잡한 상태까지 처리해야할 필요가 있다. 이번 글에서는 플러터의 기본적인 상태 관리 방식에 대해 살펴보고, 주요 상태 관리 라이브러리인 GetX, Provider, BLoC, Riverpod에 대해 간단히 알아보겠다.

0. SetState

플러터에서 setState를 사용하는 상태 관리는 StatefulWidget의 기본적인 방법 중 하나이다. 이 방법은 주로 간단한 로컬 상태의 변화를 처리할 때 사용된다. setState는 해당 위젯의 상태가 변경되었음을 프레임워크에 알리고, UI를 갱신하기 위해 위젯의 build 메서드를 다시 호출하게 한다.

class SimpleCounter extends StatefulWidget {
  @override
  _SimpleCounterState createState() => _SimpleCounterState();
}

class _SimpleCounterState extends State<SimpleCounter> {
  int _count = 0;

  void _increase() {
      // setState를 호출하면 Flutter에게 이 위젯의 상태가 변경되었음을 알려준다.
      // Flutter는 이에 반응하여 이 위젯을 다시 빌드한다.
    setState(() {
      _count++;
    });
  }

  void _decrease() {
    setState(() {
      _count--;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        IconButton(
          icon: Icon(Icons.remove),
          onPressed: _decrease,
        ),
        Text('Count: $_count'),
        IconButton(
          icon: Icon(Icons.add),
          onPressed: _increase,
        ),
      ],
    );
  }
}

이제 대표적인 플러터 상태관리 라이브러리들을 비교해 볼 것이다.
다음 표는 2024년 5월 5일 기준 pub.dev 에서 각 라이브러리의 인기 수치를 가져온 것이다.

GetXProviderBLoCRiverpod
Likes (pub.dev)14,0449,7652,7153,174
Pub Points130140140140
Stars (GitHub)9.9k5k11.4k5.8k

1. GetX

GetX는 플러터의 상태 관리뿐만 아니라, 의존성 관리 및 라우트 관리도 할 수 있는 매우 효율적이고 간단한 라이브러리이다. GetX는 반응형/비반응형 상태 관리를 모두 지원하며, 매우 낮은 오버헤드로 빠른 실행 속도를 제공한다.

특징:

  • GetX는 매우 직관적인 API를 좋은 성능에 제공하여 러닝 커브가 낮은 편이다.

  • 라우팅, 의존성 관리 등 다양한 추가 기능을 내장하고 있다.

  • 상태를 UI 코드로부터 쉽게 분리할 수 있고, 상태 변경 시 UI를 자동으로 새로고침해준다.

  • 적은 양의 보일러플레이트 코드를 요구한다.

작동 흐름:

  1. 상태를 위한 GetXController 클래스를 생성한다.

  2. 컨트롤러는 observable한 프로퍼티들을 가지고 있다.

  3. 컨트롤러의 상태를 업데이트한다.

  4. GetConsumer가 변경을 감지한다.

  5. 상태 변경에 따라 UI를 자동으로 재구성한다.

장점:

  • 매우 가볍고 빠르며 간단한 문법을 가져 배우기 쉽다.

  • 기존 비즈니스 로직 코드를 재사용하기 원활하다.

  • 세밀한 상태 관리와 라우팅 네비게이션이 가능하다.

  • 문서가 잘 작성되어 있다.

(단점):

  • 과도한 편의성: 너무 많은 기능이 하나의 라이브러리에 통합되어 있어, 간혹 "마법"처럼 보이는 코드가 생기기도 하며, 이로 인해 프로젝트의 복잡성이 증가할 수 있다고 한다.
class SimpleCounterController extends GetxController {
  var count = 0.obs;

  void increase() => count++;
  void decrease() => count--;
}

class SimpleCounter extends StatelessWidget {
  final SimpleCounterController controller = Get.put(SimpleCounterController());

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        IconButton(
          icon: Icon(Icons.remove),
          onPressed: controller.decrease,
        ),
        Obx(() => Text('Count: ${controller.count}')),
        IconButton(
          icon: Icon(Icons.add),
          onPressed: controller.increase,
        ),
      ],
    );
  }
}
  • 이런 식으로 SimpleCounterController같은 컨트롤러를 정의하고, 안에 observable 변수인 count를 선언한다.

  • Obx Widget 을 통해 count가 변할 때마다 리빌드를 진행해서 텍스트 위젯을 reactive 하게 만든다.

2. Provider

Provider는 플러터 전용으로 구축된 라이브러리로, 의존성을 주입하여 상태의 변화를 효과적으로 관리하고 UI에 쉽게 반영할 수 있게 해준다. Provider는 데이터의 흐름을 위젯 트리를 통해 쉽게 제어할 수 있도록 설계되었다.

특징:

  • 플러터 전용으로 제작된 경량화된 시스템을 가지고, 다른 라이브러리들과도 호환성이 좋다.

  • 다양한 상태 관리 방법론을 지원하여 유연성이 좋다.

  • 위젯 트리 어디에서나 의존성을 제공한다.

  • 비즈니스와 UI 로직의 분리를 장려한다.

작동 흐름:

  1. 상태를 위한 Provider repository를 생성한다.

  2. Provider 위젯을 사용해서 repository에 접근 가능하다.

  3. 서브 위젯들이 Provider.Of에 접근해서 상태를 소비(consume)한다.

  4. Repository는 변경 사항을 리스너들에게 알린다.

  5. Listenable provider들은 UI를 자동으로 리빌드한다.

장점:

  • 간단한 위젯 기반 API를 사용 가능하다.

  • 다양한 상태 관리 패턴과 잘 통합되어, 초보자부터 전문가까지, 소규모부터 대규모 앱까지 널리 사용된다.

  • Flutter 팀에서 권장하여, 다양한 튜토리얼과 문서가 많다.

(단점):

  • 해당 개념이 낯선 개발자에게는 초기 설정과 사용 방법이 복잡할 수 있다.

  • 상태 관리를 설정하고 사용하기 위한 보일러플레이트 코드가 반복되어 많이 필요할 수 있다.

class CounterModel with ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increase() {
    _count++;
    notifyListeners();
  }

  void decrease() {
    _count--;
    notifyListeners();
  }
}

class SimpleCounter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<CounterModel>(
      create: (context) => CounterModel(),
      child: Column(
        children: [
          Consumer<CounterModel>(
            builder: (context, model, child) => IconButton(
              icon: Icon(Icons.remove),
              onPressed: model.decrease,
            ),
          ),
          Consumer<CounterModel>(
            builder: (context, model, child) => Text('Count: ${model.count}'),
          ),
          Consumer<CounterModel>(
            builder: (context, model, child) => IconButton(
              icon: Icon(Icons.add),
              onPressed: model.increase,
            ),
          ),
        ],
      ),
    );
  }
}
  • 이런 식으로 CounterModel 클래스를 정의해서 ChangeNotifier 가 리스너(UI 컴포넌트들)이 변경사항에 맞게 리빌드 되게 한다.

  • ChangeNotifierProviderCounterModel 인스턴스를 위젯트리에 제공한다.

  • ConsumerCounterModel 을 listen하고, 모델 내에서 notifyListeners()가 불릴 때 서브트리를 리빌드한다.

3. BLoC

BLoC(Business Logic Component)는 이벤트 기반의 상태 관리를 제공하며, 애플리케이션의 비즈니스 로직을 UI로부터 분리한다. 이를 통해 테스트가 용이하고, 확장성이 보다 높은 앱 구조를 만들 수 있다.

특징:

  • 자체 컴포넌트에서 상태를 관리하며 UI와 비즈니스 로직의 철저한 분리를 통해 코드의 가독성과 유지 보수성을 향상시킨다.

  • stream/sync와 반응형 프로그래밍을 사용한다.

  • 데이터가 단방향으로 흐른다.

작동 흐름:

  1. UI 컴포넌트가 BLoC에 액션을 전달한다.

  2. BLoC이 액션을 처리한다.

  3. BLoC이 새로운 상태로 스트림을 업데이트한다.

  4. StreamBuilder가 상태 변경을 감지한다.

  5. 상태 변경에 따라 UI는 자동으로 재구성된다.

장점:

  • UI로부터 비즈니스 로직을 완벽하게 분리하여, 유지보수와 테스트가 용이하다.

  • 확장성이 높고, 복잡한 데이터 흐름과 상태를 관리하기 좋습니다.

  • test-driven 개발에 적합하다.

(단점):

  • 간단한 애플리케이션에는 과도하게 복잡할 수 있으며, 설정과 사용에 상당한 러닝 커브를 요구한다.

  • 많은 보일러플레이트 코드를 필요로 한다.

// Events
abstract class CounterEvent {}
class Increase extends CounterEvent {}
class Decrease extends CounterEvent {}

// BLoC
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0);

  @override
  Stream<int> mapEventToState(CounterEvent event) async* {
    if (event is Increase) {
      yield state + 1;
    } else if (event is Decrease) {
      yield state - 1;
    }
  }
}

class SimpleCounter extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => CounterBloc(),
      child: Column(
        children: [
          BlocBuilder<CounterBloc, int>(
            builder: (context, count) {
              return Text('Count: $count');
            },
          ),
          IconButton(
            icon: Icon(Icons.remove),
            onPressed: () => context.read<CounterBloc>().add(Decrease()),
          ),
          IconButton(
            icon: Icon(Icons.add),
            onPressed: () => context.read<CounterBloc>().add(Increase()),
          ),
        ],
      ),
    );
  }
}
  • CounterBloc이 Increase() 와 decrease() 라는 이벤트들에 반응해서 상태를 관리한다.

  • CounterEvent에서는 BLoC이 반응할 이벤트들을 정의한다.

4. Riverpod

Riverpod는 Provider 패키지를 기반으로 구축된 라이브러리이다. Provider의 주요 단점을 해결하고 보다 유연하고 안전한 상태 관리를 제공한다. 또한, 반응형 패러다임을 제공하며, 개발 중 발생할 수 있는 오류를 런타임이 아닌 컴파일 타임에 잡아낼 수 있다.

특징:

  • 다음과 같은 핵심 개념을 기반으로 한다:

    • Providers: 데이터 소스를 선언한다.

    • Consumers: Provider를 읽고 변경 사항에 따라 재구성한다.

    • Notifiers: Provider를 업데이트한다.

    • (Riverpod는 내부적으로 스트림을 사용해서 provider가 변경될 때 consumer를 업데이트한다)

  • Provider의 일부 타입 안전성 문제를 개선했고, 상태 객체를 더 쉽게 테스트할 수 있다.

  • 유연성과 범용성이 높다.

작동 흐름:

  1. 데이터를 위한 Provider Repository를 선언한다.

  2. 앱을 Provider 컨테이너로 감싼다.

  3. 컴포넌트는 consumer를 사용해서 provider에 접근한다.

  4. notifier를 통해 provider와 상호작용한다.

  5. consumer는 provider의 변경에 따라 UI를 재구성한다.

장점:

  • Provider의 타입 안전성 문제를 개선하였으며, 더욱 안정적인 개발 환경을 제공한다.

  • 상태 관리 로직을 쉽게 테스트하고 유지보수할 수 있다.

  • 거의 모든 종류의 상태 관리 시나리오에 적용할 수 있으며, Provider를 사용하는 것보다 더 다양한 기능을 지원한다.

단점:

  • 새로운 개념과 패턴이 많아 처음 접하는 개발자에게는 러닝 커브가 높다 느껴질 수 있다

  • 기존의 Provider 사용자가 Riverpod의 새로운 패러다임에 적응하는 데 시간이 걸릴 수 있다

  • .

// State Notifier
class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

  void increment() => state++;
  void decrement() => state--;
}

// Provider
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

// Counter Widget using Riverpod
class SimpleCounter extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Column(
      children: [
        IconButton(
          icon: Icon(Icons.remove),
          onPressed: () => ref.read(counterProvider.notifier).decrement(),
        ),
        Text('Count: $count'),
        IconButton(
          icon: Icon(Icons.add),
          onPressed: () => ref.read(counterProvider.notifier).increment(),
        ),
      ],
    );
  }
}
  • StateNotifier를 통해 카운터의 상태를 관리하고, StateNotifierProvider를 선언해 그 상태를 UI에게 '제공'해준다.

ps. 요렇게만 보면 Provider보다 Riverpod이 무조건 좋아 보일 수도 있지만, 막상 현업에 계신 분들의 의견을 들어보면 꼭 그런것만은 아닌 것 같다. 결국 자세한 코드는 직접 뜯어봐서 본인 취향과 프로젝트 요구사항에 맞추는게 최선이지 않을까 싶다 ^_^


References
https://www.icoderzsolutions.com/blog/flutter-state-management-packages/
https://engineering.linecorp.com/ko/blog/flutter-architecture-getx-bloc-provider
https://dmoogi.tistory.com/41