Flutter 개발
Riverpod 심화편: FutureProvider, StreamProvider, NotifierProvider 완벽 가이드
withmilk
2025. 5. 25. 19:07
지난 글에서는 StateProvider를 이용해 버튼을 눌러 숫자를 증가시키는 간단한 예제를 만들었어요.
하지만 앱이 조금만 복잡해지면, StateProvider만으로는 부족한 경우가 생깁니다.
- 데이터를 서버에서 불러와야 할 때
- 실시간 데이터를 계속 받아야 할 때
- 여러 상태를 하나로 묶어 관리하고 싶을 때
이럴 때 등장하는 게 바로 FutureProvider, StreamProvider, NotifierProvider예요!
이번 글에서는 이 세 가지를 왜 써야 하는지부터 어떻게 쓰는지까지 자세히 알려드릴게요 😊
🔮 1. FutureProvider – 비동기 데이터를 다룰 때
왜 필요한가요?
- StateProvider는 정적인 값만 다룰 수 있어요.
- 하지만 서버에서 데이터를 불러오거나, 파일을 읽는 등의 비동기 작업은 Future를 사용하죠.
- 이럴 때 FutureProvider를 쓰면, 비동기 데이터의 상태(로딩, 완료, 에러)를 깔끔하게 처리할 수 있어요.
언제 쓰면 좋은가요?
- API 호출 (예: 날씨 정보 불러오기)
- 디바이스 정보 가져오기
- 초기 설정 값 로딩
예시: 버튼을 누르면 2초 후에 새로운 숫자를 가져오는 카운터
final delayedCounterProvider = FutureProvider<int>((ref) async {
await Future.delayed(Duration(seconds: 2));
return 42; // API 응답이라고 가정
});
class DelayedCounter extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncValue = ref.watch(delayedCounterProvider);
return asyncValue.when(
data: (value) => Text('값: $value'),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('에러 발생: $err'),
);
}
}
💡 핵심 포인트
- FutureProvider는 값이 아직 없는 상태(로딩)와 에러 처리까지 포함해서 관리해줘요.
- ref.watch()의 결과는 AsyncValue 타입이라 .when()으로 다양한 상태를 분기할 수 있어요.
- data가 있는 경우에는 Text Widget으로 데이터를 보여주거나,
- loading 중인 경우에는 CircularProgressIndicator를 보여주거나,
- 값을 불러오다가 error가 발생한 경우에는 error 메세지를 보여줄 수도 있어요!
전체 코드
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final delayedCounterProvider = FutureProvider<int>((ref) async {
await Future.delayed(Duration(seconds: 2));
return 42; // API 응답이라고 가정
});
class DelayedCounter extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncValue = ref.watch(delayedCounterProvider);
return asyncValue.when(
data: (value) => Text('값: $value'),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('에러 발생: $err'),
);
}
}
void main() {
runApp(
// 2. 앱을 ProviderScope로 감싸야 riverpod이 작동함!
ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterScreen(),
);
}
}
// 3. 상태를 사용하려면 ConsumerWidget 또는 ConsumerStatefulWidget이 필요함!
class CounterScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: Text('Riverpod 카운터')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DelayedCounter(),
],
),
),
);
}
}
🔁 2. StreamProvider – 실시간 데이터를 다룰 때
왜 필요한가요?
- 실시간 데이터는 Stream으로 흘러와요. 예를 들면:
- 채팅 메시지
- 위치 정보
- Firebase 실시간 데이터
- 이럴 때 StateProvider는 사용할 수 없고, StreamProvider로 받아와야 해요.
언제 쓰면 좋은가요?
- 실시간 채팅, 알림
- 주기적으로 바뀌는 센서 데이터
- WebSocket, Firebase 연동
예시: 1초마다 숫자가 증가하는 스트림 카운터
final streamCounterProvider = StreamProvider<int>((ref) async* {
int i = 0;
while (true) {
await Future.delayed(Duration(seconds: 1));
yield i++;
}
});
class StreamCounter extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncValue = ref.watch(streamCounterProvider);
return asyncValue.when(
data: (value) => Text('스트림 값: $value'),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('에러: $err'),
);
}
}
💡 핵심 포인트
- StreamProvider는 시간에 따라 값이 계속 바뀌는 경우에 사용해요. 서버에서 계속 값이 전달되는 경우처럼요.
- yield 키워드로 실시간 값을 하나씩 계속 흘려보낼 수 있어요.
- 역시 AsyncValue로 상태 분기를 쉽게 할 수 있어요.
전체 코드
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final streamCounterProvider = StreamProvider<int>((ref) async* {
int i = 0;
while (true) {
await Future.delayed(Duration(seconds: 1));
yield i++;
}
});
class StreamCounter extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncValue = ref.watch(streamCounterProvider);
return asyncValue.when(
data: (value) => Text('스트림 값: $value'),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('에러: $err'),
);
}
}
void main() {
runApp(
// 2. 앱을 ProviderScope로 감싸야 riverpod이 작동함!
ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterScreen(),
);
}
}
// 3. 상태를 사용하려면 ConsumerWidget 또는 ConsumerStatefulWidget이 필요함!
class CounterScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: Text('Riverpod 카운터')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StreamCounter(),
],
),
),
);
}
}
🧠 3. NotifierProvider – 복잡한 상태와 로직을 함께 다룰 때
왜 필요한가요?
- StateProvider는 값만 저장하지, 로직(함수)은 따로 처리해야 해요.
- 여러 개의 상태(예: 카운터 + 버튼 상태)를 함께 다루거나,
값 변경 시 무언가 계산, 로직 처리까지 함께 하고 싶을 땐 NotifierProvider가 좋아요.
언제 쓰면 좋은가요?
- 상태 변경에 따라 내부 로직이 있는 경우
- 여러 상태를 묶어서 함께 관리하고 싶을 때
- 로그인, 장바구니, 필터 등 구조가 복잡한 상태일 때
예시: 증가/감소 기능이 포함된 카운터 클래스
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
final notifierCounterProvider = NotifierProvider<CounterNotifier, int>(() => CounterNotifier());
class NotifierCounter extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(notifierCounterProvider);
final notifier = ref.read(notifierCounterProvider.notifier);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('값: $count', style: TextStyle(fontSize: 30)),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(onPressed: notifier.increment, icon: Icon(Icons.add)),
IconButton(onPressed: notifier.decrement, icon: Icon(Icons.remove)),
],
)
],
);
}
}
💡 핵심 포인트
- 상태와 관련된 로직(increment, decrement)을 하나의 클래스에 담아 관리할 수 있어요.
- 상태 관리를 할때 필요한 곳에서 state를 직접 변경하지 않고, 객체의 함수를 호출해서 변경할 수 있죠!
- 외부에서 상태를 변경할 수 있는 조건과 환경을 딱 정의해놓고 재사용할 수 있기 때문에 상태를 원하는 대로 관리할 수 있어요.
- NotifierProvider는 구조화된 상태 관리가 가능해서 코드 재사용성과 유지보수성이 훨씬 좋아요.
전체 코드
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
final notifierCounterProvider =
NotifierProvider<CounterNotifier, int>(() => CounterNotifier());
class NotifierCounter extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(notifierCounterProvider);
final notifier = ref.read(notifierCounterProvider.notifier);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('값: $count', style: TextStyle(fontSize: 30)),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(onPressed: notifier.increment, icon: Icon(Icons.add)),
IconButton(onPressed: notifier.decrement, icon: Icon(Icons.remove)),
],
)
],
);
}
}
void main() {
runApp(
// 2. 앱을 ProviderScope로 감싸야 riverpod이 작동함!
ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterScreen(),
);
}
}
// 3. 상태를 사용하려면 ConsumerWidget 또는 ConsumerStatefulWidget이 필요함!
class CounterScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: Text('Riverpod 카운터')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
NotifierCounter(),
],
),
),
);
}
}
🎯 정리 요약
Provider 종류 | 용도 | 언제 사용하면 좋을까? |
StateProvider | 간단한 상태 (숫자, 문자열 등) | 버튼 클릭 카운터 등 |
FutureProvider | 비동기 값 (API 호출 등) | 서버 데이터 불러오기 |
StreamProvider | 실시간 데이터 (스트림) | 채팅, 센서, 실시간 알림 |
NotifierProvider | 상태 + 로직 같이 관리 | 로그인, 장바구니, 필터 등 복잡한 로직 처리 |
마무리하며...
이번 글에서는 riverpod의 고급 Provider들인 FutureProvider, StreamProvider, NotifierProvider에 대해 배워봤어요.
다음 글에서는 이들을 실제 앱 기능에 어떻게 적용할 수 있는지,
예를 들어 로그인 처리, API 연동, Firebase 스트림 활용 같은 예제 중심으로 소개해볼게요.