GetX vs Riverpod: 두 상태 관리 솔루션의 실전 비교

Flutter 앱 개발에서 GetX와 Riverpod 중 어떤 상태 관리를 선택해야 할까요? 미니 SNS 앱 개발 경험을 바탕으로 두 솔루션의 실질적인 차이점과 선택 기준을 제시합니다.
ImRieul's avatar
Aug 29, 2025
GetX vs Riverpod: 두 상태 관리 솔루션의 실전 비교
안녕하세요, 임리을입니다.
스타트업에서는 빠른 개발 속도와 향후 확장성 사이에서 균형을 잡아야 합니다. GetX처럼 간결한 솔루션과 Riverpod처럼 견고한 솔루션 중 무엇을 선택해야 할까요?
본 강의에서는 Riverpod을 채택했습니다. GetX처럼 간결하지는 않지만, 그만큼 기술 부채를 줄이고 테스트 코드 작성에 유리하기 때문입니다. 특히 커서 기반 페이징처럼 복잡한 상태 관리에서 그 차이가 명확히 드러납니다.
오늘은 제가 강의에서 다루는 내용을 바탕으로, 두 상태 관리 방식의 실질적인 차이점을 비교해보겠습니다.
 
 

Riverpod의 핵심 장점들


Riverpod은 여러 상태 관리 솔루션들 중에서도 특별한 장점들을 가지고 있습니다. 강의에서 제가 강조하는 5가지 핵심 포인트는 다음과 같습니다:
  • 컴파일 타임 타입 체크: 런타임 에러를 미리 방지
  • 독립적 테스트: Provider를 각각 독립적으로 테스트 가능
  • 의존성 관리: Provider들 사이의 의존성 관리가 명확
  • 메모리 효율성: 사용하지 않는 상태는 자동으로 해제
  • 디버깅 용이성: Provider Observer를 통한 상태 추적
GetX도 장점이 있지만, SNS 앱처럼 피드 목록, 좋아요 상태, 무한 스크롤 등이 복잡하게 얽히는 시나리오에서는 Riverpod의 이런 특징들이 빛을 발합니다.
 

Obx의 무한 리빌딩 문제

// 이런 코드가 초기에는 마법처럼 동작하다가 Obx(() => ListView.builder( itemCount: controller.feeds.length, itemBuilder: (context, index) { return FeedCard(feed: controller.feeds[index]); }, )); // 상태가 복잡해지니까 무한 리빌딩 시작 // 피드를 스크롤할 때마다 전체 리스트가 다시 그려짐
GetX의 반응형 상태 관리는 편리하지만, 복잡한 상태에서는 예측하기 어려운 리빌드 문제가 발생합니다. 여러 컨트롤러의 상태가 얽히면서 성능이 저하되는 현상이 나타나죠.
 

Riverpod으로 개선된 후

// 명시적으로 어떤 상태를 구독할지 정할 수 있음 ref.watch(feedProvider.select((state) => state.feeds)); // 액션은 명확하게 분리 ref.read(feedProvider.notifier).loadMore(); // 테스트도 예측 가능하게 final container = ProviderContainer(); await container.read(feedProvider.notifier).loadMore();
Riverpod의 핵심은 "명시성"입니다. ref.watchref.read를 통해 언제 어떤 위젯이 리빌드될지 명확하게 제어할 수 있습니다. 이는 확장성 있는 앱 개발에서 매우 중요한 요소입니다.
 
 

실제 페이징 상태 관리에서 느낀 Riverpod의 힘


미니 SNS 앱에서 구현한 커서 기반 페이징은 실제 프로덕션 환경에서 자주 사용되는 패턴입니다. 백엔드 API 구조는 이렇습니다:
GET /feeds?size=5&cursor=16 { "feeds": [...], "hasNext": true }
이런 페이징을 구현하려면 고려해야 할 요소가 많습니다:
  • 현재 로딩 중인지
  • 다음 페이지가 더 있는지
  • 마지막으로 받은 커서 값이 뭔지
  • 에러가 발생했는지
  • 기존 피드 목록에 새 데이터를 어떻게 추가할지
 

StateNotifierProvider로 깔끔하게 해결

@freezed class FeedState with _$FeedState { const factory FeedState({ @Default([]) List<Feed> feeds, @Default(false) bool isLoading, @Default(true) bool hasNext, String? lastCursor, String? error, }) = _FeedState; } class FeedNotifier extends StateNotifier<FeedState> { FeedNotifier(this._apiService) : super(const FeedState()); final ApiService _apiService; Future<void> loadMore() async { if (state.isLoading || !state.hasNext) return; state = state.copyWith(isLoading: true, error: null); try { final response = await _apiService.getFeeds( cursor: state.lastCursor, size: 5, ); state = state.copyWith( feeds: [...state.feeds, ...response.feeds], hasNext: response.hasNext, lastCursor: response.feeds.last.id.toString(), isLoading: false, ); } catch (e) { state = state.copyWith( isLoading: false, error: e.toString(), ); } } Future<void> refresh() async { state = const FeedState(); // 초기화 await loadMore(); } } final feedProvider = StateNotifierProvider<FeedNotifier, FeedState>((ref) { return FeedNotifier(ref.watch(apiServiceProvider)); });
 

UI에서 사용할 때도 직관적

class FeedList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final feedState = ref.watch(feedProvider); return RefreshIndicator( onRefresh: () => ref.read(feedProvider.notifier).refresh(), child: ListView.builder( controller: _scrollController, itemCount: feedState.feeds.length + (feedState.hasNext ? 1 : 0), itemBuilder: (context, index) { if (index == feedState.feeds.length) { // 로딩 인디케이터 표시 return const CircularProgressIndicator(); } return FeedCard(feed: feedState.feeds[index]); }, ), ); } }
스크롤 리스너도 이렇게 깔끔하게:
void _onScroll() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent * 0.8) { ref.read(feedProvider.notifier).loadMore(); } }
 
 

에러 처리도 한 곳에서


Riverpod의 또 다른 강점은 체계적인 에러 처리입니다:
class AppWrapper extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { ref.listen<FeedState>(feedProvider, (previous, next) { if (next.error != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(next.error!)), ); } }); return MaterialApp(home: HomePage()); } }
ref.listen을 활용하면 상태 변화를 감지하고 그에 따른 사이드 이펙트를 처리할 수 있습니다. 이는 GetX의 Get.snackbar()보다 더 체계적이고 테스트하기 쉬운 방법입니다.
 
 

의존성 주입도 자연스럽게


Riverpod은 의존성 주입을 Provider를 통해 자연스럽게 해결합니다:
final apiServiceProvider = Provider((ref) => ApiService()); final feedProvider = StateNotifierProvider<FeedNotifier, FeedState>((ref) { return FeedNotifier(ref.watch(apiServiceProvider)); }); final userProvider = StateNotifierProvider<UserNotifier, UserState>((ref) { return UserNotifier(ref.watch(apiServiceProvider)); });
Provider 간 의존성을 컴파일 타임에 체크할 수 있어서, 런타임 에러를 사전에 방지할 수 있습니다.
 
 

테스트에서 진짜 빛났던 Riverpod


test('피드 로딩 테스트', () async { final container = ProviderContainer( overrides: [ apiServiceProvider.overrideWithValue(MockApiService()), ], ); final notifier = container.read(feedProvider.notifier); await notifier.loadMore(); final state = container.read(feedProvider); expect(state.feeds.length, 5); expect(state.hasNext, true); });
Riverpod의 테스트 용이성은 정말 뛰어납니다. Mock 객체 주입이 쉽고, Context 없이 테스트할 수 있어서 단위 테스트 작성이 매우 간편합니다.
 
 

마무리: GetX vs Riverpod, 언제 무엇을 선택할까?


GetX와 Riverpod 모두 장단점이 있습니다. 간단한 앱이나 빠른 프로토타이핑에는 GetX가 적합할 수 있습니다. 하지만 다음과 같은 상황에서는 Riverpod이 더 나은 선택입니다:
  • 여러 상태가 얽히고
  • 비동기 처리가 많아지고
  • 에러 처리가 복잡해지고
  • 테스트 코드를 써야 하고
이런 상황에서 Riverpod의 타입 안정성, 테스트 용이성, 명시적인 상태 관리가 큰 도움이 됩니다.
본 강의에서는 Clean Architecture와 함께 Riverpod을 활용하여, 초기 복잡도를 낮추면서도 향후 확장성을 확보하는 방법을 다룹니다. 미니 SNS 앱을 통해 실제 프로덕션 환경에서 마주치는 다양한 상태 관리 시나리오를 경험해보실 수 있습니다.
감사합니다.
Share article

Rieul's Index