Mobile/Flutter

[Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝

Lior 2026. 7. 17. 17:10
Flutter Riverpod 실무 시리즈 · 5편

[Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝

동기화, 캐시 갱신, Provider 의존성 연결, autoDispose 리소스 정리를 실무 흐름 기준으로 정리합니다.

버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x
FlutterDart 3.xflutter_riverpod ^2.5.1수동 Provider 정의 기준
이 글에서 다루는 내용동기화, 캐시 갱신, Provider 의존성 연결, autoDispose 리소스 정리를 실무 흐름 기준으로 정리합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.
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 의존성 체이닝
동기화 완료
  ├─ invalidate(productNotifierProvider)
  ├─ invalidate(categoryNotifierProvider)
  └─ invalidate(productsByCategoryProvider)
추천 다이어그램: 이 흐름을 Mermaid 또는 이미지로 변환해 대표 설명 이미지로 사용할 수 있습니다.
실무 적용 포인트처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.

시리즈 전체 목차

1편: Provider 종류와 기본 사용법 2편: AsyncNotifier — 비동기 상태의 기준점 3편: ref.watch / ref.read / ref.listen — 세 가지 차이를 제대로 알자 4편: family 패턴 — 같은 Provider, 다른 데이터 5편: invalidate 전략과 Provider 의존성 체이닝 ← 현재 글


1. 소개

앱을 처음 만들 때는 Provider 하나가 독립적으로 동작하는 것처럼 보여요. 그런데 실제 앱에선 한 액션이 여러 Provider에 영향을 미치고, 이 Provider들이 서로 의존하는 경우가 생겨요.

예를 들어, 상품 동기화가 완료되면 어떻게 되어야 할까요?

  • 카테고리별 상품 목록 갱신
  • 검색 결과 갱신
  • 장바구니 내 상품 정보 갱신
  • 상단 뱃지 개수 갱신

이걸 각 화면에서 개별로 처리하면 놓치는 케이스가 생겨요. 특히 첫 설치 직후처럼 로컬 DB는 비어있다가 동기화로 데이터가 채워지는 경우, DB엔 데이터가 있는데 Riverpod Provider는 여전히 빈 배열을 들고 있는 상황이 실제로 꽤 자주 생겨요.

이 편에서는 이런 상황을 체계적으로 다루는 방법을 정리할게요.

  • invalidate vs invalidateSelf vs refresh — 언제 뭘 써야 하나
  • Provider 의존성 체이닝 — watch로 자동 연결하는 방법
  • 동기화 완료 후 연쇄 무효화 패턴
  • autoDisposeref.onDispose()로 리소스 관리
  • 순환 의존성 피하기

flutter_riverpod: ^2.5.1 기준으로 작성했어요.


2. invalidate / invalidateSelf / refresh 차이

세 가지가 비슷해 보이는데 동작이 달라요.

메서드호출 위치동작반환값
ref.invalidate(provider)어디서든캐시를 버리고, 다음 접근 시 재실행void
ref.invalidateSelf()Notifier 내부자기 자신의 build() 재실행 예약void
ref.refresh(provider)어디서든캐시를 버리고 즉시 재실행, 새 값 반환새 값

invalidate — 캐시를 버려, 다음에 쓸 때 다시 실행해

// 상품 추가 후 관련 목록들 무효화
Future<void> addProduct(Product product) async {
  await ref.read(productRepositoryProvider).insert(product);

  // 이 카테고리의 캐시를 버림 → 다음에 ref.watch할 때 다시 API 호출
  ref.invalidate(productsByCategoryProvider(product.categoryId));

  // 전체 상품 목록도 갱신
  ref.invalidate(allProductsProvider);
}

invalidate()는 즉시 재실행하지 않아요. "다음에 누군가 이 Provider를 쓸 때 새로 실행해"라는 예약이에요. 만약 현재 아무도 이 Provider를 감시하고 있지 않다면 그냥 캐시만 지워요.

invalidateSelf — 나 자신을 무효화

Notifier 안에서만 쓸 수 있어요. ref.invalidateSelf()를 호출하면 build()가 다시 실행돼요.

class ProductNotifier extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() {
    return ref.read(productRepositoryProvider).fetchAll();
  }

  Future<void> deleteProduct(String id) async {
    await ref.read(productRepositoryProvider).delete(id);
    ref.invalidateSelf();  // build() 재실행 → 목록 갱신
  }
}

refresh — 지금 당장 재실행하고 값 반환해

// 풀-투-리프레시에서 즉시 데이터 갱신
Future<void> onRefresh() async {
  await ref.refresh(productNotifierProvider.future);
}

// 구독 상태를 즉시 확인해야 할 때
await ref.refresh(subscriptionProvider.future);

refresh()는 동기적으로 재실행을 시작하고, .future를 붙이면 완료를 기다릴 수 있어요.


3. Provider 의존성 체이닝

ref.watch()로 자동 의존성 선언

build() 안에서 ref.watch()로 다른 Provider를 감시하면, 그 Provider가 바뀔 때 자동으로 build()가 재실행돼요. 이게 Riverpod에서 의존성을 연결하는 기본 방법이에요.

class OrderNotifier extends AsyncNotifier<List<Order>> {
  @override
  Future<List<Order>> build() async {
    // 로그인 상태가 바뀌면 자동으로 재실행
    final user = ref.watch(currentUserProvider);
    if (user == null) return [];

    // 구독 상태가 바뀌면 자동으로 재실행
    final isSubscribed = ref.watch(isSubscribedProvider);
    if (!isSubscribed) return [];

    return ref.read(orderRepositoryProvider).fetchByUser(user.id);
  }
}

로그아웃하면 currentUserProvidernull로 바뀌고, OrderNotifier.build()가 자동으로 재실행돼서 빈 배열을 반환해요.

체이닝 예제 — 동기화 초기화

아래는 로그인 상태와 구독 상태를 모두 확인한 다음 동기화를 실행하는 예제예요. 로그인이나 구독 상태가 바뀌면 동기화가 자동으로 재트리거돼요.

전체 코드 보기
class SyncInitNotifier extends AsyncNotifier<void> {
  @override
  Future<void> build() async {
    // 1. 로그인 상태 감시
    final user = ref.watch(currentUserProvider);

    // 2. 구독 상태 감시
    final isSubscribed = ref.watch(isSubscribedProvider);

    // 조건 불충족 시 조기 반환
    if (user == null || !isSubscribed) return;

    // 3. 동기화 실행 (수동 단계별 처리)
    final syncService = ref.read(syncServiceProvider);

    ref.read(syncStateProvider.notifier).state = SyncState.syncing;

    try {
      await syncService.fullSync(
        onProgress: (step, current, total) {
          ref.read(syncProgressProvider.notifier).state =
              SyncProgress(step: step, current: current, total: total);
        },
      );

      // 4. 동기화 완료 → 관련 Provider 전체 무효화
      ref.read(syncStateProvider.notifier).state = SyncState.synced;
      ref.read(syncProgressProvider.notifier).state = null;

      _invalidateDataProviders();
    } catch (e) {
      ref.read(syncStateProvider.notifier).state = SyncState.error;
    }
  }

  void _invalidateDataProviders() {
    // 동기화로 DB 내용이 바뀌었으니 관련 캐시 전부 버림
    ref.invalidate(productNotifierProvider);
    ref.invalidate(categoryNotifierProvider);
    ref.invalidate(productsByCategoryProvider);  // family 전체
    ref.invalidate(allProductsProvider);
  }
}

final syncInitProvider = AsyncNotifierProvider<SyncInitNotifier, void>(
  SyncInitNotifier.new,
);

홈 화면에서 syncInitProvider를 감시만 하면, 로그인 상태가 바뀔 때마다 동기화가 자동으로 실행돼요.

@override
Widget build(BuildContext context, WidgetRef ref) {
  // 이것만 있으면 로그인/로그아웃 시 동기화 자동 재트리거
  ref.watch(syncInitProvider);

  // 나머지 UI 빌드...
}

의존성 흐름 시각화

SyncInitProvider
  ├── ref.watch(currentUserProvider)  ← 로그인 상태
  │     └── StreamProvider(authStateChanges)
  └── ref.watch(isSubscribedProvider) ← 구독 상태
        └── StreamProvider(firestoreSubscriptionStream)

동기화 완료 후 → invalidate:
  ├── productNotifierProvider
  ├── categoryNotifierProvider
  └── productsByCategoryProvider (family 전체)

4. 실무 적용 방법 — 완전한 동기화 + 연쇄 무효화 예제

쇼핑몰 앱에서 데이터 동기화 완료 후 모든 화면이 갱신되는 패턴이에요.

전체 코드 보기
// ---- providers/sync_provider.dart ----

// 동기화 진행 상태
enum SyncState { idle, syncing, synced, error }
final syncStateProvider = StateProvider<SyncState>((_) => SyncState.idle);
final syncProgressProvider = StateProvider<SyncProgress?>((_) => null);

class SyncInitNotifier extends AsyncNotifier<void> {
  @override
  Future<void> build() async {
    final user = ref.watch(currentUserProvider);
    final isSubscribed = ref.watch(isSubscribedProvider);

    if (user == null || !isSubscribed) return;

    await _runSync(user);
  }

  Future<void> _runSync(User user) async {
    final syncService = ref.read(syncServiceProvider);
    ref.read(syncStateProvider.notifier).state = SyncState.syncing;

    try {
      await syncService.fullSync(
        userId: user.id,
        onProgress: (step, current, total) {
          ref.read(syncProgressProvider.notifier).state =
              SyncProgress(step: step, current: current, total: total);
        },
      );

      ref.read(syncStateProvider.notifier).state = SyncState.synced;
      ref.read(syncProgressProvider.notifier).state = null;

      // 동기화 완료 → 모든 데이터 Provider 무효화
      ref.invalidate(productNotifierProvider);
      ref.invalidate(categoryNotifierProvider);
      ref.invalidate(productsByCategoryProvider);
      ref.invalidate(cartNotifierProvider);
      ref.invalidate(orderNotifierProvider);
    } catch (e) {
      ref.read(syncStateProvider.notifier).state = SyncState.error;
      rethrow;
    }
  }

  // 수동 재동기화
  Future<void> triggerSync() async {
    ref.invalidateSelf();
  }
}

final syncInitProvider =
    AsyncNotifierProvider<SyncInitNotifier, void>(SyncInitNotifier.new);


// ---- widgets/sync_indicator.dart ----

class SyncIndicator extends ConsumerWidget {
  const SyncIndicator({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final syncState = ref.watch(syncStateProvider);
    final progress = ref.watch(syncProgressProvider);

    return switch (syncState) {
      SyncState.syncing => Tooltip(
          message: progress != null
              ? '동기화 중... ${progress.step} (${progress.current}/${progress.total})'
              : '동기화 중...',
          child: const SizedBox(
            width: 18,
            height: 18,
            child: CircularProgressIndicator(strokeWidth: 2),
          ),
        ),
      SyncState.synced => const Tooltip(
          message: '동기화 완료',
          child: Icon(Icons.cloud_done_outlined, size: 20, color: Colors.green),
        ),
      SyncState.error => Tooltip(
          message: '동기화 실패',
          child: Icon(Icons.cloud_off_outlined, size: 20, color: Colors.red),
        ),
      SyncState.idle => const SizedBox.shrink(),
    };
  }
}


// ---- screens/home_screen.dart ----

class HomeScreen extends ConsumerStatefulWidget {
  const HomeScreen({super.key});

  @override
  ConsumerState<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends ConsumerState<HomeScreen> {
  String? _selectedCategoryId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 로그인/구독 상태 변경 시 동기화 자동 재트리거
    ref.watch(syncInitProvider);

    // 동기화 오류 감지 → 스낵바
    ref.listen<SyncState>(
      syncStateProvider,
      (prev, next) {
        if (next == SyncState.error && context.mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('동기화에 실패했어요. 나중에 다시 시도할게요.')),
          );
        }
      },
    );

    final categoriesAsync = ref.watch(categoryNotifierProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('쇼핑몰'),
        actions: const [SyncIndicator()],
      ),
      body: categoriesAsync.when(
        data: (cats) => Row(
          children: [
            // 카테고리 사이드바
            NavigationRail(
              selectedIndex: cats
                  .indexWhere((c) => c.id == _selectedCategoryId)
                  .clamp(0, cats.length - 1),
              destinations: cats
                  .map((c) => NavigationRailDestination(
                        icon: Icon(c.icon),
                        label: Text(c.name),
                      ))
                  .toList(),
              onDestinationSelected: (i) =>
                  setState(() => _selectedCategoryId = cats[i].id),
            ),
            // 상품 목록
            Expanded(
              child: _selectedCategoryId == null
                  ? const Center(child: Text('카테고리를 선택해주세요'))
                  : ProductGrid(categoryId: _selectedCategoryId!),
            ),
          ],
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('$e')),
      ),
    );
  }
}

5. autoDispose와 ref.onDispose로 리소스 관리

autoDispose — 감시하는 곳이 없으면 자동 정리

autoDispose를 붙이면 해당 Provider를 감시하는 위젯이 모두 사라질 때 Provider가 자동으로 dispose돼요.

// 검색 화면을 닫으면 검색 결과 자동 정리
final searchResultsProvider =
    FutureProvider.autoDispose.family<List<Product>, String>((ref, query) {
  return ref.read(productRepositoryProvider).search(query);
});

// 상품 상세 화면을 닫으면 자동 정리
final productDetailProvider =
    FutureProvider.autoDispose.family<Product, String>((ref, id) {
  return ref.read(productRepositoryProvider).findById(id);
});

전역으로 유지해야 하는 상태는 autoDispose를 빼요.

// 앱 전체에서 유지해야 하는 상태 — autoDispose 없음
final currentUserProvider = Provider<User?>(...);
final cartNotifierProvider = AsyncNotifierProvider<CartNotifier, Cart>(...);
final syncInitProvider = AsyncNotifierProvider<SyncInitNotifier, void>(...);

ref.onDispose — Provider가 폐기될 때 정리 작업

스트림 구독, 타이머, 컨트롤러처럼 명시적으로 정리해야 하는 리소스는 ref.onDispose()를 써요.

final iapServiceProvider = Provider<IapService>((ref) {
  final service = IapService(
    onPurchaseComplete: (productId) async {
      // 구매 완료 → Firestore에 구독 상태 업데이트
      final user = ref.read(currentUserProvider);
      if (user == null) return;
      await ref.read(subscriptionRepositoryProvider)
          .activate(user.id, productId);
    },
  );

  // Provider가 폐기될 때 구독 스트림 닫기
  ref.onDispose(service.dispose);

  return service;
});

final searchDebounceProvider = Provider.autoDispose<Debouncer>((ref) {
  final debouncer = Debouncer(delay: const Duration(milliseconds: 300));

  // 화면 닫히면 타이머 정리
  ref.onDispose(debouncer.cancel);

  return debouncer;
});

6. 주의사항

순환 의존성은 런타임 에러

Provider A가 Provider B를 감시하고, B가 A를 감시하면 순환 참조가 생겨요. Riverpod은 이걸 런타임에 감지하고 에러를 던져요.

// ❌ 순환 의존 — 에러
final providerA = Provider((ref) {
  return ref.watch(providerB);  // B를 감시
});

final providerB = Provider((ref) {
  return ref.watch(providerA);  // A를 감시 → 순환!
});

해결책은 한쪽을 ref.read()로 바꾸거나, 공통 의존성을 제3의 Provider로 분리하는 거예요.

// ✅ 한쪽을 read로
final providerA = Provider((ref) {
  return ref.watch(providerB);
});

final providerB = Provider((ref) {
  return ref.read(providerA);  // 감시 아닌 단순 읽기
});

// ✅ 또는 공통 상태를 별도 Provider로
final sharedStateProvider = StateProvider<SomeState>(...);

final providerA = Provider((ref) => ref.watch(sharedStateProvider));
final providerB = Provider((ref) => ref.watch(sharedStateProvider));

invalidate 타이밍 — UI 이벤트 핸들러 vs Notifier 내부

invalidate는 어디서든 호출할 수 있지만, Notifier 내부에서 다른 Provider를 invalidate하는 건 Notifier 메서드에서만 하는 게 좋아요.

class CartNotifier extends AsyncNotifier<Cart> {
  @override
  Future<Cart> build() async {
    final user = ref.watch(currentUserProvider);
    if (user == null) return Cart.empty();
    return ref.read(cartRepositoryProvider).fetch(user.id);
  }

  Future<void> checkout() async {
    final current = state.valueOrNull;
    if (current == null || current.isEmpty) return;

    final user = ref.read(currentUserProvider);
    if (user == null) return;

    await ref.read(orderRepositoryProvider).place(user.id, current);

    // 결제 완료 → 장바구니 초기화 + 주문 목록 갱신
    await ref.read(cartRepositoryProvider).clear(user.id);
    ref.invalidateSelf();  // 장바구니 갱신
    ref.invalidate(orderNotifierProvider);  // 주문 목록 갱신
  }
}

invalidate 후 로딩 깜빡임

invalidateSelf()invalidate()는 Provider를 로딩 상태로 만들어요. 화면이 잠깐 로딩 인디케이터로 바뀌는 게 UX적으로 어색하다면, 낙관적 업데이트로 상태를 직접 업데이트하는 게 나을 수 있어요.

// 로딩 깜빡임이 있는 방법
Future<void> addToCart(Product product) async {
  await ref.read(cartRepositoryProvider).add(product);
  ref.invalidateSelf();  // 로딩 → 데이터 순서로 UI 전환
}

// 낙관적 업데이트 — 로딩 없이 즉시 반영
Future<void> addToCart(Product product) async {
  final current = state.valueOrNull ?? Cart.empty();
  state = AsyncValue.data(current.copyWith(
    items: [...current.items, CartItem(product: product, quantity: 1)],
  ));
  await ref.read(cartRepositoryProvider).add(product);
}

7. 성능 / 구조 팁

watch는 최대한 좁게

ref.watch()로 대형 Provider를 통째로 감시하면 불필요한 리빌드가 생겨요. select()로 필요한 부분만 뽑아요.

// 전체 동기화 상태를 감시 — syncProgress가 바뀔 때도 리빌드
final syncState = ref.watch(syncStateProvider);

// 동기화 여부만 감시
final isSyncing = ref.watch(
  syncStateProvider.select((s) => s == SyncState.syncing),
);

Provider 의존 관계를 도식화해두면 좋아요

Provider가 10개 이상 넘어가면 어떤 게 어디에 의존하는지 파악하기 어려워져요. 팀 내에서 공유하는 간단한 도식이 있으면 협업이 쉬워져요.

currentUserProvider (StreamProvider)
  └── isSubscribedProvider (StreamProvider)
        └── syncInitProvider (AsyncNotifier)
              ├── productNotifierProvider ← invalidate 대상
              ├── categoryNotifierProvider ← invalidate 대상
              └── productsByCategoryProvider (family) ← invalidate 대상

8. 정리

  • ref.invalidate(provider) — 캐시를 버림, 다음 접근 시 재실행
  • ref.invalidateSelf() — Notifier 내부에서 자기 build() 재실행 예약
  • ref.refresh(provider) — 즉시 재실행하고 새 값 반환
  • ref.watch() — 의존성 선언, 값이 바뀌면 build() 자동 재실행
  • 동기화 완료 후 연쇄 무효화로 모든 화면을 일관되게 갱신
  • autoDispose — 감시자가 없으면 자동 정리
  • ref.onDispose() — 스트림, 타이머, 컨트롤러 명시적 정리
  • 순환 의존성 — 한쪽을 ref.read()로, 또는 공통 상태를 분리
  • 낙관적 업데이트 — invalidateSelf() 대신 state = AsyncValue.data()로 로딩 깜빡임 없애기

시리즈 전체 정리

5편에 걸쳐서 Riverpod 2.x의 핵심을 다뤘어요.

1편 — 기초 Provider 종류를 이해하고 ProviderScope를 설정하는 법을 배웠어요. Provider, StateProvider, FutureProvider, StreamProvider 각각의 용도가 명확하게 구분돼요.

2편 — AsyncNotifier 읽기만 하는 FutureProvider를 넘어서, CRUD 액션까지 포함한 상태 객체를 만드는 방법이에요. build()invalidateSelf(), 낙관적 업데이트의 조합이 핵심이에요.

3편 — ref.watch / ref.read / ref.listen 세 가지 규칙만 기억하면 돼요. build()에서는 watch, 핸들러에서는 read, 부작용은 listen.

4편 — family 같은 Provider를 매개변수별로 독립 운용하는 패턴이에요. 캐시 효율이 올라가고 매개변수 타입이 == 기반이어야 한다는 것만 주의하면 돼요.

5편 — invalidate 전략 Provider 간 의존성을 watch로 연결하고, 한 이벤트가 여러 Provider에 영향을 미칠 때 연쇄 무효화로 일관성을 유지해요.

이 시리즈의 패턴들은 실제 프로덕션 앱에서 검증된 것들이에요. 처음엔 낯설어도 한 번 손에 익으면 상태 관리가 훨씬 예측 가능해져요.

시리즈 연결이 글로 Riverpod 기본 시리즈를 마무리합니다. 이후 인증, 동기화, 로컬 DB 캐싱으로 확장할 수 있습니다.
추천 태그: Flutter, Riverpod, Dart, 상태관리, 모바일앱개발, invalidate, autoDispose, Provider 의존성
대표 이미지 문구: Riverpod invalidate / 캐시 갱신과 의존성 설계
SEO Description: 동기화, 캐시 갱신, Provider 의존성 연결, autoDispose 리소스 정리를 실무 흐름 기준으로 정리합니다.