[Flutter Riverpod] 1편. Provider 종류와 기본 사용법
Riverpod Provider 종류, ProviderScope, ConsumerWidget, NotifierProvider, FutureProvider, StreamProvider 기본기를 실무 구조로 정리합니다.
- 1편. Provider 종류와 기본 사용법
- 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기
- 3편. ref.watch / ref.read / ref.listen 차이 완전 정리
- 4편. family 패턴으로 매개변수 기반 Provider 만들기
- 5편. invalidate 전략과 Provider 의존성 체이닝
ProviderScope
└─ Provider / NotifierProvider / FutureProvider / StreamProvider
└─ ConsumerWidget에서 ref.watch로 사용
시리즈 전체 목차
1편: Provider 종류와 기본 사용법 ← 현재 글
2편: AsyncNotifier — 비동기 상태의 기준점
3편: ref.watch / ref.read / ref.listen — 세 가지 차이를 제대로 알자
4편: family 패턴 — 같은 Provider, 다른 데이터
5편: invalidate 전략과 Provider 의존성 체이닝
1. 소개
Flutter 앱이 조금만 커지면 상태 관리 문제가 바로 터져요. setState는 금방 한계가 오고, 여러 화면에서 같은 데이터를 써야 할 때 어떻게 전달할지 막막해지죠.
Provider 패키지를 써보셨다면 알 텐데, BuildContext에 강하게 의존해서 위젯 트리 밖에서 접근하기가 불편하고 테스트 짜기도 귀찮아요.
Riverpod은 그 불편함들을 정면으로 해결해요.
- BuildContext 없이도 Provider에 접근 가능
- 컴파일 타임에 의존성 오류 검출
- 동일한 Provider를 여러 화면에서 재사용
- 테스트에서 Provider를 쉽게 오버라이드
- 자동 dispose — 화면이 닫히면 상태도 정리
이 시리즈는 flutter_riverpod: ^2.5.1 기준으로 작성했어요. 1.x 문서나 예전 자료를 보고 계셨다면 API가 많이 바뀌었으니 이 시리즈로 환승을 추천해요.
2. 기본 사용 방법
설치
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.1
코드 생성을 쓸 계획이 있다면 이것도 함께 추가해요.
dependencies:
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.3
build_runner: ^2.4.11
ProviderScope 설정
앱 최상단에 ProviderScope를 감싸줘야 해요. 모든 Provider의 인스턴스가 여기 저장돼요.
void main() {
runApp(const ProviderScope(child: MyApp()));
}
비동기 초기화(Firebase, DB 등)가 있다면 ProviderScope 전에 처리해요.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await AppDatabase.instance.initialize();
runApp(const ProviderScope(child: MyApp()));
}
ProviderScope는 앱 전체에서 딱 한 번만 써요. 테스트에서 Provider를 오버라이드할 때도 ProviderScope의 overrides 파라미터를 사용해요.
ConsumerWidget vs ConsumerStatefulWidget
Riverpod에서 ref를 쓰려면 일반 위젯 대신 Consumer 계열을 써야 해요.
ConsumerWidget — 로컬 상태가 필요 없을 때
class ProductCard extends ConsumerWidget {
final String productId;
const ProductCard({super.key, required this.productId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref로 Provider에 접근
final product = ref.watch(productProvider(productId));
return Text(product.name);
}
}
ConsumerStatefulWidget — initState, dispose가 필요하거나 로컬 상태를 함께 쓸 때
class ProductListScreen extends ConsumerStatefulWidget {
const ProductListScreen({super.key});
@override
ConsumerState<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
// initState에서도 ref를 쓸 수 있어요
// 단, ref.watch는 build()에서만 — 여기선 ref.read만
ref.read(analyticsProvider).logScreenView('product_list');
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final products = ref.watch(productsProvider);
// ...
}
}
3. Provider 종류별 사용 방법
Riverpod의 Provider는 용도에 따라 여러 종류가 있어요. 어떤 걸 써야 할지 헷갈린다면 아래 표를 참고해요.
| Provider | 용도 | 특징 |
|---|---|---|
Provider |
불변 값, 서비스 객체 | 가장 기본, 한 번 만들면 유지 |
NotifierProvider |
동기 가변 상태 | 명시적 메서드 기반 상태 관리 |
FutureProvider |
비동기 데이터 페칭 | 로딩/에러 상태 자동 관리 |
StreamProvider |
실시간 데이터 스트림 | Firebase 같은 경우 |
AsyncNotifierProvider |
복잡한 비동기 상태 + 메서드 | 2편에서 자세히 다룸 |
Provider — 서비스 싱글톤
값이 변하지 않는 서비스나 Repository 객체를 만들 때 써요. 다른 Provider를 의존할 때는 ref.watch()로 가져와요.
// 외부 서비스
final apiServiceProvider = Provider<ApiService>((ref) {
return ApiService(baseUrl: 'https://api.example.com');
});
// apiServiceProvider에 의존하는 Repository
final productRepositoryProvider = Provider<ProductRepository>((ref) {
final api = ref.watch(apiServiceProvider);
return ProductRepository(api);
});
ref.watch(apiServiceProvider)로 의존하면, apiServiceProvider가 바뀔 때 productRepositoryProvider도 자동으로 재생성돼요. 물론 Provider는 값이 바뀌지 않으니까 이 경우엔 거의 일어나지 않지만, 패턴을 일관되게 쓰는 게 좋아요.
NotifierProvider — 동기 상태 관리
class SelectedCategoryNotifier extends Notifier<String?> {
@override
String? build() => null;
void select(String id) => state = id;
}
final selectedCategoryIdProvider =
NotifierProvider<SelectedCategoryNotifier, String?>(
SelectedCategoryNotifier.new,
);
enum SortOrder { newest, priceAsc, priceDesc }
class SortOrderNotifier extends Notifier<SortOrder> {
@override
SortOrder build() => SortOrder.newest;
void change(SortOrder order) => state = order;
void reset() => state = SortOrder.newest;
}
final sortOrderProvider =
NotifierProvider<SortOrderNotifier, SortOrder>(
SortOrderNotifier.new,
);
UI에서는 ref.watch()로 읽고, .notifier의 메서드로 상태를 변경해요.
final selectedId = ref.watch(selectedCategoryIdProvider);
ref.read(selectedCategoryIdProvider.notifier)
.select('electronics');
단순한 값 하나라도 변경 의도가 드러나도록 메서드로 감싸두면, 화면 코드가 상태 변경 규칙을 직접 알 필요가 없어져요.
FutureProvider — 비동기 데이터 페칭
API 호출이나 DB 쿼리처럼 비동기로 데이터를 가져올 때 써요. Riverpod이 로딩/에러 상태를 자동으로 관리해줘요.
final categoriesProvider = FutureProvider<List<Category>>((ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.fetchCategories();
});
final productsProvider = FutureProvider<List<Product>>((ref) async {
final repo = ref.watch(productRepositoryProvider);
final sortOrder = ref.watch(sortOrderProvider); // 정렬 순서가 바뀌면 재실행
return repo.fetchAll(sortBy: sortOrder);
});
sortOrderProvider처럼 ref.watch()로 의존을 걸어두면, 의존하는 값이 바뀔 때 FutureProvider가 자동으로 다시 실행돼요.
UI에서는 when()으로 세 가지 상태를 처리해요.
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (_, i) => ProductTile(products[i]),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('오류가 발생했어요: $error')),
);
데이터만 필요하고 로딩/에러는 무시할 때는 valueOrNull을 써요.
final products = ref.watch(productsProvider).valueOrNull ?? [];
StreamProvider — 실시간 스트림
Firebase Auth 상태나 Firestore 실시간 업데이트처럼 값이 외부에서 계속 들어올 때 써요.
// 로그인 상태 스트림
final authStateProvider = StreamProvider<User?>((ref) {
return FirebaseAuth.instance.authStateChanges();
});
// 현재 로그인 유저만 뽑아쓸 때
final currentUserProvider = Provider<User?>((ref) {
return ref.watch(authStateProvider).valueOrNull;
});
// 장바구니 실시간 업데이트
final cartStreamProvider = StreamProvider<Cart>((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) return Stream.value(Cart.empty());
return FirebaseFirestore.instance
.collection('carts')
.doc(user.uid)
.snapshots()
.map(Cart.fromSnapshot);
});
currentUserProvider처럼 valueOrNull로 현재 값을 동기적으로 꺼내쓰는 패턴은 실무에서 자주 써요.
4. 실무 적용 방법 — 쇼핑몰 앱 전체 예제
카테고리별 상품 목록 화면을 만드는 예제예요.
전체 코드 보기
// ---- providers/api_providers.dart ----
final apiServiceProvider = Provider<ApiService>((ref) {
return ApiService(baseUrl: 'https://api.shop.example.com');
});
final productRepositoryProvider = Provider<ProductRepository>((ref) {
final api = ref.watch(apiServiceProvider);
return ProductRepository(api);
});
// ---- providers/product_providers.dart ----
// 로그인 상태
final authStateProvider = StreamProvider<User?>((ref) {
return FirebaseAuth.instance.authStateChanges();
});
final currentUserProvider = Provider<User?>((ref) {
return ref.watch(authStateProvider).valueOrNull;
});
// 카테고리 목록
final categoriesProvider = FutureProvider<List<Category>>((ref) {
return ref.watch(productRepositoryProvider).fetchCategories();
});
// 선택된 카테고리
class SelectedCategoryNotifier extends Notifier<String?> {
@override
String? build() => null;
void select(String id) => state = id;
void clear() => state = null;
}
final selectedCategoryIdProvider =
NotifierProvider<SelectedCategoryNotifier, String?>(
SelectedCategoryNotifier.new,
);
// 정렬 순서
enum SortOrder { newest, priceAsc, priceDesc }
class SortOrderNotifier extends Notifier<SortOrder> {
@override
SortOrder build() => SortOrder.newest;
void change(SortOrder order) => state = order;
void reset() => state = SortOrder.newest;
}
final sortOrderProvider =
NotifierProvider<SortOrderNotifier, SortOrder>(
SortOrderNotifier.new,
);
// 선택된 카테고리의 상품 (family 패턴 — 4편에서 자세히)
final productsByCategoryProvider =
FutureProvider.family<List<Product>, String>((ref, categoryId) {
final repo = ref.watch(productRepositoryProvider);
final sort = ref.watch(sortOrderProvider);
return repo.fetchByCategory(categoryId, sortBy: sort);
});
// ---- screens/product_list_screen.dart ----
class ProductListScreen extends ConsumerWidget {
const ProductListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedId = ref.watch(selectedCategoryIdProvider);
final categoriesAsync = ref.watch(categoriesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('쇼핑몰'),
actions: [
PopupMenuButton<SortOrder>(
onSelected: (order) => ref
.read(sortOrderProvider.notifier)
.change(order),
itemBuilder: (_) => const [
PopupMenuItem(
value: SortOrder.newest,
child: Text('최신순'),
),
PopupMenuItem(
value: SortOrder.priceAsc,
child: Text('가격 낮은 순'),
),
PopupMenuItem(
value: SortOrder.priceDesc,
child: Text('가격 높은 순'),
),
],
),
],
),
body: Column(
children: [
categoriesAsync.when(
data: (categories) => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: categories.map((category) {
return ChoiceChip(
label: Text(category.name),
selected: category.id == selectedId,
onSelected: (_) => ref
.read(selectedCategoryIdProvider.notifier)
.select(category.id),
);
}).toList(),
),
),
loading: () => const LinearProgressIndicator(),
error: (_, __) => const SizedBox.shrink(),
),
Expanded(
child: selectedId == null
? const Center(child: Text('카테고리를 선택해주세요'))
: ref.watch(productsByCategoryProvider(selectedId)).when(
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (_, index) => ProductTile(products[index]),
),
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('오류: $error')),
),
),
],
),
);
}
}
5. 주의사항
ref.watch를 이벤트 핸들러 안에서 쓰면 안 돼요
이게 초반에 가장 많이 하는 실수예요. ref.watch()는 "이 값이 바뀔 때 나를 다시 실행해줘"라는 의미예요. 그런데 버튼 콜백 안에서 쓰면 Riverpod이 이걸 추적할 방법이 없고, 경고 없이 이상하게 동작해요.
// 잘못된 예
ElevatedButton(
onPressed: () {
// ❌ 이벤트 핸들러 안에서 ref.watch 금지
final repo = ref.watch(productRepositoryProvider);
repo.deleteProduct(id);
},
child: const Text('삭제'),
)
// 올바른 예
ElevatedButton(
onPressed: () {
// ✅ 이벤트 핸들러에서는 ref.read
final repo = ref.read(productRepositoryProvider);
repo.deleteProduct(id);
},
child: const Text('삭제'),
)
FutureProvider는 자동으로 캐시돼요
ref.watch(productsProvider)를 여러 위젯에서 써도 API는 한 번만 호출돼요. Riverpod이 결과를 캐시하기 때문이에요. 그런데 데이터가 실제로 바뀌었을 때는 어떻게 할까요?
// 강제로 다시 실행시키기
ref.invalidate(productsProvider);
// 또는 즉시 실행하고 새 값 받기
await ref.refresh(productsProvider.future);
이 패턴은 5편 invalidate 전략에서 자세히 다뤄요.
autoDispose를 써야 하는 경우
기본 Provider는 앱 생명주기 내내 살아있어요. 화면별로 임시 상태라면 autoDispose를 붙여서 화면이 닫힐 때 자동으로 정리되게 해야 해요.
// 검색 결과 — 검색 화면을 떠나면 자동 정리
final searchResultsProvider =
FutureProvider.autoDispose.family<List<Product>, String>((ref, query) {
return ref.watch(productRepositoryProvider).search(query);
});
// 상품 상세 — 상세 화면을 닫으면 자동 정리
final productDetailProvider =
FutureProvider.autoDispose.family<Product, String>((ref, id) {
return ref.watch(productRepositoryProvider).findById(id);
});
6. 성능 / 구조 팁
Provider 계층을 명확하게 나눠요
서비스 레이어 → ApiService, FirebaseService, DatabaseService
Repository 레이어 → ProductRepository, UserRepository
상태 레이어 → Notifier, AsyncNotifier, FutureProvider
UI 레이어 → ConsumerWidget, ref.watch
이렇게 나눠두면 테스트에서 Repository만 모킹하거나, 서비스만 교체하기 쉬워요.
// 테스트에서 Repository를 Mock으로 교체
await tester.pumpWidget(
ProviderScope(
overrides: [
productRepositoryProvider.overrideWithValue(MockProductRepository()),
],
child: const ProductListScreen(),
),
);
select()로 불필요한 리빌드 줄이기
장바구니에 10개 항목이 있을 때 개수만 표시하는 위젯이라면, 장바구니 전체가 바뀔 때마다 리빌드할 필요가 없어요.
// 장바구니 전체를 감시 — 하나만 바뀌어도 리빌드
final cart = ref.watch(cartProvider);
Text('${cart.items.length}개');
// select()로 개수만 감시 — 개수가 바뀔 때만 리빌드
final count = ref.watch(cartProvider.select((c) => c.items.length));
Text('$count개');
7. 정리
ProviderScope— 앱 최상단에 딱 한 번, 모든 Provider의 저장소Provider— 불변 서비스, 싱글톤 객체NotifierProvider— 동기 상태와 명시적 메서드 관리FutureProvider— 비동기 페칭 + 캐싱,when()으로 상태 처리StreamProvider— 실시간 스트림,.valueOrNull로 현재 값 추출ConsumerWidget/ConsumerStatefulWidget—ref를 쓰려면 이걸로ref.watch()— build() 안에서만, 이벤트 핸들러엔ref.read()autoDispose— 화면 단위 임시 상태는 붙여주기
다음 편 예고: 단순 데이터 페칭을 넘어 생성/수정/삭제 메서드까지 포함된 완전한 상태 객체가 필요할 때 어떻게 할까요? 2편에서 AsyncNotifier를 다뤄요. build()가 어떻게 동작하는지, invalidateSelf()로 상태를 갱신하는 방법, state = AsyncValue.data(...)로 낙관적 업데이트 하는 법까지 실무 예제로 설명할게요.
'Mobile > Flutter' 카테고리의 다른 글
| [Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기 (0) | 2026.06.14 |
|---|