Flutter 상태관리 라이브러리 뭐 쓸지 고민하는 시간이 실제 코딩 시간보다 길었다. Provider? Bloc? Riverpod? GetX? 구글링하면 '이게 최고!'라는 글이 수십 개인데 전부 다른 걸 추천한다. 결국 우리 팀 3명이 각각 다른 라이브러리로 같은 화면을 만들어보는 실험을 했다. 그 결과를 공유한다.
상태관리가 중요한 이유
Flutter는 선언형 UI 프레임워크입니다. 상태가 변경되면 UI가 자동으로 다시 그려지는 구조입니다. 간단한 앱에서는 setState()만으로 충분하지만, 앱이 복잡해지면 다음과 같은 문제가 발생합니다:
- Prop Drilling: 상위 위젯에서 하위 위젯으로 데이터를 계속 전달해야 함
- 불필요한 리빌드: 관련 없는 위젯까지 다시 그려지는 성능 문제
- 테스트 어려움: UI와 비즈니스 로직이 결합되어 테스트가 복잡
- 코드 유지보수: 상태 변경 흐름을 추적하기 어려움
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의 위젯 트리와 분리되어 디버깅이 어렵고, 매직이 많아 동작 원리를 파악하기 어렵습니다.
실무에서의 선택 기준
여러 프로젝트를 경험하면서 정리한 선택 기준입니다:
- 소규모 프로젝트 / 프로토타입: Provider 또는 GetX가 적합합니다. 빠르게 개발하고 검증할 수 있습니다.
- 중규모 프로젝트 (개인 또는 소규모 팀): Riverpod을 추천합니다. 타입 안전성과 유연성의 균형이 좋습니다.
- 대규모 프로젝트 (다수 팀원): BLoC이 적합합니다. 구조가 강제되어 코드 일관성을 유지하기 쉽습니다.
개인적으로는 Riverpod을 가장 선호합니다. BitRoom Factory 프로젝트에서도 Riverpod + Supabase 조합을 사용하고 있으며, 코드 생성을 통한 보일러플레이트 감소와 Provider 간 의존성 관리가 매우 편리합니다.
팀에서 상태관리 라이브러리를 선택한 과정
상태관리 라이브러리에 정답은 없습니다. 프로젝트의 규모, 팀의 숙련도, 개발 속도 요구사항에 따라 적절한 도구를 선택하는 것이 중요합니다. 어떤 라이브러리를 선택하든 비즈니스 로직과 UI를 분리하는 원칙만 지킨다면 유지보수 가능한 코드를 만들 수 있습니다.
10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.
GitHub →
💬 댓글