Mobile/Flutter

[Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 Provider 만들기

Lior 2026. 7. 17. 17:09
Flutter Riverpod 실무 시리즈 · 4편

[Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 Provider 만들기

카테고리 ID, 상품 ID처럼 매개변수별로 독립 캐시가 필요한 상황에서 family 패턴을 실무적으로 사용하는 방법을 설명합니다.

버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x
FlutterDart 3.xflutter_riverpod ^2.5.1수동 Provider 정의 기준
이 글에서 다루는 내용카테고리 ID, 상품 ID처럼 매개변수별로 독립 캐시가 필요한 상황에서 family 패턴을 실무적으로 사용하는 방법을 설명합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.
Flutter Riverpod 실무 시리즈
  1. 1편. Provider 종류와 기본 사용법
  2. 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기
  3. 3편. ref.watch / ref.read / ref.listen 차이 완전 정리
  4. 4편. family 패턴으로 매개변수 기반 Provider 만들기
  5. 5편. invalidate 전략과 Provider 의존성 체이닝
family parameter
  ├─ categoryId = electronics → Provider A
  └─ categoryId = fashion     → Provider B
추천 다이어그램: 이 흐름을 Mermaid 또는 이미지로 변환해 대표 설명 이미지로 사용할 수 있습니다.
실무 적용 포인트처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.

시리즈 전체 목차

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,
);

FamilyAsyncNotifierarg로 초기 매개변수에 언제든지 접근할 수 있어요.


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를 갱신하는 패턴, autoDisposeref.onDispose()로 리소스를 정리하는 방법, 그리고 순환 의존성을 피하는 방법까지 정리할게요.

시리즈 연결다음 글에서는 invalidate 전략과 Provider 의존성 체이닝를 이어서 다룹니다.
추천 태그: Flutter, Riverpod, Dart, 상태관리, 모바일앱개발, family, Provider
대표 이미지 문구: Riverpod family / 같은 Provider, 다른 데이터
SEO Description: 카테고리 ID, 상품 ID처럼 매개변수별로 독립 캐시가 필요한 상황에서 family 패턴을 실무적으로 사용하는 방법을 설명합니다.