Flutter 상태관리 라이브러리 뭐 쓸지 고민하는 시간이 실제 코딩 시간보다 길었다. Provider? Bloc? Riverpod? GetX? 구글링하면 '이게 최고!'라는 글이 수십 개인데 전부 다른 걸 추천한다. 결국 우리 팀 3명이 각각 다른 라이브러리로 같은 화면을 만들어보는 실험을 했다. 그 결과를 공유한다.

상태관리가 중요한 이유

Flutter는 선언형 UI 프레임워크입니다. 상태가 변경되면 UI가 자동으로 다시 그려지는 구조입니다. 간단한 앱에서는 setState()만으로 충분하지만, 앱이 복잡해지면 다음과 같은 문제가 발생합니다:

Provider: Flutter 팀이 추천하는 기본 선택

Provider는 InheritedWidget을 감싼 래퍼로, Flutter 공식 문서에서도 추천하는 상태관리 솔루션입니다.

// Provider 예시
class CartNotifier extends ChangeNotifier {
  final List<CartItem> _items = [];

  List<CartItem> get items => List.unmodifiable(_items);
  int get totalPrice => _items.fold(0, (sum, item) => sum + item.price);

  void addItem(CartItem item) {
    _items.add(item);
    notifyListeners();
  }

  void removeItem(String id) {
    _items.removeWhere((item) => item.id == id);
    notifyListeners();
  }
}

// 위젯에서 사용
class CartPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cart = context.watch<CartNotifier>();
    return ListView.builder(
      itemCount: cart.items.length,
      itemBuilder: (context, index) => CartItemTile(cart.items[index]),
    );
  }
}

장점: 학습 곡선이 낮고, Flutter와 잘 통합되며, 간단한 프로젝트에 적합합니다.

단점: 타입 안전성이 약하고(런타임 에러), Provider 간 의존성 관리가 불편합니다.

Riverpod: Provider의 진화

Riverpod은 Provider의 한계를 해결하기 위해 같은 개발자가 만든 라이브러리입니다. 컴파일 타임 안전성과 유연한 의존성 관리가 핵심입니다.

// Riverpod 예시
@riverpod
class CartNotifier extends _$CartNotifier {
  @override
  List<CartItem> build() => [];

  void addItem(CartItem item) {
    state = [...state, item];
  }

  void removeItem(String id) {
    state = state.where((item) => item.id != id).toList();
  }
}

@riverpod
int cartTotalPrice(ref) {
  final items = ref.watch(cartNotifierProvider);
  return items.fold(0, (sum, item) => sum + item.price);
}

// 위젯에서 사용
class CartPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final items = ref.watch(cartNotifierProvider);
    final totalPrice = ref.watch(cartTotalPriceProvider);

    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            itemCount: items.length,
            itemBuilder: (context, index) => CartItemTile(items[index]),
          ),
        ),
        Text('총 금액: $totalPrice원'),
      ],
    );
  }
}

장점: 컴파일 타임 안전성, 코드 생성으로 보일러플레이트 감소, Provider 간 의존성을 명시적으로 관리할 수 있습니다.

단점: 학습 곡선이 Provider보다 높고, 코드 생성(build_runner) 설정이 필요합니다.

BLoC: 이벤트 기반의 구조적 접근

BLoC(Business Logic Component)은 이벤트와 상태를 명확히 분리하는 패턴입니다. 대규모 팀에서 일관된 코드 구조를 유지하는 데 유리합니다.

// BLoC 예시
// Events
abstract class CartEvent {}
class AddToCart extends CartEvent {
  final CartItem item;
  AddToCart(this.item);
}
class RemoveFromCart extends CartEvent {
  final String itemId;
  RemoveFromCart(this.itemId);
}

// State
class CartState {
  final List<CartItem> items;
  int get totalPrice => items.fold(0, (sum, item) => sum + item.price);

  const CartState({this.items = const []});
  CartState copyWith({List<CartItem>? items}) =>
      CartState(items: items ?? this.items);
}

// BLoC
class CartBloc extends Bloc<CartEvent, CartState> {
  CartBloc() : super(const CartState()) {
    on<AddToCart>((event, emit) {
      emit(state.copyWith(items: [...state.items, event.item]));
    });
    on<RemoveFromCart>((event, emit) {
      emit(state.copyWith(
        items: state.items.where((i) => i.id != event.itemId).toList(),
      ));
    });
  }
}

// 위젯에서 사용
class CartPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CartBloc, CartState>(
      builder: (context, state) => ListView.builder(
        itemCount: state.items.length,
        itemBuilder: (context, index) => CartItemTile(state.items[index]),
      ),
    );
  }
}

장점: 이벤트 추적이 용이하고, 테스트가 쉬우며, 대규모 팀에서 일관된 구조를 유지할 수 있습니다.

단점: 보일러플레이트 코드가 많고, 간단한 기능에도 Event/State/BLoC 3개 클래스가 필요합니다.

GetX: 빠른 프로토타이핑

GetX는 상태관리, 라우팅, 의존성 주입을 하나의 패키지로 제공합니다. 빠른 개발이 필요한 경우 유용합니다.

// GetX 예시
class CartController extends GetxController {
  final items = <CartItem>[].obs;
  int get totalPrice => items.fold(0, (sum, item) => sum + item.price);

  void addItem(CartItem item) => items.add(item);
  void removeItem(String id) => items.removeWhere((i) => i.id == id);
}

// 위젯에서 사용
class CartPage extends StatelessWidget {
  final controller = Get.find<CartController>();

  @override
  Widget build(BuildContext context) {
    return Obx(() => ListView.builder(
      itemCount: controller.items.length,
      itemBuilder: (context, index) =>
          CartItemTile(controller.items[index]),
    ));
  }
}

장점: 코드가 간결하고, 학습이 쉬우며, 올인원 패키지로 편리합니다.

단점: Flutter의 위젯 트리와 분리되어 디버깅이 어렵고, 매직이 많아 동작 원리를 파악하기 어렵습니다.

팀 내 비교 실험기: 저희 팀에서 상태관리 라이브러리를 선택할 때, 말로만 비교하지 말고 직접 만들어보자는 의견이 나왔습니다. 3명이서 각각 Provider, Bloc, Riverpod로 동일한 화면(상품 목록 + 장바구니 + 결제 화면)을 2주간 만들었습니다. 그리고 서로의 코드를 크로스 리뷰했죠. 결과는 흥미로웠습니다. Bloc은 코드량이 가장 많았지만(약 1.5배) 구조가 명확해서 처음 보는 사람도 흐름을 따라갈 수 있었고, Provider는 코드량이 가장 적었지만 Provider 간 의존관계가 복잡해지면서 런타임 에러가 3번 발생했습니다. Riverpod은 코드량이 중간이었고 컴파일 타임에 에러를 잡아줘서 런타임 에러가 0건이었습니다. 결국 저희는 "러닝커브 대비 장기 생산성"을 기준으로 Riverpod을 선택했습니다. 초기 2주 정도 학습 시간이 필요했지만, 한 달 후부터는 확실히 개발 속도가 올라갔습니다.

실무에서의 선택 기준

여러 프로젝트를 경험하면서 정리한 선택 기준입니다:

개인적으로는 Riverpod을 가장 선호합니다. BitRoom Factory 프로젝트에서도 Riverpod + Supabase 조합을 사용하고 있으며, 코드 생성을 통한 보일러플레이트 감소와 Provider 간 의존성 관리가 매우 편리합니다.

팀에서 상태관리 라이브러리를 선택한 과정

상태관리 라이브러리에 정답은 없습니다. 프로젝트의 규모, 팀의 숙련도, 개발 속도 요구사항에 따라 적절한 도구를 선택하는 것이 중요합니다. 어떤 라이브러리를 선택하든 비즈니스 로직과 UI를 분리하는 원칙만 지킨다면 유지보수 가능한 코드를 만들 수 있습니다.

Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글