Mobile/Flutter

[Flutter Riverpod] 1편. Provider 종류와 기본 사용법

Lior 2026. 6. 8. 22:02
Flutter Riverpod 실무 시리즈 · 1편

[Flutter Riverpod] 1편. Provider 종류와 기본 사용법

Riverpod Provider 종류, ProviderScope, ConsumerWidget, NotifierProvider, FutureProvider, StreamProvider 기본기를 실무 구조로 정리합니다.

버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x
FlutterDart 3.xflutter_riverpod ^2.5.1Notifier 기반 수동 Provider 정의 기준
이 글에서 다루는 내용Riverpod Provider 종류, ProviderScope, ConsumerWidget, NotifierProvider, FutureProvider, StreamProvider 기본기를 실무 구조로 정리합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.
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 의존성 체이닝
ProviderScope
  └─ Provider / NotifierProvider / FutureProvider / StreamProvider
       └─ ConsumerWidget에서 ref.watch로 사용
실무 적용 포인트처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/동기 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.

시리즈 전체 목차

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
실무 메모이 시리즈에서는 코드 생성 없이 수동 Provider 정의 방식을 써요. 생성기를 쓰지 않아도 충분히 깔끔하게 쓸 수 있고, 동작 방식을 이해하는 데는 수동 방식이 더 명확해요.

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를 오버라이드할 때도 ProviderScopeoverrides 파라미터를 사용해요.

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

ConsumerStatefulWidgetinitState, 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 / ConsumerStatefulWidgetref를 쓰려면 이걸로
  • ref.watch() — build() 안에서만, 이벤트 핸들러엔 ref.read()
  • autoDispose — 화면 단위 임시 상태는 붙여주기

다음 편 예고: 단순 데이터 페칭을 넘어 생성/수정/삭제 메서드까지 포함된 완전한 상태 객체가 필요할 때 어떻게 할까요? 2편에서 AsyncNotifier를 다뤄요. build()가 어떻게 동작하는지, invalidateSelf()로 상태를 갱신하는 방법, state = AsyncValue.data(...)로 낙관적 업데이트 하는 법까지 실무 예제로 설명할게요.

시리즈 연결다음 글에서는 AsyncNotifier로 비동기 CRUD 상태 관리하기를 이어서 다룹니다.