Mobile/Flutter

[Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기

Lior 2026. 6. 14. 20:48
Flutter Riverpod 실무 시리즈 · 2편

[Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기

FutureProvider로 부족해지는 추가·수정·삭제 액션을 AsyncNotifier로 묶어 실무형 비동기 상태를 설계합니다.

버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x
FlutterDart 3.xflutter_riverpod ^2.5.1수동 Provider 정의 기준
이 글에서 다루는 내용FutureProvider로 부족해지는 추가·수정·삭제 액션을 AsyncNotifier로 묶어 실무형 비동기 상태를 설계합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.
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 의존성 체이닝
AsyncNotifier
  ├─ build(): 초기 로딩
  ├─ add/update/delete(): 액션
  └─ state / invalidateSelf로 UI 갱신

 

실무 적용 포인트처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.

시리즈 전체 목차

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()가 언제 실행되는지 정확히 알아야 해요.

  1. 처음 감시될 때ref.watch(productNotifierProvider) 처음 실행
  2. ref.invalidateSelf() 호출 시 — 메서드에서 명시적 호출
  3. 의존하는 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편에서 정리할게요.

시리즈 연결다음 글에서는 ref.watch / ref.read / ref.listen 차이 완전 정리를 이어서 다룹니다.