[Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 Provider 만들기
카테고리 ID, 상품 ID처럼 매개변수별로 독립 캐시가 필요한 상황에서 family 패턴을 실무적으로 사용하는 방법을 설명합니다.
- 1편. Provider 종류와 기본 사용법
- 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기
- 3편. ref.watch / ref.read / ref.listen 차이 완전 정리
- 4편. family 패턴으로 매개변수 기반 Provider 만들기
- 5편. invalidate 전략과 Provider 의존성 체이닝
family parameter ├─ categoryId = electronics → Provider A └─ categoryId = fashion → Provider B
시리즈 전체 목차
1편: Provider 종류와 기본 사용법 2편: AsyncNotifier — 비동기 상태의 기준점 3편: ref.watch / ref.read / ref.listen — 세 가지 차이를 제대로 알자 4편: family 패턴 — 같은 Provider, 다른 데이터 ← 현재 글 5편: invalidate 전략과 Provider 의존성 체이닝
1. 소개
앱을 만들다 보면 이런 상황이 생겨요.
"카테고리마다 상품 목록을 따로 관리하고 싶은데, Provider를 카테고리 수만큼 만들어야 하나?"
그럼 이 질문이 떠오르죠.
"카테고리 ID를 받아서 해당 카테고리 상품만 가져오는 Provider를 만들 수 있을까?"
family가 딱 그 역할이에요. 매개변수를 받아서 그 값별로 독립적인 Provider 인스턴스를 만들어줘요.
// 이렇게 쓸 수 있어요
final productsAsync = ref.watch(productsByCategoryProvider('electronics'));
final reviewsAsync = ref.watch(reviewsByProductProvider('product-123'));
같은 매개변수로 호출하면 캐시된 인스턴스를 재사용해요. 다른 매개변수면 별도 인스턴스가 생겨요.
이 편은 flutter_riverpod: ^2.5.1 기준으로 작성했어요.
2. 기본 사용 방법
FutureProvider.family
// 일반 FutureProvider — 매개변수 없음
final allProductsProvider = FutureProvider<List<Product>>((ref) {
return ref.read(productRepositoryProvider).fetchAll();
});
// FutureProvider.family — 매개변수 포함
final productsByCategoryProvider =
FutureProvider.family<List<Product>, String>((ref, categoryId) {
return ref.read(productRepositoryProvider).fetchByCategory(categoryId);
});
타입 파라미터는 <반환값 타입, 매개변수 타입> 순서예요.
UI에서 사용할 때는 Provider 이름에 괄호로 매개변수를 전달해요.
// 'electronics' 카테고리 상품 목록
final productsAsync = ref.watch(productsByCategoryProvider('electronics'));
// 'fashion' 카테고리 상품 목록 — 별도 인스턴스
final fashionAsync = ref.watch(productsByCategoryProvider('fashion'));
AsyncNotifierProvider.family
액션 메서드까지 포함한 경우엔 AsyncNotifierProvider.family를 써요.
전체 코드 보기
class CategoryProductsNotifier
extends FamilyAsyncNotifier<List<Product>, String> {
@override
Future<List<Product>> build(String categoryId) {
// arg == categoryId (초기에 전달된 매개변수)
return ref.read(productRepositoryProvider).fetchByCategory(arg);
}
Future<Product> addProduct({
required String name,
required int price,
}) async {
final product = Product(
id: const Uuid().v4(),
name: name,
price: price,
categoryId: arg, // 이 Notifier의 categoryId
createdAt: DateTime.now(),
);
await ref.read(productRepositoryProvider).insert(product);
ref.invalidateSelf();
return product;
}
Future<void> deleteProduct(String id) async {
await ref.read(productRepositoryProvider).delete(id);
ref.invalidateSelf();
}
}
final categoryProductsNotifierProvider = AsyncNotifierProvider.family<
CategoryProductsNotifier, List<Product>, String>(
CategoryProductsNotifier.new,
);FamilyAsyncNotifier의 arg로 초기 매개변수에 언제든지 접근할 수 있어요.
3. 실무 적용 방법 — 카테고리별 상품 관리 전체 예제
여러 카테고리를 탭으로 탐색하는 쇼핑 앱 예제예요.
전체 코드 보기
// ---- providers/product_providers.dart ----
// 카테고리별 상품 목록 (읽기 전용)
final productsByCategoryProvider =
FutureProvider.family<List<Product>, String>((ref, categoryId) {
final repo = ref.read(productRepositoryProvider);
final sort = ref.watch(sortOrderProvider); // 정렬 기준 변경 시 재실행
return repo.fetchByCategory(categoryId, sortBy: sort);
});
// 카테고리별 상품 관리 (CRUD 포함)
final categoryProductsNotifierProvider = AsyncNotifierProvider.family<
CategoryProductsNotifier, List<Product>, String>(
CategoryProductsNotifier.new,
);
// 상품 단건 조회 (autoDispose — 상세 화면 닫히면 정리)
final productDetailProvider =
FutureProvider.autoDispose.family<Product, String>((ref, productId) {
return ref.read(productRepositoryProvider).findById(productId);
});
// 상품 리뷰 (autoDispose + family)
final reviewsByProductProvider =
FutureProvider.autoDispose.family<List<Review>, String>((ref, productId) {
return ref.read(reviewRepositoryProvider).fetchByProduct(productId);
});
// ---- screens/category_screen.dart ----
class CategoryScreen extends ConsumerWidget {
final String categoryId;
const CategoryScreen({super.key, required this.categoryId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 이 화면의 categoryId로 해당 카테고리 상품만 감시
final productsAsync =
ref.watch(categoryProductsNotifierProvider(categoryId));
return Scaffold(
appBar: AppBar(title: Text('카테고리 상품')),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context, ref),
child: const Icon(Icons.add),
),
body: productsAsync.when(
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (_, i) => ProductTile(
product: products[i],
onDelete: () => ref
.read(categoryProductsNotifierProvider(categoryId).notifier)
.deleteProduct(products[i].id),
),
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('$e'),
),
);
}
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
final result = await showDialog<String>(
context: context,
builder: (_) => const AddProductDialog(),
);
if (result == null || !context.mounted) return;
await ref
.read(categoryProductsNotifierProvider(categoryId).notifier)
.addProduct(name: result, price: 0);
}
}
// ---- screens/product_detail_screen.dart ----
class ProductDetailScreen extends ConsumerWidget {
final String productId;
const ProductDetailScreen({super.key, required this.productId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productAsync = ref.watch(productDetailProvider(productId));
final reviewsAsync = ref.watch(reviewsByProductProvider(productId));
return Scaffold(
body: productAsync.when(
data: (product) => CustomScrollView(
slivers: [
SliverAppBar(title: Text(product.name)),
SliverToBoxAdapter(
child: Column(children: [
Text('가격: ${product.price}원'),
const Divider(),
const Text('리뷰', style: TextStyle(fontWeight: FontWeight.bold)),
]),
),
reviewsAsync.when(
data: (reviews) => SliverList(
delegate: SliverChildBuilderDelegate(
(_, i) => ReviewTile(reviews[i]),
childCount: reviews.length,
),
),
loading: () => const SliverToBoxAdapter(
child: CircularProgressIndicator(),
),
error: (e, _) => SliverToBoxAdapter(child: Text('$e')),
),
],
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('$e'),
),
);
}
}4. 주의사항
매개변수로 복잡한 타입을 쓸 때
family의 매개변수는 ==으로 비교돼요. 같은 값이면 캐시를 재사용해요. 그래서 매개변수 타입은 반드시 ==이 올바르게 동작해야 해요.
// ✅ 기본 타입은 항상 OK
FutureProvider.family<List<Product>, String>(...)
FutureProvider.family<List<Product>, int>(...)
FutureProvider.family<List<Product>, bool>(...)
// ✅ freezed 또는 equatable 클래스도 OK (== 오버라이드됨)
@freezed
class ProductFilter with _$ProductFilter {
const factory ProductFilter({
required String categoryId,
required SortOrder sort,
int? maxPrice,
}) = _ProductFilter;
}
final filteredProductsProvider =
FutureProvider.family<List<Product>, ProductFilter>((ref, filter) {
return ref.read(productRepositoryProvider).fetchFiltered(filter);
});
// 사용할 때
ref.watch(filteredProductsProvider(
const ProductFilter(categoryId: 'electronics', sort: SortOrder.newest),
));
// ❌ 일반 클래스는 위험 — == 오버라이드 안 하면 매번 새 인스턴스로 인식
class BadFilter {
final String categoryId;
BadFilter(this.categoryId);
// == 오버라이드 없음 → 참조 비교 → 같은 값이어도 다른 인스턴스로 취급
}
Map, List를 매개변수로 쓰면 안 돼요
Map이나 List는 Dart에서 기본적으로 참조 비교라서 같은 내용이어도 다른 인스턴스로 인식해요. 매번 새 Provider 인스턴스가 생겨서 캐시가 전혀 동작하지 않아요.
// ❌ 위험 — Map은 == 비교 안 됨
FutureProvider.family<List<Product>, Map<String, dynamic>>(...)
// ✅ 대신 freezed 클래스나 직렬화 가능한 레코드 사용
FutureProvider.family<List<Product>, ({String categoryId, String? tag})>(
(ref, params) {
return ref.read(repo).fetch(params.categoryId, tag: params.tag);
},
);
family와 autoDispose를 같이 쓸 때
화면 단위 임시 Provider라면 autoDispose도 붙여줘야 메모리가 정리돼요.
// autoDispose + family 조합
final productDetailProvider =
FutureProvider.autoDispose.family<Product, String>((ref, productId) {
ref.keepAlive(); // 특정 조건에서 dispose를 막으려면
return ref.read(productRepositoryProvider).findById(productId);
});
5. 성능 / 구조 팁
캐시 활용 — 같은 ID로 여러 번 접근해도 한 번만 요청해요
class ProductListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final categories = ref.watch(categoriesProvider).valueOrNull ?? [];
return ListView(
children: [
for (final cat in categories)
CategorySection(
category: cat,
// 각 카테고리별로 별도 캐시, 여러 위젯에서 같은 ID로 접근해도 API 1번
productsAsync: ref.watch(productsByCategoryProvider(cat.id)),
),
],
);
}
}
family Provider 무효화
특정 카테고리의 캐시만 무효화하고 싶을 때는 매개변수를 전달해요.
// 'electronics' 카테고리 캐시만 무효화
ref.invalidate(productsByCategoryProvider('electronics'));
// 전체 family 캐시 무효화 — 매개변수 없이 Provider 전체를 무효화
ref.invalidate(productsByCategoryProvider);
여러 매개변수가 필요할 때 — Record 타입 활용
Dart 3.0의 Record 타입을 쓰면 간단하게 여러 매개변수를 합칠 수 있어요. Record는 ==이 값 기반으로 동작해서 family에 적합해요.
// Record를 매개변수로 사용
final filteredProductsProvider =
FutureProvider.family<List<Product>, (String, SortOrder)>(
(ref, params) {
final (categoryId, sortOrder) = params;
return ref.read(productRepositoryProvider)
.fetchByCategory(categoryId, sortBy: sortOrder);
},
);
// 사용
ref.watch(filteredProductsProvider(('electronics', SortOrder.newest)));
6. 정리
FutureProvider.family<ReturnType, ParamType>— 매개변수별 독립 캐시AsyncNotifierProvider.family— CRUD 포함,FamilyAsyncNotifier상속arg— FamilyAsyncNotifier 안에서 초기 매개변수 접근- 같은 매개변수로 접근하면 캐시 재사용 → API/DB 중복 호출 방지
- 매개변수 타입은
==비교가 올바르게 동작해야 해요 (freezed, Record, 기본 타입) autoDispose + family— 화면 단위 임시 상태에서 자동 정리ref.invalidate(provider(arg))— 특정 매개변수만 무효화ref.invalidate(provider)— 전체 family 무효화
다음 편 예고: 지금까지 개별 Provider를 다뤘는데, 실제 앱에선 여러 Provider가 서로 의존하고 연쇄적으로 무효화되는 경우가 많아요. 5편에서는 invalidate 전략과 Provider 의존성 체이닝을 다뤄요. 동기화 완료 후 관련된 모든 Provider를 갱신하는 패턴, autoDispose와 ref.onDispose()로 리소스를 정리하는 방법, 그리고 순환 의존성을 피하는 방법까지 정리할게요.
'Mobile > Flutter' 카테고리의 다른 글
| [Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝 (0) | 2026.07.17 |
|---|---|
| [Flutter Riverpod] 3편. ref.watch / ref.read / ref.listen 차이 완전 정리 (0) | 2026.07.17 |
| [Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기 (0) | 2026.06.14 |
| [Flutter Riverpod] 1편. Provider 종류와 기본 사용법 (0) | 2026.06.08 |