
안녕하세요, 임리을입니다.
스타트업에서는 빠른 개발 속도와 향후 확장성 사이에서 균형을 잡아야 합니다. 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.watch
와 ref.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