[Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝
동기화, 캐시 갱신, Provider 의존성 연결, autoDispose 리소스 정리를 실무 흐름 기준으로 정리합니다.
- 1편. Provider 종류와 기본 사용법
- 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기
- 3편. ref.watch / ref.read / ref.listen 차이 완전 정리
- 4편. family 패턴으로 매개변수 기반 Provider 만들기
- 5편. invalidate 전략과 Provider 의존성 체이닝
동기화 완료 ├─ invalidate(productNotifierProvider) ├─ invalidate(categoryNotifierProvider) └─ invalidate(productsByCategoryProvider)
시리즈 전체 목차
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는 여전히 빈 배열을 들고 있는 상황이 실제로 꽤 자주 생겨요.
이 편에서는 이런 상황을 체계적으로 다루는 방법을 정리할게요.
invalidatevsinvalidateSelfvsrefresh— 언제 뭘 써야 하나- Provider 의존성 체이닝 — watch로 자동 연결하는 방법
- 동기화 완료 후 연쇄 무효화 패턴
autoDispose와ref.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);
}
}
로그아웃하면 currentUserProvider가 null로 바뀌고, 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에 영향을 미칠 때 연쇄 무효화로 일관성을 유지해요.
이 시리즈의 패턴들은 실제 프로덕션 앱에서 검증된 것들이에요. 처음엔 낯설어도 한 번 손에 익으면 상태 관리가 훨씬 예측 가능해져요.
'Mobile > Flutter' 카테고리의 다른 글
| [Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 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 |