[Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기
FutureProvider로 부족해지는 추가·수정·삭제 액션을 AsyncNotifier로 묶어 실무형 비동기 상태를 설계합니다.
- 1편. Provider 종류와 기본 사용법
- 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기
- 3편. ref.watch / ref.read / ref.listen 차이 완전 정리
- 4편. family 패턴으로 매개변수 기반 Provider 만들기
- 5편. invalidate 전략과 Provider 의존성 체이닝
AsyncNotifier
├─ build(): 초기 로딩
├─ add/update/delete(): 액션
└─ state / invalidateSelf로 UI 갱신
시리즈 전체 목차
1편: Provider 종류와 기본 사용법 2편: AsyncNotifier — 비동기 상태의 기준점 ← 현재 글 3편: ref.watch / ref.read / ref.listen — 세 가지 차이를 제대로 알자 4편: family 패턴 — 같은 Provider, 다른 데이터 5편: invalidate 전략과 Provider 의존성 체이닝
1. 소개
1편에서 본 FutureProvider는 데이터를 읽어오기엔 딱 좋아요. 그런데 실무에선 읽기만 하는 경우가 드물잖아요. 상품을 추가하고, 수정하고, 삭제하는 액션이 필요해요. 그러면서도 그 상태 변화가 UI에 즉시 반영돼야 하죠.
FutureProvider로는 이걸 깔끔하게 처리하기 어려워요. 외부 함수를 만들어서 직접 ref.invalidate()를 쓰면 Provider와 액션이 분리돼서 관리가 힘들어지고요.
AsyncNotifier는 바로 이 문제를 해결해요.
- 상태 + 액션을 한 클래스에서 관리
build()가 초기 데이터 로딩 담당- 메서드에서 DB/API 호출 후 상태 갱신
state를 직접 업데이트해서 낙관적 UI도 가능
이 편에서는 flutter_riverpod: ^2.5.1 기준의 AsyncNotifier를 실무 예제 중심으로 설명할게요.
2. 기본 사용 방법
AsyncNotifier 구조
class ProductNotifier extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
// 초기 데이터 로딩
// ref.watch()로 다른 Provider에 의존 가능
return ref.watch(productRepositoryProvider).fetchAll();
}
// 액션 메서드들
Future<void> add(Product product) async { ... }
Future<void> update(Product product) async { ... }
Future<void> delete(String id) async { ... }
}
final productNotifierProvider =
AsyncNotifierProvider<ProductNotifier, List<Product>>(
ProductNotifier.new,
);
build()는 이 Notifier가 처음 감시될 때 실행돼요. 이후 invalidateSelf()가 호출되거나, ref.watch()로 의존하던 다른 Provider가 바뀌면 build()가 다시 실행돼요.
build() 안에서 ref.watch()
build() 안에서 ref.watch()를 쓰면 해당 Provider가 바뀔 때 자동으로 build()가 재실행돼요.
@override
Future<List<Product>> build() async {
// 정렬 순서가 바뀌면 build()가 다시 실행돼 목록을 다시 가져와요
final sortOrder = ref.watch(sortOrderProvider);
return ref.watch(productRepositoryProvider).fetchAll(sortBy: sortOrder);
}
3. 실무 적용 방법 — 상품 CRUD 전체 예제
쇼핑몰 앱의 상품 목록을 AsyncNotifier로 관리하는 전체 예제예요.
전체 코드 보기
// ---- providers/product_notifier.dart ----
class ProductNotifier extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
// 정렬 순서 변경 시 자동 재실행
final sort = ref.watch(sortOrderProvider);
return ref.read(productRepositoryProvider).fetchAll(sortBy: sort);
}
// 상품 추가
Future<Product> addProduct({
required String name,
required int price,
required String categoryId,
}) async {
final product = Product(
id: const Uuid().v4(),
name: name,
price: price,
categoryId: categoryId,
createdAt: DateTime.now(),
);
await ref.read(productRepositoryProvider).insert(product);
// build()를 다시 실행해서 최신 목록으로 갱신
ref.invalidateSelf();
return product;
}
// 상품 수정 (낙관적 업데이트)
Future<void> updateProduct(Product updated) async {
// 1. 먼저 로컬 상태를 즉시 업데이트 (낙관적 UI)
final current = state.valueOrNull ?? [];
state = AsyncValue.data(
current.map((p) => p.id == updated.id ? updated : p).toList(),
);
try {
// 2. 실제 API/DB 호출
await ref.read(productRepositoryProvider).update(updated);
} catch (e, st) {
// 3. 실패하면 에러 상태로 롤백
state = AsyncValue.error(e, st);
// 또는 원래 목록으로 되돌리기
ref.invalidateSelf();
}
}
// 상품 삭제
Future<void> deleteProduct(String id) async {
await ref.read(productRepositoryProvider).delete(id);
ref.invalidateSelf();
}
// 가격 일괄 변경
Future<void> applyDiscount(double rate) async {
// 로딩 상태로 전환
state = const AsyncValue.loading();
try {
final current = await ref.read(productRepositoryProvider).fetchAll();
final discounted = current
.map((p) => p.copyWith(price: (p.price * (1 - rate)).round()))
.toList();
await ref.read(productRepositoryProvider).batchUpdate(discounted);
state = AsyncValue.data(discounted);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
final productNotifierProvider =
AsyncNotifierProvider<ProductNotifier, List<Product>>(
ProductNotifier.new,
);
UI에서는 이렇게 써요.
전체 코드 보기
// ---- screens/product_list_screen.dart ----
class ProductListScreen extends ConsumerWidget {
const ProductListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productNotifierProvider);
return Scaffold(
appBar: AppBar(
title: const Text('상품 목록'),
actions: [
IconButton(
icon: const Icon(Icons.local_offer),
tooltip: '10% 할인 적용',
onPressed: () {
ref.read(productNotifierProvider.notifier).applyDiscount(0.1);
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddProductDialog(context, ref),
child: const Icon(Icons.add),
),
body: productsAsync.when(
data: (products) => products.isEmpty
? const Center(child: Text('상품이 없어요'))
: ListView.builder(
itemCount: products.length,
itemBuilder: (_, i) => ProductTile(
product: products[i],
onEdit: (p) => _showEditDialog(context, ref, p),
onDelete: (id) => ref
.read(productNotifierProvider.notifier)
.deleteProduct(id),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('오류: $e'),
ElevatedButton(
onPressed: () => ref.invalidate(productNotifierProvider),
child: const Text('다시 시도'),
),
],
),
),
),
);
}
Future<void> _showAddProductDialog(BuildContext context, WidgetRef ref) async {
// 다이얼로그 로직 생략
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => const AddProductDialog(),
);
if (result == null) return;
await ref.read(productNotifierProvider.notifier).addProduct(
name: result['name'],
price: result['price'],
categoryId: result['categoryId'],
);
}
}
autoDispose — 화면 단위 임시 상태
현재 편집 중인 상품처럼, 화면이 닫히면 상태를 버려도 되는 경우엔 autoDispose를 붙여요.
전체 코드 보기
class CurrentProductNotifier extends AutoDisposeAsyncNotifier<Product?> {
String? _productId;
@override
Future<Product?> build() async {
if (_productId == null) return null;
return ref.read(productRepositoryProvider).findById(_productId!);
}
void setProductId(String id) {
_productId = id;
ref.invalidateSelf(); // build() 재실행
}
Future<void> updateTitle(String title) async {
final current = state.valueOrNull;
if (current == null) return;
final updated = current.copyWith(name: title, updatedAt: DateTime.now());
await ref.read(productRepositoryProvider).update(updated);
// 낙관적 업데이트: API 완료를 기다리지 않고 즉시 UI 갱신
state = AsyncValue.data(updated);
// 목록 Provider도 같이 갱신
ref.invalidate(productNotifierProvider);
}
}
final currentProductProvider =
AsyncNotifierProvider.autoDispose<CurrentProductNotifier, Product?>(
CurrentProductNotifier.new,
);
에디터 화면에서 이렇게 써요.
전체 코드 보기
class ProductEditScreen extends ConsumerStatefulWidget {
final String productId;
const ProductEditScreen({super.key, required this.productId});
@override
ConsumerState<ProductEditScreen> createState() => _ProductEditScreenState();
}
class _ProductEditScreenState extends ConsumerState<ProductEditScreen> {
final _nameController = TextEditingController();
bool _initialized = false;
@override
void initState() {
super.initState();
// initState에서는 microtask로 감싸서 실행
Future.microtask(() {
ref.read(currentProductProvider.notifier).setProductId(widget.productId);
});
}
@override
Widget build(BuildContext context) {
final productAsync = ref.watch(currentProductProvider);
return productAsync.when(
data: (product) {
// 처음 한 번만 텍스트 필드 초기화
if (product != null && !_initialized) {
_nameController.text = product.name;
_initialized = true;
}
return Scaffold(
appBar: AppBar(title: const Text('상품 편집')),
body: Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _nameController,
onChanged: (v) =>
ref.read(currentProductProvider.notifier).updateTitle(v),
decoration: const InputDecoration(labelText: '상품명'),
),
),
);
},
loading: () => const Scaffold(
body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(
body: Center(child: Text('오류: $e'))),
);
}
}
4. 주의사항
invalidateSelf() vs state = AsyncValue.data()
처음엔 이 둘의 차이가 헷갈릴 수 있어요.
| 방법 | 동작 | 언제 쓰나 |
|---|---|---|
ref.invalidateSelf() |
build()를 다시 실행 (로딩 상태 거침) |
DB/API에서 최신 데이터를 다시 가져올 때 |
state = AsyncValue.data(value) |
로딩 없이 즉시 상태 교체 | 낙관적 업데이트, 로컬 계산으로 충분할 때 |
// 상품 삭제 후 목록 갱신 — DB에서 다시 가져와야 하므로 invalidateSelf
Future<void> deleteProduct(String id) async {
await ref.read(productRepositoryProvider).delete(id);
ref.invalidateSelf(); // build() 재실행 → 로딩 상태 잠깐 보임
}
// 상품 수정 — 어떤 항목이 바뀌는지 알고 있으므로 낙관적 업데이트
Future<void> updateProduct(Product updated) async {
final current = state.valueOrNull ?? [];
state = AsyncValue.data(
current.map((p) => p.id == updated.id ? updated : p).toList(),
); // 로딩 없이 즉시 UI 갱신
await ref.read(productRepositoryProvider).update(updated);
}
build()가 실행되는 타이밍
build()가 언제 실행되는지 정확히 알아야 해요.
- 처음 감시될 때 —
ref.watch(productNotifierProvider)처음 실행 ref.invalidateSelf()호출 시 — 메서드에서 명시적 호출- 의존하는 Provider가 바뀔 때 —
build()안에서ref.watch()한 Provider가 바뀔 때
@override
Future<List<Product>> build() async {
// sortOrderProvider가 바뀌면 build()가 자동으로 재실행
final sort = ref.watch(sortOrderProvider);
return ref.read(productRepositoryProvider).fetchAll(sortBy: sort);
}
build() 안에서는 ref.watch()를 쓰고, 메서드 안에서는 ref.read()를 써요. 메서드 안에서 ref.watch()를 쓰면 런타임 에러가 나요.
메서드 안에서 state를 읽을 때
Future<void> pinProduct(String id) async {
// state.valueOrNull — AsyncValue.data() 상태일 때만 값이 있어요
final current = state.valueOrNull;
if (current == null) return; // 로딩 중이거나 에러 상태면 건너뜀
final updated = current.map((p) =>
p.id == id ? p.copyWith(isPinned: !p.isPinned) : p
).toList();
await ref.read(productRepositoryProvider).batchUpdate(updated);
state = AsyncValue.data(updated);
}
5. 성능 / 구조 팁
낙관적 업데이트로 UX 개선
API 응답을 기다리지 않고 먼저 UI를 업데이트하면 사용자 입장에선 훨씬 빠르게 느껴져요.
Future<void> togglePin(String id) async {
final current = state.valueOrNull ?? [];
// 1. 먼저 UI 갱신
state = AsyncValue.data(
current.map((p) => p.id == id ? p.copyWith(isPinned: !p.isPinned) : p).toList(),
);
try {
// 2. 백엔드에 반영
final target = current.firstWhere((p) => p.id == id);
await ref.read(productRepositoryProvider).update(
target.copyWith(isPinned: !target.isPinned),
);
} catch (_) {
// 3. 실패하면 원래 상태로 롤백
state = AsyncValue.data(current);
}
}
AsyncNotifier vs FutureProvider — 언제 뭘 쓸까
이 기준이 명확하면 선택이 쉬워져요.
| 상황 | 추천 |
|---|---|
| 읽기 전용, 외부 액션 없음 | FutureProvider |
| 데이터 추가/수정/삭제가 있음 | AsyncNotifier |
| 여러 곳에서 같은 데이터를 수정 | AsyncNotifier (단일 진실 원천) |
| 화면 단위 임시 상태 | AsyncNotifier.autoDispose |
에러 복구 패턴
에러 상태에서 재시도 버튼을 제공하는 게 좋아요.
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 8),
Text('$e'),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('다시 시도'),
onPressed: () => ref.invalidate(productNotifierProvider),
),
],
),
),
6. 정리
AsyncNotifier— 상태 + 액션을 한 클래스에서 관리build()— 초기 데이터 로딩,ref.watch()로 의존 선언 가능ref.invalidateSelf()—build()재실행, DB에서 최신 데이터를 다시 가져올 때state = AsyncValue.data(value)— 로딩 없이 즉시 상태 교체, 낙관적 업데이트state.valueOrNull— 현재 데이터를 null-safe하게 추출autoDispose— 화면이 닫힐 때 자동 정리, 에디터 같은 임시 상태에 사용- 메서드 안에선
ref.read(),build()안에선ref.watch()
다음 편 예고: Provider를 쓰다 보면 ref.watch(), ref.read(), ref.listen() 중 어떤 걸 써야 할지 헷갈리는 순간이 와요. 각각 어떻게 다르고 어떤 상황에서 쓰는지, 그리고 select()로 불필요한 리빌드를 막는 방법을 3편에서 정리할게요.
'Mobile > Flutter' 카테고리의 다른 글
| [Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝 (0) | 2026.07.17 |
|---|---|
| [Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 Provider 만들기 (0) | 2026.07.17 |
| [Flutter Riverpod] 3편. ref.watch / ref.read / ref.listen 차이 완전 정리 (0) | 2026.07.17 |
| [Flutter Riverpod] 1편. Provider 종류와 기본 사용법 (0) | 2026.06.08 |