Riverpod이란 무엇인가
Riverpod은 Flutter의 상태 관리 라이브러리입니다. Provider의 개선된 버전으로, 컴파일 타임 안전성과 더 나은 테스트 가능성을 제공합니다.
왜 Riverpod을 써야 할까요?
• 컴파일 타임 에러 감지: 런타임이 아닌 코딩 중에 문제 발견
• Provider 충돌 없음: 같은 타입의 여러 Provider 사용 가능
• 테스트 용이: Provider 오버라이드로 쉬운 목 처리
• 자동 dispose: 메모리 관리 자동화
왜 Riverpod을 써야 할까요?
• 컴파일 타임 에러 감지: 런타임이 아닌 코딩 중에 문제 발견
• Provider 충돌 없음: 같은 타입의 여러 Provider 사용 가능
• 테스트 용이: Provider 오버라이드로 쉬운 목 처리
• 자동 dispose: 메모리 관리 자동화
Provider의 기본 개념
Provider는 "전역 변수의 더 나은 버전"입니다. 전역으로 접근 가능하지만, 의존성 주입과 생명주기 관리가 자동으로 됩니다.
Provider의 종류와 용도
Dart
// 1. Provider - 기본, 변하지 않는 값
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(baseUrl: 'https://api.example.com');
});
// 2. StateProvider - 간단한 상태 (카운터, 토글 등)
final counterProvider = StateProvider<int>((ref) => 0);
// 3. FutureProvider - 비동기 데이터
final userProvider = FutureProvider<User>((ref) async {
final client = ref.read(apiClientProvider);
return client.getUser();
});
// 4. StreamProvider - 실시간 데이터
final messagesProvider = StreamProvider<List<Message>>((ref) {
final client = ref.read(apiClientProvider);
return client.messageStream();
});
// 5. StateNotifierProvider - 복잡한 상태
final todoListProvider = StateNotifierProvider<TodoNotifier, List<Todo>>((ref) {
return TodoNotifier();
});
// 6. NotifierProvider - Riverpod 2.0의 새로운 방식
final asyncTodosProvider = AsyncNotifierProvider<AsyncTodosNotifier, List<Todo>>(() {
return AsyncTodosNotifier();
});
ref의 세 가지 메서드 이해하기
ref는 Provider 간 상호작용의 핵심입니다.
1. ref.read - 한 번 읽기
Dart
final submitButtonProvider = Provider<VoidCallback>((ref) {
return () {
// 버튼 클릭 시 한 번만 읽기
final user = ref.read(userProvider);
final api = ref.read(apiClientProvider);
api.updateUser(user);
};
});
언제 사용?
• 이벤트 핸들러 (onPressed, onTap)
• 초기화 로직
• 일회성 작업
• 이벤트 핸들러 (onPressed, onTap)
• 초기화 로직
• 일회성 작업
2. ref.watch - 구독하고 리빌드
Dart
final greetingProvider = Provider<String>((ref) {
// userProvider가 변경되면 greetingProvider도 재계산
final user = ref.watch(userProvider);
return 'Hello, ${user.name}!';
});
// Widget에서
class GreetingWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// userProvider 변경 시 위젯 리빌드
final greeting = ref.watch(greetingProvider);
return Text(greeting);
}
}
언제 사용?
• UI 빌드 메서드
• 다른 Provider 계산
• 반응형 로직
• UI 빌드 메서드
• 다른 Provider 계산
• 반응형 로직
3. ref.listen - 부수 효과 처리
Dart
class HomeScreen extends ConsumerStatefulWidget {
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
@override
Widget build(BuildContext context) {
// 에러 발생 시 스낵바 표시
ref.listen(authProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('로그인 실패: ${next.error}')),
);
}
// 로그인 성공 시 화면 이동
if (previous?.value == null && next.value != null) {
Navigator.pushReplacementNamed(context, '/dashboard');
}
});
return Scaffold(
// UI 구성
);
}
}
언제 사용?
• 스낵바, 다이얼로그 표시
• 네비게이션
• 로깅, 애널리틱스
• 스낵바, 다이얼로그 표시
• 네비게이션
• 로깅, 애널리틱스
ConsumerWidget vs ConsumerStatefulWidget
ConsumerWidget - 상태 없는 위젯
Dart
class ProductCard extends ConsumerWidget {
final String productId;
const ProductCard({required this.productId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final product = ref.watch(productProvider(productId));
return Card(
child: Column(
children: [
Text(product.name),
Text('\${product.price}'),
ElevatedButton(
onPressed: () {
// read 사용 - 리빌드 불필요
ref.read(cartProvider.notifier).add(product);
},
child: Text('Add to Cart'),
),
],
),
);
}
}
ConsumerStatefulWidget - 상태 있는 위젯
Dart
class SearchScreen extends ConsumerStatefulWidget {
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final TextEditingController _controller = TextEditingController();
Timer? _debounceTimer;
@override
void initState() {
super.initState();
// initState에서 ref 사용 가능
_controller.text = ref.read(searchQueryProvider);
_controller.addListener(() {
_debounceTimer?.cancel();
_debounceTimer = Timer(Duration(milliseconds: 300), () {
ref.read(searchQueryProvider.notifier).state = _controller.text;
});
});
}
@override
void dispose() {
_controller.dispose();
_debounceTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final searchResults = ref.watch(searchResultsProvider);
return Scaffold(
body: Column(
children: [
TextField(controller: _controller),
Expanded(
child: ListView.builder(
itemCount: searchResults.length,
itemBuilder: (context, index) {
return ListTile(title: Text(searchResults[index].name));
},
),
),
],
),
);
}
}
Family와 AutoDispose 수식어
Family - 매개변수가 있는 Provider
Dart
// 제품 ID별로 다른 Provider 인스턴스
final productProvider = FutureProvider.family<Product, String>((ref, productId) async {
final api = ref.read(apiClientProvider);
return api.getProduct(productId);
});
// 사용
Widget build(BuildContext context, WidgetRef ref) {
final product = ref.watch(productProvider('product-123'));
return Text(product.when(
data: (p) => p.name,
loading: () => 'Loading...',
error: (e, s) => 'Error: $e',
));
}
Family의 용도:
• 동적 ID로 데이터 조회
• 파라미터별 캐싱
• 여러 인스턴스 관리
• 동적 ID로 데이터 조회
• 파라미터별 캐싱
• 여러 인스턴스 관리
AutoDispose - 자동 정리
Dart
// 사용하지 않으면 자동으로 dispose
final searchResultsProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
final query = ref.watch(searchQueryProvider);
final api = ref.read(apiClientProvider);
return api.searchProducts(query);
});
// Family와 함께 사용
final productDetailsProvider = FutureProvider.family.autoDispose<Product, String>(
(ref, productId) async {
// 화면 떠나면 자동 정리
final api = ref.read(apiClientProvider);
return api.getProduct(productId);
},
);
// keepAlive로 유지하기
final importantDataProvider = FutureProvider.autoDispose<ImportantData>((ref) async {
// 특정 조건에서만 유지
final shouldKeepAlive = await checkCondition();
if (shouldKeepAlive) {
ref.keepAlive(); // dispose 방지
}
return fetchImportantData();
});
StateNotifier 패턴 실전
복잡한 비즈니스 로직을 처리할 때 StateNotifier를 사용합니다.
Dart
// 상태 클래스 (Freezed 사용)
@freezed
class CartState with _$CartState {
const factory CartState({
@Default([]) List<CartItem> items,
@Default(0.0) double total,
@Default(false) bool isLoading,
String? errorMessage,
PromoCode? appliedPromo,
}) = _CartState;
}
// StateNotifier
class CartNotifier extends StateNotifier<CartState> {
final ApiClient _api;
final LocalStorage _storage;
CartNotifier(this._api, this._storage) : super(const CartState()) {
_loadCart();
}
Future<void> _loadCart() async {
state = state.copyWith(isLoading: true);
try {
final items = await _storage.getCartItems();
final total = _calculateTotal(items);
state = state.copyWith(
items: items,
total: total,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),
);
}
}
void addItem(Product product, {int quantity = 1}) {
final existingIndex = state.items.indexWhere((item) => item.product.id == product.id);
List<CartItem> newItems;
if (existingIndex != -1) {
// 이미 있으면 수량 증가
newItems = [...state.items];
newItems[existingIndex] = newItems[existingIndex].copyWith(
quantity: newItems[existingIndex].quantity + quantity,
);
} else {
// 새 아이템 추가
newItems = [
...state.items,
CartItem(product: product, quantity: quantity),
];
}
state = state.copyWith(
items: newItems,
total: _calculateTotal(newItems),
);
_saveCart();
}
void removeItem(String productId) {
final newItems = state.items.where((item) => item.product.id != productId).toList();
state = state.copyWith(
items: newItems,
total: _calculateTotal(newItems),
);
_saveCart();
}
Future<void> checkout() async {
state = state.copyWith(isLoading: true);
try {
final order = await _api.createOrder(
items: state.items,
promoCode: state.appliedPromo?.code,
);
// 결제 성공 후 카트 비우기
await _storage.clearCart();
state = const CartState(); // 초기 상태로
// 주문 완료 화면으로 이동 등의 처리
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: '결제 처리 중 오류가 발생했습니다',
);
}
}
double _calculateTotal(List<CartItem> items) {
return items.fold(0.0, (sum, item) => sum + (item.product.price * item.quantity));
}
Future<void> _saveCart() async {
try {
await _storage.saveCartItems(state.items);
} catch (e) {
print('카트 저장 실패: $e');
}
}
}
// Provider 정의
final cartProvider = StateNotifierProvider<CartNotifier, CartState>((ref) {
final api = ref.read(apiClientProvider);
final storage = ref.read(localStorageProvider);
return CartNotifier(api, storage);
});
파생 Provider들:
Dart
final cartItemCountProvider = Provider<int>((ref) {
final cart = ref.watch(cartProvider);
return cart.items.fold(0, (sum, item) => sum + item.quantity);
});
final cartTotalProvider = Provider<double>((ref) {
return ref.watch(cartProvider).total;
});
final hasPromoProvider = Provider<bool>((ref) {
return ref.watch(cartProvider).appliedPromo != null;
});
AsyncNotifier - 비동기 상태 관리의 새로운 방식
Riverpod 2.0에서 도입된 AsyncNotifier는 비동기 작업을 더 우아하게 처리합니다.
Dart
// AsyncNotifier 구현
class ProductListNotifier extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
// 초기 데이터 로드
return _fetchProducts();
}
Future<List<Product>> _fetchProducts({
String? category,
String? searchQuery,
SortOrder? sortOrder,
}) async {
final api = ref.read(apiClientProvider);
final products = await api.getProducts(
category: category,
query: searchQuery,
sort: sortOrder,
);
return products;
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetchProducts());
}
Future<void> searchProducts(String query) async {
state = const AsyncValue.loading();
try {
final products = await _fetchProducts(searchQuery: query);
state = AsyncValue.data(products);
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
}
}
Future<void> filterByCategory(String category) async {
// 기존 데이터 유지하면서 로딩 표시
state = const AsyncValue.loading().copyWithPrevious(state);
state = await AsyncValue.guard(() => _fetchProducts(category: category));
}
Future<void> addProduct(Product product) async {
final api = ref.read(apiClientProvider);
try {
final newProduct = await api.createProduct(product);
// 기존 리스트에 추가
final currentProducts = state.value ?? [];
state = AsyncValue.data([...currentProducts, newProduct]);
} catch (error, stackTrace) {
// 에러 처리하되 기존 데이터 유지
state = AsyncValue.error(error, stackTrace).copyWithPrevious(state);
}
}
Future<void> deleteProduct(String productId) async {
final api = ref.read(apiClientProvider);
// 낙관적 업데이트 (UI 먼저 업데이트)
final currentProducts = state.value ?? [];
state = AsyncValue.data(
currentProducts.where((p) => p.id != productId).toList(),
);
try {
await api.deleteProduct(productId);
} catch (error) {
// 실패 시 롤백
state = AsyncValue.data(currentProducts);
rethrow;
}
}
}
Provider 오버라이드와 테스트
테스트에서 Provider 모킹
Dart
void main() {
testWidgets('상품 목록 표시 테스트', (tester) async {
final mockProducts = [
Product(id: '1', name: 'Product 1', price: 100),
Product(id: '2', name: 'Product 2', price: 200),
];
await tester.pumpWidget(
ProviderScope(
overrides: [
// Provider 오버라이드
productListProvider.overrideWith(() {
return MockProductListNotifier(mockProducts);
}),
],
child: MaterialApp(
home: ProductListScreen(),
),
),
);
// 로딩 표시
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 데이터 로드 완료 대기
await tester.pumpAndSettle();
// 상품 표시 확인
expect(find.text('Product 1'), findsOneWidget);
expect(find.text('Product 2'), findsOneWidget);
});
test('카트에 상품 추가 테스트', () async {
final container = ProviderContainer(
overrides: [
apiClientProvider.overrideWithValue(MockApiClient()),
],
);
final cart = container.read(cartProvider.notifier);
final product = Product(id: '1', name: 'Test', price: 100);
cart.addItem(product);
final state = container.read(cartProvider);
expect(state.items.length, 1);
expect(state.total, 100);
container.dispose();
});
}
개발/운영 환경별 설정
Dart
// 환경 설정 Provider
final environmentProvider = Provider<Environment>((ref) {
return Environment.fromString(
const String.fromEnvironment('ENV', defaultValue: 'dev'),
);
});
// API Client Provider (환경별 다른 설정)
final apiClientProvider = Provider<ApiClient>((ref) {
final env = ref.watch(environmentProvider);
switch (env) {
case Environment.dev:
return ApiClient(
baseUrl: 'https://dev-api.example.com',
timeout: Duration(seconds: 30),
enableLogging: true,
);
case Environment.staging:
return ApiClient(
baseUrl: 'https://staging-api.example.com',
timeout: Duration(seconds: 15),
enableLogging: true,
);
case Environment.production:
return ApiClient(
baseUrl: 'https://api.example.com',
timeout: Duration(seconds: 10),
enableLogging: false,
);
}
});
// 앱 시작
void main() {
runApp(
ProviderScope(
overrides: [
// 개발 중에는 목 데이터 사용
if (kDebugMode) ...[
productListProvider.overrideWith(() => MockProductListNotifier()),
],
],
child: MyApp(),
),
);
}
고급 패턴과 최적화
1. Select를 사용한 부분 리빌드
Dart
// 전체 상태가 아닌 특정 필드만 구독
class CartBadge extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// items.length만 변경될 때 리빌드
final itemCount = ref.watch(cartProvider.select((cart) => cart.items.length));
return Badge(
label: Text('$itemCount'),
child: Icon(Icons.shopping_cart),
);
}
}
2. 캐싱과 무효화 전략
Dart
// 캐시 시간 설정
final cachedDataProvider = FutureProvider.autoDispose<Data>((ref) async {
// 5분 동안 캐시 유지
ref.cacheFor(const Duration(minutes: 5));
return fetchData();
});
// 수동 무효화
class DataManager {
final WidgetRef ref;
DataManager(this.ref);
void refreshData() {
// 특정 Provider 무효화
ref.invalidate(cachedDataProvider);
}
void refreshAll() {
// 모든 Provider 무효화
ref.invalidateAll();
}
}
3. Provider 의존성 그래프
Dart
// 의존성 체인
final userProvider = FutureProvider<User>((ref) async {
return fetchUser();
});
final userSettingsProvider = FutureProvider<Settings>((ref) async {
final user = await ref.watch(userProvider.future);
return fetchSettings(user.id);
});
final themeProvider = Provider<ThemeData>((ref) {
final settingsAsync = ref.watch(userSettingsProvider);
return settingsAsync.when(
data: (settings) => settings.isDarkMode ? darkTheme : lightTheme,
loading: () => lightTheme,
error: (_, __) => lightTheme,
);
});
4. Debounce와 Throttle
Dart
// Debounce 검색
class SearchNotifier extends StateNotifier<String> {
Timer? _debounceTimer;
SearchNotifier() : super('');
void updateQuery(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(Duration(milliseconds: 300), () {
state = query;
});
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
}
// Throttle 스크롤
class ScrollNotifier extends StateNotifier<double> {
DateTime? _lastUpdate;
static const _throttleDuration = Duration(milliseconds: 100);
ScrollNotifier() : super(0.0);
void updatePosition(double position) {
final now = DateTime.now();
if (_lastUpdate == null ||
now.difference(_lastUpdate!) > _throttleDuration) {
_lastUpdate = now;
state = position;
}
}
}
성능 최적화 팁
1. 불필요한 리빌드 방지
Dart
// Bad - 전체 상태 구독
final widget = ref.watch(complexStateProvider);
// Good - 필요한 부분만 구독
final specificValue = ref.watch(
complexStateProvider.select((state) => state.specificField)
);
2. 비동기 작업 최적화
Dart
// 병렬 처리
final combinedProvider = FutureProvider<CombinedData>((ref) async {
final results = await Future.wait([
ref.watch(dataProvider1.future),
ref.watch(dataProvider2.future),
ref.watch(dataProvider3.future),
]);
return CombinedData(
data1: results[0],
data2: results[1],
data3: results[2],
);
});
3. 메모리 관리
Dart
// 자동 dispose 활용
final heavyDataProvider = FutureProvider.autoDispose<HeavyData>((ref) async {
// 화면 벗어나면 자동 정리
return fetchHeavyData();
});
// 선택적 유지
final importantProvider = Provider.autoDispose<Important>((ref) {
// 조건부로 유지
if (shouldKeepAlive) {
ref.keepAlive();
}
return Important();
});
마무리
Riverpod은 Flutter의 강력한 상태 관리 도구입니다. 핵심 개념을 정리하면:
• Provider 종류: 용도에 맞는 Provider 선택
• ref 메서드: read(이벤트), watch(리빌드), listen(부수효과)
• 수식어: family(파라미터), autoDispose(자동정리)
• StateNotifier/AsyncNotifier: 복잡한 상태 관리
• 테스트: Provider 오버라이드로 쉬운 모킹
Riverpod의 진정한 힘은 이 모든 기능이 타입 안전하고, 테스트 가능하며, 성능 최적화가 쉽다는 점입니다. 작은 프로젝트부터 시작해서 점진적으로 고급 기능을 적용해보세요.
• Provider 종류: 용도에 맞는 Provider 선택
• ref 메서드: read(이벤트), watch(리빌드), listen(부수효과)
• 수식어: family(파라미터), autoDispose(자동정리)
• StateNotifier/AsyncNotifier: 복잡한 상태 관리
• 테스트: Provider 오버라이드로 쉬운 모킹
Riverpod의 진정한 힘은 이 모든 기능이 타입 안전하고, 테스트 가능하며, 성능 최적화가 쉽다는 점입니다. 작은 프로젝트부터 시작해서 점진적으로 고급 기능을 적용해보세요.