[Flutter Riverpod] 3편. ref.watch / ref.read / ref.listen 차이 완전 정리
Riverpod에서 가장 많이 헷갈리는 watch, read, listen의 차이를 UI 리빌드와 부작용 관점에서 정리합니다.
- 1편. Provider 종류와 기본 사용법
- 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기
- 3편. ref.watch / ref.read / ref.listen 차이 완전 정리
- 4편. family 패턴으로 매개변수 기반 Provider 만들기
- 5편. invalidate 전략과 Provider 의존성 체이닝
ref.watch → UI 리빌드 ref.read → 이벤트에서 한 번 읽기 ref.listen → 스낵바/네비게이션 같은 부작용
시리즈 전체 목차
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 캐시를 어떻게 관리하는지 설명할게요.
'Mobile > Flutter' 카테고리의 다른 글
| [Flutter Riverpod] 5편. invalidate 전략과 Provider 의존성 체이닝 (0) | 2026.07.17 |
|---|---|
| [Flutter Riverpod] 4편. family 패턴으로 매개변수 기반 Provider 만들기 (0) | 2026.07.17 |
| [Flutter Riverpod] 2편. AsyncNotifier로 비동기 CRUD 상태 관리하기 (0) | 2026.06.14 |
| [Flutter Riverpod] 1편. Provider 종류와 기본 사용법 (0) | 2026.06.08 |