Mobile/Flutter

[Flutter Riverpod] 3편. ref.watch / ref.read / ref.listen 차이 완전 정리

Lior 2026. 7. 17. 17:08
Flutter Riverpod 실무 시리즈 · 3편

[Flutter Riverpod] 3편. ref.watch / ref.read / ref.listen 차이 완전 정리

Riverpod에서 가장 많이 헷갈리는 watch, read, listen의 차이를 UI 리빌드와 부작용 관점에서 정리합니다.

버전 기준: flutter_riverpod ^2.5.1 · Dart 3.x · Flutter 3.x
FlutterDart 3.xflutter_riverpod ^2.5.1수동 Provider 정의 기준
이 글에서 다루는 내용Riverpod에서 가장 많이 헷갈리는 watch, read, listen의 차이를 UI 리빌드와 부작용 관점에서 정리합니다. 단순 문법 소개보다 실제 화면 구조에서 왜 이 패턴을 선택하는지에 초점을 맞춥니다.
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 의존성 체이닝
ref.watch → UI 리빌드
ref.read  → 이벤트에서 한 번 읽기
ref.listen → 스낵바/네비게이션 같은 부작용
추천 다이어그램: 이 흐름을 Mermaid 또는 이미지로 변환해 대표 설명 이미지로 사용할 수 있습니다.
실무 적용 포인트처음부터 모든 Provider를 복잡하게 설계하기보다, 읽기 전용/단순 상태/비동기 액션을 구분한 뒤 필요한 지점에서 구조를 확장하는 것이 유지보수에 유리합니다.

시리즈 전체 목차

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


1. 소개

Riverpod 코드를 보다 보면 비슷해 보이는 세 가지가 계속 나와요.

ref.watch(someProvider)
ref.read(someProvider)
ref.listen(someProvider, (prev, next) { ... })

처음엔 "그냥 값 가져오는 거 아닌가?" 싶은데, 세 가지가 동작 방식이 완전히 달라요. 잘못 쓰면 리빌드가 과하게 일어나거나, 상태가 바뀌어도 UI가 업데이트 안 되거나, 최악엔 런타임 에러가 나요.

이 편에서는 세 가지의 정확한 차이와, 어떤 상황에서 어떤 걸 써야 하는지 규칙을 정리할게요. flutter_riverpod: ^2.5.1 기준이에요.


2. 세 가지 핵심 차이

한 줄 요약부터 하고 시작할게요.

메서드UI 리빌드부작용 실행주 사용처
ref.watch()값이 바뀌면 리빌드없음build()
ref.read()없음없음이벤트 핸들러, 메서드 안
ref.listen()없음값 변경 시 콜백 실행부작용 (다이얼로그, 네비게이션)

3. ref.watch() — 값이 바뀌면 나를 다시 그려줘

ref.watch()는 Provider를 구독해요. 그 Provider의 값이 바뀌면, ref.watch()를 호출한 위젯이나 Provider가 자동으로 다시 실행돼요.

class ProductListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // productsAsync 값이 바뀌면 build() 전체가 다시 실행돼요
    final productsAsync = ref.watch(productNotifierProvider);
    final sortOrder = ref.watch(sortOrderProvider);  // 이것도 감시

    return productsAsync.when(
      data: (products) => ProductGrid(products: products, sort: sortOrder),
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('오류: $e'),
    );
  }
}

ref.watch()를 쓰면 안 되는 곳

이벤트 핸들러 안에서 ref.watch()를 쓰면 Riverpod이 해당 감시를 추적할 수 없어요. 이 경우 경고 없이 이상하게 동작하거나, 특정 버전에선 에러가 나요.

// ❌ 잘못된 예
ElevatedButton(
  onPressed: () {
    final notifier = ref.watch(productNotifierProvider.notifier);
    notifier.deleteProduct(id);
  },
  child: const Text('삭제'),
)

// ✅ 올바른 예
ElevatedButton(
  onPressed: () {
    ref.read(productNotifierProvider.notifier).deleteProduct(id);
  },
  child: const Text('삭제'),
)

initState 안에서도 ref.watch()는 안 돼요. ref.read()를 쓰거나, build()로 로직을 옮겨야 해요.

// ❌ 잘못된 예
@override
void initState() {
  super.initState();
  ref.watch(productNotifierProvider);  // 에러
}

// ✅ 올바른 예 — initState에선 ref.read()
@override
void initState() {
  super.initState();
  Future.microtask(() {
    ref.read(productNotifierProvider.notifier).setFilter('active');
  });
}

4. ref.read() — 지금 이 순간의 값만 줘

ref.read()는 현재 시점의 값을 딱 한 번 읽어요. 구독이 아니라 단순 조회예요. Provider가 나중에 바뀌어도 아무 반응 없어요.

// 버튼 누를 때 현재 사용자 정보 읽기
ElevatedButton(
  onPressed: () async {
    final user = ref.read(currentUserProvider);  // 현재 값만
    if (user == null) return;
    await ref.read(orderRepositoryProvider).placeOrder(user.id, cart);
  },
  child: const Text('주문하기'),
)

Notifier 메서드 호출

Notifier의 메서드를 호출할 때는 항상 ref.read()를 써요. ref.watch(notifier.notifier)처럼 쓰면 Notifier 자체를 구독하게 되는데, 이렇게 쓰는 경우는 거의 없어요.

// Notifier 메서드 호출 패턴
ref.read(productNotifierProvider.notifier).addProduct(product);
ref.read(cartNotifierProvider.notifier).addItem(item);
ref.read(currentProductProvider.notifier).setProductId(id);

ref.read()를 build() 안에서 쓰면 안 되는 이유

build() 안에서 ref.read()를 쓰면 해당 Provider가 바뀌어도 UI가 갱신되지 않아요. 처음엔 올바른 값이 나오는 것처럼 보이지만, 상태가 바뀌어도 UI가 굳어있는 버그가 생겨요.

@override
Widget build(BuildContext context, WidgetRef ref) {
  // ❌ 이렇게 하면 상품 목록이 바뀌어도 UI가 업데이트 안 돼요
  final products = ref.read(productNotifierProvider).valueOrNull ?? [];
  return ListView.builder(...);

  // ✅ 이래야 바뀔 때마다 UI가 갱신돼요
  final productsAsync = ref.watch(productNotifierProvider);
  return productsAsync.when(...);
}

5. ref.listen() — 바뀔 때 부작용을 실행해줘

ref.listen()은 구독은 하지만 UI를 리빌드하지는 않아요. 값이 바뀔 때 콜백만 실행해요. 다이얼로그 표시, 스낵바, 네비게이션처럼 UI 리빌드가 아닌 부작용이 필요할 때 써요.

@override
Widget build(BuildContext context, WidgetRef ref) {
  // 오류가 발생하면 스낵바 표시 — UI 리빌드는 일어나지 않아요
  ref.listen<AsyncValue<List<Product>>>(
    productNotifierProvider,
    (prev, next) {
      if (next is AsyncError && prev is! AsyncError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('오류: ${next.error}')),
        );
      }
    },
  );

  // 인증 상태 변경 시 네비게이션
  ref.listen<User?>(
    currentUserProvider,
    (prev, next) {
      if (next == null && prev != null) {
        // 로그아웃 → 로그인 화면으로
        context.go('/login');
      }
    },
  );

  // 실제 UI 빌드
  final productsAsync = ref.watch(productNotifierProvider);
  return Scaffold(...);
}

ref.listen()의 특징

  • build() 안에서 호출해야 해요 (이벤트 핸들러 안 ❌)
  • 이전 값(prev)과 새 값(next) 모두 받아요
  • 리빌드를 일으키지 않아요 → ScaffoldMessenger, Navigator에 적합
  • prev == null이면 처음 등록될 때예요

6. 실무 적용 방법 — 세 가지를 함께 쓰는 패턴

주문 화면에서 세 가지를 조합해서 쓰는 예제예요.

전체 코드 보기
class CheckoutScreen extends ConsumerStatefulWidget {
  const CheckoutScreen({super.key});

  @override
  ConsumerState<CheckoutScreen> createState() => _CheckoutScreenState();
}

class _CheckoutScreenState extends ConsumerState<CheckoutScreen> {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ---- ref.listen: 주문 완료/실패 시 부작용 ----
    ref.listen<AsyncValue<Order?>>(
      orderNotifierProvider,
      (prev, next) {
        // 로딩에서 데이터로 전환 = 주문 완료
        if (prev is AsyncLoading && next is AsyncData && next.value != null) {
          context.go('/order-complete/${next.value!.id}');
        }
        // 에러 발생
        if (next is AsyncError) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('주문 실패: ${next.error}')),
          );
        }
      },
    );

    // ---- ref.watch: UI 리빌드가 필요한 상태 ----
    final cartAsync = ref.watch(cartNotifierProvider);
    final orderAsync = ref.watch(orderNotifierProvider);
    final isLoading = orderAsync is AsyncLoading;

    return Scaffold(
      appBar: AppBar(title: const Text('결제')),
      body: cartAsync.when(
        data: (cart) => _CartSummary(cart: cart),
        loading: () => const CircularProgressIndicator(),
        error: (e, _) => Text('$e'),
      ),
      bottomNavigationBar: Padding(
        padding: const EdgeInsets.all(16),
        child: ElevatedButton(
          onPressed: isLoading
              ? null
              : () {
                  // ---- ref.read: 이벤트 핸들러에서 액션 실행 ----
                  final user = ref.read(currentUserProvider);
                  final cart = ref.read(cartNotifierProvider).valueOrNull;
                  if (user == null || cart == null) return;

                  ref.read(orderNotifierProvider.notifier).placeOrder(
                    userId: user.id,
                    cart: cart,
                  );
                },
          child: isLoading
              ? const SizedBox(
                  height: 20,
                  width: 20,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              : const Text('주문하기'),
        ),
      ),
    );
  }
}

7. 주의사항

ref.listen()은 build()에서만 호출해요

ref.listen()initState()나 버튼 콜백 안에서 호출하면 구독이 제대로 등록되지 않거나, 구독이 해제되지 않는 문제가 생겨요.

// ❌ 잘못된 예 — initState에서 ref.listen
@override
void initState() {
  super.initState();
  ref.listen(orderNotifierProvider, (prev, next) { ... });  // 위험
}

// ✅ 올바른 예 — build()에서 ref.listen
@override
Widget build(BuildContext context, WidgetRef ref) {
  ref.listen(orderNotifierProvider, (prev, next) { ... });  // 정상
  // ...
}

context가 살아있는지 확인해야 해요

ref.listen()의 콜백 안에서 context를 쓸 때 위젯이 이미 unmount됐을 수 있어요.

ref.listen<AsyncValue<Order?>>(
  orderNotifierProvider,
  (prev, next) {
    if (next is AsyncData && next.value != null) {
      // ✅ mounted 체크 또는 context.mounted 사용
      if (context.mounted) {
        context.go('/order-complete');
      }
    }
  },
);

8. select()로 불필요한 리빌드 줄이기

ref.watch()는 Provider의 모든 변화에 반응해요. 장바구니에 10개 항목이 있는데 아이콘 뱃지에 개수만 표시한다면, 상품 하나가 바뀔 때마다 리빌드될 필요가 없어요.

select()로 관심 있는 부분만 뽑아서 감시할 수 있어요.

// 장바구니 전체를 감시 — 어떤 변화든 리빌드
final cart = ref.watch(cartNotifierProvider).valueOrNull;

// 개수만 감시 — 개수가 바뀔 때만 리빌드
final itemCount = ref.watch(
  cartNotifierProvider.select((async) => async.valueOrNull?.items.length ?? 0),
);

// 구독 활성 여부만 감시 — 구독 상태 전체가 아닌 active 필드만 체크
final isSubscribed = ref.watch(
  subscriptionProvider.select((async) => async.valueOrNull?.isActive ?? false),
);

select()는 반환값이 이전과 동일하면 리빌드를 건너뛰어요. 값 비교는 == 연산자로 해요.

listen과 select 조합

// 구독 상태가 비활성으로 바뀔 때만 다이얼로그 표시
ref.listen<bool>(
  subscriptionProvider.select((s) => s.valueOrNull?.isActive ?? false),
  (prev, next) {
    if (prev == true && next == false && context.mounted) {
      _showSubscriptionExpiredDialog(context);
    }
  },
);

9. 정리

규칙 3개만 기억하면 돼요.

  • ref.watch() → build()에서, UI 갱신이 필요할 때
  • ref.read() → 이벤트 핸들러·메서드에서, 한 번만 읽을 때
  • ref.listen() → build()에서, 리빌드 없이 부작용만 실행할 때

자주 하는 실수 정리

상황잘못된 방법올바른 방법
버튼 onPressed에서 상태 읽기ref.watch()ref.read()
build()에서 상태 읽기ref.read()ref.watch()
initState에서 Notifier 메서드 호출ref.watch()Future.microtask(() => ref.read(...))
오류 시 스낵바 표시ref.watch() + 조건문ref.listen()
build()에서 조건부 이벤트ref.listen() 없이 조건문만ref.listen() + prev/next 비교

select()로 최적화

// 전체 객체가 아닌 필요한 필드만 감시
ref.watch(cartProvider.select((c) => c.valueOrNull?.itemCount ?? 0))

다음 편 예고: 같은 Provider인데 카테고리마다, 상품마다 다른 인스턴스가 필요할 때 어떻게 할까요? 4편에서 family 패턴을 다뤄요. FutureProvider.family, AsyncNotifierProvider.family, 그리고 family 캐시를 어떻게 관리하는지 설명할게요.

시리즈 연결다음 글에서는 family 패턴으로 매개변수 기반 Provider 만들기를 이어서 다룹니다.
추천 태그: Flutter, Riverpod, Dart, 상태관리, 모바일앱개발, ref.watch, ref.read, ref.listen
대표 이미지 문구: watch read listen / 세 가지 ref 사용 규칙
SEO Description: Riverpod에서 가장 많이 헷갈리는 watch, read, listen의 차이를 UI 리빌드와 부작용 관점에서 정리합니다.