effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화

Riverpod vs Bloc, 프로덕션에서 후회하지 않으려면: 팀 규모별 실전 의사결정 가이드

Riverpod vs Bloc, 프로덕션에서 후회하지 않으려면: 팀 규모별 실전 의사결정 가이드

Flutter 상태 관리 라이브러리를 고를 때 흔히 보는 자료는 “Riverpod는 컴파일 타임 안전성이 좋고, Bloc은 이벤트 기반이라 테스트하기 쉽다” 같은 기능 비교표다. 틀린 말은 아니지만, 실제로 프로덕션에서 팀을 괴롭히는 건 기능 목록이 아니라 6개월~1년 뒤에야 드러나는 운영 비용이다. 이 글은 두 라이브러리 중 무엇이 우월한지 가리지 않는다. 대신 각 라이브러리가 실무에서 실제로 터뜨리는 문제를 먼저 보여주고, 팀 규모와 도메인 특성에 따라 언제 무엇을 선택하고 언제 마이그레이션할 가치가 있는지 판단하는 기준을 제시한다.

핵심 요약

  • Riverpod의 진짜 위험은 기능 부족이 아니라 provider scope 실수로 인한 stale state다. 특히 showDialog, showModalBottomSheet, rootNavigator: true 같은 경로는 ProviderScope override 트리를 벗어나 예상과 다른 인스턴스를 읽는다.
  • Riverpod codegen(@riverpod + build_runner)은 협업 마찰을 만든다. .g.dart 파일이 diff 노이즈를 늘리고, 신규 입사자는 빌드가 빨간 상태에서 시작하는 경우가 많다.
  • Bloc의 진짜 위험은 이벤트 클래스 증식(event explosion)이다. 이벤트가 도메인 언어처럼 쌓이다 보면, 신규 개발자가 기존 이벤트를 의미가 다른 곳에 재사용해 부작용이 얽힌다.
  • 팀 규모 1~3명 MVP는 Riverpod 쪽 마찰이 작고, 여러 스쿼드가 계약처럼 상태를 주고받아야 하는 대규모 조직이나 이벤트소싱이 필요한 금융/커머스 도메인은 Bloc의 명시적 구조가 유리하다.
  • 2026년 7월 기준 안정 버전은 riverpod 3.3.2, riverpod_generator 4.0.4, flutter_bloc 9.1.1, bloc_test 10.0.0, provider 6.1.5+1이다(pub.dev 확인).

왜 기능 비교표는 틀린 질문인가

Riverpod와 Bloc 모두 “테스트 가능”, “DI 지원”, “컴파일 타임 안전성”이라는 항목에서 좋은 점수를 받는다. 실제로 둘 다 프로덕션에서 잘 동작하는 라이브러리다. 문제는 이 비교표가 도입 시점의 학습 곡선만 보여주고, 팀이 커지고 코드베이스가 쌓였을 때 무엇이 마찰을 만드는지는 알려주지 않는다는 점이다. 실제로 이슈 트래커와 커뮤니티에서 반복 보고되는 통증은 딱 두 가지 축으로 갈린다. Riverpod는 “스코프를 잘못 다뤄서 상태가 어긋난다”는 문제, Bloc은 “이벤트가 늘어나면서 팀이 이벤트의 의미를 통제하지 못한다”는 문제다.

Riverpod가 프로덕션에서 실제로 터지는 지점

provider scope 실수로 인한 stale state

Riverpod의 스코프 모델은 강력하지만 직관을 배신하는 지점이 있다. 흔한 실수 패턴부터 보자.

// 주문 상세 화면에서만 유효한 orderId를 스코프에 주입
class OrderDetailPage extends ConsumerWidget {
  const OrderDetailPage({required this.orderId, super.key});
  final String orderId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ProviderScope(
      overrides: [
        currentOrderIdProvider.overrideWithValue(orderId),
      ],
      child: const _OrderDetailBody(),
    );
  }
}

여기까지는 정상이다. 문제는 _OrderDetailBody 내부에서 showDialog(context: context, ...)showModalBottomSheet를 호출할 때 생긴다. 이 위젯들은 대부분 루트 Navigator의 Overlay에 붙는다.ProviderScope 서브트리 바깥으로 렌더링되어, 다이얼로그 안에서 ref.watch(currentOrderIdProvider)를 호출하면 override가 적용되지 않은 전역 기본값(또는 이전 화면에서 남은 값) 을 읽는다. 화면은 A 주문을 보고 있는데 다이얼로그는 B 주문 데이터를 보여주는, 재현하기 매우 까다로운 버그가 이렇게 생긴다.

두 번째 흔한 원인은 autoDispose의 오용이다. 튜토리얼을 따라 습관적으로 모든 provider에 .autoDispose를 붙이면, 화면 전환 중 마지막 리스너가 사라지는 순간 provider가 즉시 폐기된다. 사용자 세션처럼 화면 여러 개에 걸쳐 살아있어야 하는 상태까지 autoDispose가 걸리면, 뒤로 갔다 오는 순간 로그인 상태나 장바구니 내용이 리셋된 것처럼 보인다. 실제로는 “오래된 값”이 아니라 “재생성된 초기값”인데, 사용자 입장에서는 둘 다 stale state로 느껴진다.

대응 원칙은 명확하다.

build_runner가 만드는 협업 마찰과 노이즈 diff

riverpod_generator를 쓰면 @riverpod 어노테이션이 붙은 클래스/함수마다 part 'x.g.dart';가 필요하고, 코드를 저장할 때마다 dart run build_runner watch --delete-conflicting-outputs가 백그라운드에서 돌아야 IDE가 빨간 줄 없이 유지된다. 이게 팀 단위에서 만드는 마찰은 세 가지다.

완화 방법은 있다. codegen 없이 순수 Provider/NotifierProvider/StateNotifierProvider 문법으로도 Riverpod를 충분히 쓸 수 있다. 팀이 codegen의 편의(자동 .family/.autoDispose 추론, 보일러플레이트 감소)를 얻을 만큼 성숙하지 않았다면, 초기에는 non-codegen 스타일로 시작하고 컨벤션이 자리 잡은 뒤 전환하는 것이 협업 마찰을 줄이는 현실적인 선택이다.

Bloc의 흔한 함정: 이벤트 클래스 증식(event explosion)

Bloc은 “이벤트 → Bloc → 상태”라는 명시적 흐름을 강제한다. 이 명시성은 초기에는 장점이지만, 기능이 늘어날수록 이벤트 클래스가 기하급수적으로 불어난다.

abstract class CartEvent extends Equatable {
  const CartEvent();
  @override
  List<Object?> get props => [];
}

class CartItemAdded extends CartEvent { /* ... */ }
class CartItemAddedFromRecommendation extends CartEvent { /* ... */ } // 분석 필드만 다름
class CartItemQuantityIncreased extends CartEvent { /* ... */ }
class CartItemQuantityDecreased extends CartEvent { /* ... */ }
class CartItemQuantityChangedManually extends CartEvent { /* ... */ } // 디바운스 타이머 트리거
class CartCouponApplied extends CartEvent { /* ... */ }
class CartCouponAppliedFromDeepLink extends CartEvent { /* ... */ } // 부수효과가 다름

여기서 실제로 벌어지는 사고는 이렇다. 신규 개발자가 “장바구니 전체 수량 일괄 변경” 기능을 추가하면서, 기존 CartItemQuantityChangedManually 핸들러를 재사용한다. 이름만 보면 “수량을 바꾸는 이벤트”라 문제없어 보이지만, 실제 핸들러 내부에는 수동 입력 시에만 필요한 디바운스 타이머와 분석 로깅이 딸려 있었다. 그 결과 일괄 변경 시 아이템 개수만큼 디바운스 타이머가 동시에 생성되고, 분석 서버에는 사용자가 하지도 않은 “수동 수정” 이벤트가 N번 잡힌다. 이벤트 이름이 도메인 언어처럼 보이지만 실제로는 핸들러에 우발적으로 결합된 부수효과를 감추고 있었던 것이다.

이 문제는 코드 품질이 낮아서가 아니라 이벤트 네이밍과 핸들러 책임이 분리되지 않아서 생긴다. 팀이 취할 수 있는 실질적 완화책은 다음과 같다.

팀 규모·도메인별 선택 기준표

팀/도메인 추천 이유
1~3인 스타트업 MVP Riverpod(non-codegen) Event/State/Bloc 삼종 세트를 기능마다 만드는 보일러플레이트 없이 빠르게 피벗 가능. codegen은 나중에 붙여도 늦지 않음
4~15인 성장기 팀 컨벤션 문서화 여력에 좌우 Bloc의 명시적 계약이 온보딩에 유리하지만, 이벤트 거버넌스(재사용 금지 규칙)를 문서화·리뷰로 강제할 여력이 있어야 함. 여력이 부족하면 Riverpod + Notifier 패턴이 유지비가 낮음
대규모 엔터프라이즈(여러 스쿼드) Bloc Event/State 클래스가 스쿼드 간 계약처럼 기능하고, PR 리뷰에서 인터페이스 변경이 명확히 드러남. Riverpod의 암묵적 의존성 그래프는 린트로 강제하지 않으면 스쿼드 간 결합이 숨겨지기 쉬움
이벤트소싱이 필요한 금융/커머스 Bloc 도메인 이벤트가 곧 Bloc 이벤트와 자연스럽게 대응하고, 이벤트 스트림을 그대로 이벤트 스토어에 append하거나 감사 로그로 재사용 가능. Riverpod는 상태 중심 패러다임이라 이벤트 로그를 별도로 얹어야 함

이 표는 “이 규모면 무조건 이 라이브러리”가 아니라, 어느 쪽 마찰을 감당할 여력이 있는가를 묻는 도구로 써야 한다. 예를 들어 대규모 조직이라도 사내에 강력한 린트/아키텍처 컨벤션 팀이 있어 Riverpod의 스코프 오용을 정적 분석으로 잡아낼 수 있다면 Riverpod를 밀어붙이는 것도 합리적이다.

실제 마이그레이션 사례: Provider → Riverpod 전환

provider 패키지(현재 6.1.5+1)로 시작한 앱을 Riverpod로 옮기는 흐름은 대체로 다음 순서를 따른다.

  1. 루트 래핑 교체: runApp(MultiProvider(providers: [...], child: MyApp()))runApp(ProviderScope(child: MyApp()))로 바꾼다. 이 시점에서는 기존 ChangeNotifierProvider를 리버파드의 호환 계층으로 감싸 병행 운용하는 방식이 리스크가 작다.
  2. 위젯 타입 전환: context.watch<T>() / context.read<T>() 호출부를 ref.watch(xProvider) / ref.read(xProvider)로 바꾸려면 StatelessWidget/StatefulWidgetConsumerWidget/ConsumerStatefulWidget으로 전환해야 한다. 기계적이지만 diff가 크므로, 기능 단위로 쪼개 리뷰 가능한 크기로 PR을 나누는 것이 실무에서 통한다.
  3. 스코프 재설계: MultiProvider의 중첩 구조(탭별, 리스트 아이템별 스코프)는 Riverpod의 .family + .autoDispose 조합으로 다시 설계해야 한다. 여기서 앞서 설명한 autoDispose 오남용으로 인한 stale state 문제가 실제로 가장 많이 보고되는 이슈다. 튜토리얼을 그대로 따라 하다 세션 상태까지 autoDispose를 걸어 로그인 상태가 리셋되는 사고가 흔하다.
  4. 테스트 재작성: ChangeNotifierProvider<T>.value로 위젯을 감싸던 테스트는 ProviderScope(overrides: [...])로 다시 감싸야 한다. 위젯 테스트가 수백 개 규모인 앱이라면, 이 부분이 마이그레이션 diff에서 가장 많은 줄 수를 차지한다. 공용 testProviderScope() 헬퍼를 미리 만들어 두면 반복 작업을 줄일 수 있다.
  5. 점진 전환: 빅뱅 리라이트보다 화면 단위로 하나씩, 공용 루트 ProviderScope 아래에서 신규 화면은 Riverpod로, 미전환 화면은 기존 provider로 당분간 공존시키는 방식이 실무에서 리스크를 관리하기 쉽다. 두 라이브러리가 같은 위젯 트리 안에서 공존해도 충돌하지 않으므로 강제로 한 번에 끝낼 필요는 없다.

테스트 용이성 비교: provider override vs bloc_test

Riverpod는 ProviderContaineroverrides를 넘기는 방식으로 의존성을 교체한다.

test('주문 목록 로드 실패 시 error 상태를 노출한다', () async {
  final container = ProviderContainer(
    overrides: [
      orderRepositoryProvider.overrideWithValue(
        FakeOrderRepository()..throwsOnFetch = true,
      ),
    ],
  );
  addTearDown(container.dispose);

  await expectLater(
    container.read(orderListProvider.future),
    throwsA(isException),
  );
});

Bloc은 bloc_test 패키지의 blocTest로 이벤트-상태 시퀀스를 선언적으로 검증한다.

blocTest<OrderBloc, OrderState>(
  'fetch 실패 시 OrderError를 emit한다',
  build: () {
    when(() => repository.fetchOrders()).thenThrow(Exception('network'));
    return OrderBloc(repository: repository);
  },
  act: (bloc) => bloc.add(OrderFetched()),
  expect: () => [OrderLoading(), isA<OrderError>()],
);

두 접근의 실무 차이는 다음과 같다.

결론적으로 Riverpod는 테스트에서도 스코프 개념을 그대로 가져오기 때문에 프로덕션에서 겪는 스코프 실수를 테스트에서도 똑같이 겪을 수 있다는 점을, Bloc은 이벤트 시퀀스 검증은 쉽지만 의존성 주입 인프라를 별도로 갖춰야 한다는 점을 감안해야 한다.

결론: 코드베이스 규모와 팀 성숙도 기준 의사결정 체크리스트

아래 항목에 “예”가 많을수록 해당 라이브러리 쪽 마찰을 감당할 준비가 되어 있다는 뜻이다.

Riverpod가 맞을 가능성이 높은 경우

Bloc이 맞을 가능성이 높은 경우

어느 쪽을 고르든, 라이브러리 선택보다 그 라이브러리가 강제하지 않는 규율을 팀이 스스로 만들 수 있는가가 실제 프로덕션 성패를 가른다. Riverpod는 스코프 규율을, Bloc은 이벤트 거버넌스 규율을 팀이 직접 세워야 한다. 이 규율을 세울 여력과 의지가 있는 쪽을 기준으로 고르는 것이, 기능 비교표보다 훨씬 정확한 의사결정 방법이다.