# Riverpod 소개 및 실습 Riverpod는 Flutter 앱에서 상태 관리를 위한 현대적인 솔루션으로, Provider 패키지의 다음 단계 진화형입니다. Provider의 창시자인 Remi Rousselet이 개발한 이 라이브러리는 Provider의 장점을 유지하면서 몇 가지 핵심적인 문제점을 해결합니다. 이 장에서는 Riverpod의 개념, 장점, 그리고 실제 사용법에 대해 알아보겠습니다. ## Riverpod이란? Riverpod는 "Provider"의 애너그램(글자를 재배열한 단어)으로, Provider의 제한사항을 해결하기 위해 처음부터 다시 설계된 상태 관리 라이브러리입니다. Provider가 InheritedWidget을 기반으로 하는 반면, Riverpod는 위젯 트리와 완전히 독립적으로 작동합니다. ```mermaid graph TD A[Riverpod] --> B[컴파일 타임
안전성] A --> C[위젯 트리
독립성] A --> D[캐싱 및
중복 제거] A --> E[강력한
비동기 지원] A --> F[의존성
오버라이드] ``` ### Riverpod vs Provider Riverpod가 Provider와 비교하여 갖는 주요 장점은 다음과 같습니다: 1. **컴파일 타임 안전성**: 존재하지 않는 Provider를 참조하면 컴파일 오류 발생 2. **위젯 트리 독립성**: BuildContext 없이도 Provider에 접근 가능 3. **Provider 결합**: 여러 Provider를 쉽게 결합 가능 4. **자동 캐싱 및 중복 제거**: 동일한 Provider에 대한 요청이 중복되지 않음 5. **강력한 비동기 지원**: Future와 Stream 처리를 위한 기본 지원 6. **테스트 용이성**: Provider의 값을 쉽게 오버라이드하여 테스트 가능 ## Riverpod 설치하기 Riverpod를 사용하기 위해 먼저 필요한 패키지를 설치해야 합니다: ```yaml dependencies: flutter: sdk: flutter flutter_riverpod: ^2.3.6 # 최신 버전 확인 riverpod_annotation: ^2.1.1 dev_dependencies: build_runner: ^2.3.3 riverpod_generator: ^2.2.3 ``` `flutter_riverpod`는 Flutter 앱에서 Riverpod를 사용하기 위한 패키지이고, `riverpod_annotation`와 `riverpod_generator`는 코드 생성을 위한 패키지입니다. ## Riverpod 시작하기 ### 1. ProviderScope 설정 Riverpod를 사용하는 첫 번째 단계는 앱의 루트에 `ProviderScope` 위젯을 배치하는 것입니다: ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { runApp( // ProviderScope는 Riverpod의 모든 Provider를 관리합니다 ProviderScope( child: MyApp(), ), ); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Riverpod Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: HomePage(), ); } } ``` ### 2. Provider 정의 Riverpod에서는 여러 종류의 Provider를 제공합니다: ```dart // 1. Provider - 읽기 전용 값 제공 final helloWorldProvider = Provider((ref) => 'Hello, World!'); // 2. StateProvider - 단순한 상태 관리 final counterProvider = StateProvider((ref) => 0); // 3. StateNotifierProvider - 복잡한 상태 관리 final todosProvider = StateNotifierProvider>((ref) => TodosNotifier()); // 4. FutureProvider - 비동기 데이터 로드 final userProvider = FutureProvider((ref) => fetchUser()); // 5. StreamProvider - 스트림 데이터 구독 final messagesProvider = StreamProvider>((ref) => fetchMessages()); ``` ### 3. Consumer 위젯으로 Provider 사용하기 Provider의 값을 읽기 위해 `Consumer` 또는 `ConsumerWidget`을 사용할 수 있습니다: ```dart // Consumer 위젯 사용 class CounterWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Consumer( builder: (context, ref, child) { final count = ref.watch(counterProvider); return Text('카운트: $count'); }, ); } } // ConsumerWidget 사용 (더 간단함) class CounterWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider); return Text('카운트: $count'); } } ``` ### 4. ref 객체 사용하기 `ref` 객체는 Provider와 상호 작용하는 핵심 객체로, 다음과 같은 메서드를 제공합니다: ```dart // Provider 값 읽기 및 변경 감지 (UI 자동 업데이트) final count = ref.watch(counterProvider); // Provider 값 읽기 (변경 감지 없음) final count = ref.read(counterProvider); // 상태 변경을 수신하는 리스너 등록 ref.listen( counterProvider, (previous, next) { print('카운터가 $previous에서 $next로 변경됨'); }, ); // Provider 값 강제로 새로고침 ref.refresh(userProvider); // StateProvider 값 변경 ref.read(counterProvider.notifier).state++; ``` ## Riverpod의 주요 개념 ### Provider와 ref Riverpod에서는 두 가지 핵심 개념이 있습니다: 1. **Provider**: 상태를 정의하고 외부에 노출하는 객체 2. **ref**: Provider에 접근하고 상호 작용하는 객체 ```mermaid graph LR A[Provider] <--> B[ref] A --> C[상태 정의/노출] B --> D[상태 접근/조작] B --> E[다른 Provider 접근] B --> F[수명주기 관리] ``` ### Riverpod의 자동 의존성 처리 Riverpod의 가장 강력한 기능 중 하나는 Provider 간의 자동 의존성 처리입니다: ```dart // 첫 번째 Provider final cityProvider = StateProvider((ref) => '서울'); // 두 번째 Provider (첫 번째에 의존) final weatherProvider = FutureProvider((ref) async { final city = ref.watch(cityProvider); return fetchWeather(city); // city가 변경되면 자동으로 다시 실행 }); ``` 이 예제에서 `weatherProvider`는 `cityProvider`에 의존합니다. `cityProvider`의 값이 변경되면 `weatherProvider`는 자동으로 재계산됩니다. ## Riverpod 코드 생성 기능 Riverpod 2.0부터는 애노테이션과 코드 생성을 사용하여 더 간결하게 Provider를 정의할 수 있습니다: ```dart import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'counter.g.dart'; // 코드 생성을 위한 애노테이션 사용 @riverpod class Counter extends _$Counter { @override int build() { return 0; // 초기값 } void increment() { state = state + 1; } } // 사용 방법 (counterProvider가 자동으로 생성됨) final value = ref.watch(counterProvider); ref.read(counterProvider.notifier).increment(); ``` 코드 생성을 실행하려면 다음 명령을 사용합니다: ```bash flutter pub run build_runner build ``` ### 비동기 Provider 정의 비동기 데이터를 처리하는 Provider도 쉽게 정의할 수 있습니다: ```dart @riverpod Future user(UserRef ref) async { final userId = ref.watch(userIdProvider); return await fetchUser(userId); } // 사용 방법 ref.watch(userProvider).when( data: (user) => Text(user.name), loading: () => CircularProgressIndicator(), error: (error, stack) => Text('에러: $error'), ); ``` ## Riverpod 실전 사용법 이제 Riverpod를 사용하여 간단한 할 일 목록 앱을 구현해보겠습니다. ### 1. 모델 정의 ```dart // todo.dart import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter/foundation.dart'; part 'todo.freezed.dart'; part 'todo.g.dart'; @freezed class Todo with _$Todo { const factory Todo({ required String id, required String title, @Default(false) bool completed, }) = _Todo; factory Todo.fromJson(Map json) => _$TodoFromJson(json); } ``` ### 2. Provider 정의 ```dart // todo_provider.dart import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:uuid/uuid.dart'; import 'todo.dart'; part 'todo_provider.g.dart'; @riverpod class TodoList extends _$TodoList { @override List build() { return []; // 초기 빈 목록 } void addTodo(String title) { final newTodo = Todo( id: const Uuid().v4(), title: title, ); state = [...state, newTodo]; } void toggleTodo(String id) { state = [ for (final todo in state) if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo, ]; } void removeTodo(String id) { state = state.where((todo) => todo.id != id).toList(); } } @riverpod int completedTodosCount(CompletedTodosCountRef ref) { final todos = ref.watch(todoListProvider); return todos.where((todo) => todo.completed).length; } @riverpod int uncompletedTodosCount(UncompletedTodosCountRef ref) { final todos = ref.watch(todoListProvider); return todos.where((todo) => !todo.completed).length; } ``` ### 3. UI 구현 ```dart // todo_screen.dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'todo_provider.dart'; import 'todo.dart'; class TodoScreen extends ConsumerWidget { final TextEditingController _controller = TextEditingController(); @override Widget build(BuildContext context, WidgetRef ref) { final todos = ref.watch(todoListProvider); final completedCount = ref.watch(completedTodosCountProvider); final uncompletedCount = ref.watch(uncompletedTodosCountProvider); return Scaffold( appBar: AppBar( title: const Text('Riverpod 할 일 목록'), ), body: Column( children: [ Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ Expanded( child: TextField( controller: _controller, decoration: const InputDecoration( labelText: '할 일 추가', border: OutlineInputBorder(), ), ), ), const SizedBox(width: 16), ElevatedButton( onPressed: () { if (_controller.text.isNotEmpty) { ref.read(todoListProvider.notifier).addTodo(_controller.text); _controller.clear(); } }, child: const Text('추가'), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('완료: $completedCount'), Text('미완료: $uncompletedCount'), ], ), ), const Divider(), Expanded( child: ListView.builder( itemCount: todos.length, itemBuilder: (context, index) { final todo = todos[index]; return ListTile( leading: Checkbox( value: todo.completed, onChanged: (_) { ref.read(todoListProvider.notifier).toggleTodo(todo.id); }, ), title: Text( todo.title, style: TextStyle( decoration: todo.completed ? TextDecoration.lineThrough : TextDecoration.none, ), ), trailing: IconButton( icon: const Icon(Icons.delete), onPressed: () { ref.read(todoListProvider.notifier).removeTodo(todo.id); }, ), ); }, ), ), ], ), ); } } ``` ## 고급 Riverpod 기법 ### 1. 비동기 데이터 처리 `AsyncValue`는 비동기 데이터의 세 가지 상태(데이터, 로딩, 오류)를 표현하는 편리한 클래스입니다: ```dart @riverpod class UserRepository extends _$UserRepository { @override Future build(String userId) async { return fetchUser(userId); } } // UI에서 사용 class UserProfile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final userAsync = ref.watch(userRepositoryProvider('user-123')); return userAsync.when( data: (user) => Text('이름: ${user.name}'), loading: () => const CircularProgressIndicator(), error: (error, stackTrace) => Text('에러: $error'), ); } } ``` ### 2. 패밀리 Provider (매개변수가 있는 Provider) 매개변수를 받는 Provider를 정의할 수 있습니다: ```dart @riverpod Future product(ProductRef ref, String productId) { return fetchProduct(productId); } // UI에서 사용 final product = ref.watch(productProvider('product-123')); ``` ### 3. Provider 캐싱 및 자동 폐기 Riverpod는 Provider 인스턴스를 자동으로 캐싱하고, 더 이상 사용되지 않을 때 정리합니다: ```dart @riverpod class ProductsRepository extends _$ProductsRepository { @override Future> build() async { // API 호출 print('상품 로드 중...'); return fetchProducts(); } @override void dispose() { print('ProductsRepository 폐기됨'); super.dispose(); } } ``` ### 4. 상태 동기화 여러 Provider 간에 상태를 동기화하는 것이 쉽습니다: ```dart @riverpod class AuthState extends _$AuthState { @override User? build() => null; Future login(String username, String password) async { state = await authService.login(username, password); } void logout() { state = null; } } @riverpod class CartRepository extends _$CartRepository { @override List build() { final user = ref.watch(authStateProvider); // 사용자가 로그아웃하면 자동으로 장바구니 비우기 if (user == null) { return []; } // 사용자에 따라 장바구니 데이터 로드 return loadCartItems(user.id); } } ``` ## Riverpod의 주요 사용 패턴 ### 1. 데이터 로직 분리 패턴 데이터 로직을 UI에서 분리하는 패턴을 사용합니다: ```dart // Repository - 데이터 액세스 로직 @riverpod class ProductsRepository extends _$ProductsRepository { @override Future> build() async { return api.fetchProducts(); } } // Notifier - 비즈니스 로직 @riverpod class ProductsFilter extends _$ProductsFilter { @override FilterCriteria build() { return FilterCriteria(); } void setCategory(String category) { state = state.copyWith(category: category); } void setPriceRange(double min, double max) { state = state.copyWith(minPrice: min, maxPrice: max); } } // ViewModel - 화면에 표시할 데이터 준비 @riverpod Future> filteredProducts(FilteredProductsRef ref) async { final products = await ref.watch(productsRepositoryProvider.future); final filter = ref.watch(productsFilterProvider); return products .where((p) => p.category == filter.category) .where((p) => p.price >= filter.minPrice && p.price <= filter.maxPrice) .map((p) => ProductViewModel.fromProduct(p)) .toList(); } ``` ### 2. 자동 새로고침 패턴 Provider가 의존하는 다른 Provider가 업데이트되면 자동으로 새로고침됩니다: ```dart @riverpod class SearchQuery extends _$SearchQuery { @override String build() => ''; void setQuery(String query) { state = query; } } @riverpod Future> searchResults(SearchResultsRef ref) async { final query = ref.watch(searchQueryProvider); // 검색어가 없으면 빈 결과 반환 if (query.isEmpty) { return []; } // 검색어가 변경될 때마다 자동으로 새 검색 수행 return searchApi.search(query); } ``` ### 3. 오버라이드 패턴 테스트나 개발 환경에서 Provider 값을 오버라이드할 수 있습니다: ```dart void main() { runApp( ProviderScope( overrides: [ // 실제 API 대신 목업 API 사용 apiProvider.overrideWithValue(MockApi()), // 초기 상태 설정 authStateProvider.overrideWith( (ref) => AuthState()..state = User(id: 'test-user', name: '테스트 사용자'), ), ], child: MyApp(), ), ); } ``` ## Riverpod의 성능 최적화 ### 1. 세분화된 Provider 설계 Provider를 세분화하여 불필요한 리빌드를 방지합니다: ```dart // 나쁜 예시 - 하나의 거대한 Provider @riverpod class AppState extends _$AppState { @override AppStateModel build() { return AppStateModel( user: User(), products: [], cart: Cart(), // 기타 많은 상태들... ); } } // 좋은 예시 - 세분화된 Provider @riverpod class UserState extends _$UserState { @override User build() => User(); } @riverpod class ProductsState extends _$ProductsState { @override List build() => []; } @riverpod class CartState extends _$CartState { @override Cart build() => Cart(); } ``` ### 2. select 메서드 사용 객체의 특정 속성만 감시하여 불필요한 리빌드를 방지합니다: ```dart // 전체 사용자 객체 변경 시 리빌드 final user = ref.watch(userProvider); final name = user.name; // 이름이 변경될 때만 리빌드 final name = ref.watch(userProvider.select((user) => user.name)); ``` ### 3. autoDispose 수정자 사용 Provider가 더 이상 사용되지 않을 때 자동으로 폐기하려면 `autoDispose` 수정자를 사용합니다: ```dart @riverpod class SearchResults extends _$SearchResults { @override Future> build() async { // 화면이 닫히면 이 Provider는 자동으로 폐기됨 return api.search(); } } ``` ## Riverpod와 Flutter Hooks 사용하기 Flutter Hooks와 Riverpod를 함께 사용하면 더욱 간결한 코드를 작성할 수 있습니다: ```dart import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // hooks_riverpod 패키지 추가 필요 class TodoForm extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // Flutter Hook을 사용하여 상태 관리 final textController = useTextEditingController(); final isFocused = useState(false); // Riverpod Provider 사용 final todos = ref.watch(todoListProvider); return Column( children: [ TextField( controller: textController, onFocusChange: (focus) => isFocused.value = focus, decoration: InputDecoration( labelText: '할 일 추가', border: OutlineInputBorder(), ), ), ElevatedButton( onPressed: () { if (textController.text.isNotEmpty) { ref.read(todoListProvider.notifier).addTodo(textController.text); textController.clear(); } }, child: Text('추가'), ), ], ); } } ``` ## 요약 - **Riverpod**는 Provider의 제한사항을 해결하기 위해 개발된 현대적인 상태 관리 라이브러리입니다. - **컴파일 타임 안전성**, **위젯 트리 독립성**, **자동 의존성 처리** 등의 장점을 제공합니다. - **코드 생성**을 통해 더 간결한 코드를 작성할 수 있습니다. - **AsyncValue**를 통해 비동기 데이터를 쉽게 처리할 수 있습니다. - **Provider 세분화**, **select 메서드**, **autoDispose** 등을 통해 성능을 최적화할 수 있습니다. Riverpod는 Provider의 장점을 유지하면서 몇 가지 핵심적인 문제점을 해결한 강력한 상태 관리 솔루션입니다. 특히 중대형 앱의 개발에서 코드 유지보수성, 테스트 용이성, 성능 최적화에 큰 도움을 줄 수 있습니다. 다음 장에서는 실제 TodoList 앱을 개선하면서 Riverpod의 실전 사용법을 자세히 알아보겠습니다.