이 글은 Riverpod 2.0 기준으로 작성되었습니다.
이번 포스트에서는 공식 문서를 보면서 Riverpod에 대해 알아보고, 왜 Riverpod을 사용해야 하는지에 대해 알아보겠습니다.
사용 방법은 간략하게만 다루었으며 Provider의 종류에 대한 정리는 패키지가 워낙 자주 바뀌기도 하고 공식 홈페이지에 워낙 잘 정리되어 있어서 참고하시면 좋을 것 같습니다.
Flutter의 상태 관리
Flutter는 선언형 UI이며 각각의 상태를 포함하고 있는 위젯 트리를 기반으로 구성되기 때문에 상태 관리
가 매우 중요합니다. 단일 위젯에서만 사용하는 상태일 경우는 상태 관리가 특별하게 관리되지 않아도 되지만, 앱의 여러 위젯에서 상태를 사용하는 경우에는 상태 관리가 특히 더 중요합니다. 이러한 여러 위젯에서 사용하는 상태를 App State라고 부르며 App State 관리를 지원하는 여러 라이브러리가 존재하는데 Riverpod 또한 이 App State를 관리를 위한 상태 관리 라이브러리입니다.
자세한 내용은 아래 포스트에 정리되어 있습니다.
[Flutter] 상태 관리(State Management) 정리
Riverpod이란?
Riverpod
은 InheritedWidget과 비슷한 동작을 하는 새로운 매커니즘으로 재구현한 상태 관리 패키지입니다. Riverpod의 공식 사이트에서는 Riverpod을 리액티브 캐싱, 데이터 바인딩 프레임워크라고 소개되어 있습니다. 그래서 먼저 리액티브 캐싱과 데이터 바인딩이 무엇인지 알아보겠습니다.
리액티브 캐싱과 데이터 바인딩
리액티브 캐싱(Reactive Caching)
-> 리액티브 캐싱이란, 데이터를 비동기적으로 계산 할 때 캐싱하여 해당 데이터가 필요한 모든 곳에서 쉽게 접근할 수 있도록 하는 기술입니다. Riverpod에서는 Provider를 이용하여 상태를 지속적으로 캐시하고 노출함으로써 데이터를 쉽게 공유하고 동기화할 수 있습니다. 예를 들어, Riverpod의 Provider를 사용하면, 처음 데이터를 가져온 뒤에는 동일한 데이터를 다른 컴포넌트에서 다시 가져올 필요 없이, 이미 캐시된 데이터를 활용하여 중복 호출을 줄이며 상태를 효율적으로 관리하고 성능을 향상시킬 수 있습니다.
데이터 바인딩(Data Binding)
-> 데이터 바인딩이란, UI와 데이터를 결합하는 기술로 데이터의 변경에 따라 UI를 자동으로 변경하도록 해줍니다. Riverpod을 이용하면 상태를 나타내는 Provider 및 ref 객체의 watch 메소드를 통해서 상태의 변경을 관찰하여 상태가 변경되면 UI가 자동으로 업데이트되도록 할 수 있습니다. 즉, Riverpod을 이용하면 MVVM 패턴을 구현할 수 있습니다.
Flutter는 선언형 UI 프로그래밍 방식이기 때문에 상태 관리가 매우 중요하며, Riverpod을 이용하면 상태 관리를 효율적으로 할 수 있고 데이터 바인딩을 통해 UI를 자동으로 업데이트할 수 있습니다.
Riverpod이 만들어진 이유
Riverpod은 Provider 패키지의 상위 버전 패키지입니다. 버전을 올리지 않고 새로운 패키지로 배포한 이유는 Provider의 결함을 해결하기 위해서는 Provider의 매커니즘을 크게 수정하는 방법밖에 없었기 때문에 결함을 해결한 후에 글자 순서를 재조합하여 Riverpod이라는 새로운 패키지로 만들었다고 합니다.
Provider의 결함들은 대표적으로 아래와 같은 것들이 있다고 합니다.
- 런타임에 ProviderNotFoundException이 호출되는 문제
- Provider를 더 이상 사용하지 않을 때 수동으로 dispose 해야하는 문제
- 다른 Provider를 의존하는 복잡한 Provider를 생성할 수 없는 문제
Riverpod의 장점
공식 문서와 깃허브에 적혀있는 Riverpod 패키지의 장점을 정리하겠습니다.
먼저, Riverpod은 Provider의 장점을 모두 상속하여 제작하였습니다.
그러므로, Provider의 장점을 알아보면 아래와 같습니다.
- 위젯 rebuild시 상태가 손실될 걱정 없이 create, observe, dispose 할 수 있습니다.
- lazy loading을 지원 하므로 사용자가 사용중이지 않은 Provider를 당장 로딩하지 않습니다.
- Flutter의 devetool에서 Provider 객체를 관찰할 수 있습니다. (Diagnosticable API 사용)
- 단방향의 데이터 흐름으로 앱의 확장성을 높일 수 있습니다.
Riverpod의 장점은 아래와 같습니다.
Riverpod은 Compile-safe 합니다
-> Provider 사용에 문제가 있다면 컴파일 타임에 오류를 발견해주므로 더 이상 런타임에 ProviderNotFoundException 예외가 발생하지 않습니다.
Flutter SDK에 의존하지 않습니다
-> Flutter 뿐만 아니라 Dart에서도 사용 가능합니다.
동일한 타입의 여러 개의 Provider를 가질 수 있습니다
-> Provider 패키지에서는 동일한 타입의 Provider를 만들 수 없었으며 동일한 타입을 정의하려면 커스텀 클래스를 정의해야만 했었습니다. (링크)
최소한의 보일러 플레이트 코드로 다른 Provider와 간편하게 결합할 수 있습니다
-> 반면, Provider 패키지에서는 여러 Provider를 결합해야 할 경우 다른 형태인 ProxyProvider를 사용해야 합니다.
테스트가 간편해집니다
-> 테스트 내에서 Provider를 재정의할 필요가 없고 setUp/tearDown 단계가 불필요해지므로 특정 행위에 대한 테스트가 쉬워집니다.
범위 지정(scoping)에 대한 과도한 의존도를 줄이고 사용되지 않는 Provider의 state를 자동으로 dispose 시킬 수 있습니다
-> autoDispose 기능을 제공합니다.
Riverpod 사용 예제 - Counter 앱
Counter 앱에 Riverpod을 어떻게 적용할 수 있는지에 대해서 간단하게 정리하겠습니다.
ProviderScope
ProviderScope
는 정의한 Provider의 상태를 저장하는 위젯입니다. Provider가 정의될 때 ProviderScope 내부적으로 ProviderContainer 인스턴스를 생성합니다. 그러므로, Provider를 정의할 때 ProviderContainer에 대해 신경쓸 필요가 없습니다.
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
// Adding ProviderScope enables Riverpod for the entire project
const ProviderScope(child: RiverpodCounterApp()),
);
}
Provider 정의
int 타입의 Counter에 대한 상태를 정의하는 StateNotifierProvider를 정의하였습니다. StateNotifierProvider는 StateNotifier를 상속받은 클래스로 만든 객체를 반환해야 하는데 StateNotifier 클래스는 비즈니스 로직을 집중할 수 있는 장점이 있습니다. 참고로 Riverpod 2.0 부터는 비동기 작업에서는 StateNotifierProvider 대신 AsyncNotifierProvider를 사용하는 것을 권장한다고 적혀있는데 AsyncValue를 반환해주며 기본적인 사용방법은 비슷합니다. counter 작업과 같은 경우는 비동기 작업이 아니므로 StateNotifierProvider를 사용하였습니다.
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CounterNotifier extends StateNotifier<int> {
CounterNotifier(this.ref) : super(0);
final Ref ref;
Future<void> increment() async {
state++;
}
Future<void> decrement() async {
state--;
}
}
final counterStateProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier(ref);
});
Provider 읽기
Provider를 읽기 위해서는 ref 객체
가 필요합니다. ref 객체는 위젯 혹은 Provider에서 Provider를 읽을 수 있도록 해줍니다. ref 객체는 일반적으로 사용하는 StatelessWidget에서는 얻을 수 없고 ConsumerWidget을 사용하거나 사용하고 싶은 위젯 부분에서 Consumer 위젯으로 감싸면 ref 객체를 얻을 수 있게 됩니다.
build() 메소드 내부에 선언한 count 변수는 ref 객체의 watch 메소드
를 사용하여 위에서 생성한 counterStateProvider의 int 타입의 상태값을 받아오고 있습니다. watch 메소드를 사용했으므로 이 변수는 Counter의 상태가 변할때마다 감지할 수 있고 위젯을 리빌드 할 수 있습니다.
class RiverpodCounterApp extends StatelessWidget {
const RiverpodCounterApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: CounterScreen());
}
}
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterStateProvider);
return Scaffold(
appBar: AppBar(title: const Text('Counter example')),
body: Center(
child: Text(
'$count',
style: const TextStyle(fontSize: 32.0),
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
key: const Key('counterView_increment_floatingActionButton'),
child: const Icon(Icons.add),
onPressed: () => ref.read(counterStateProvider.notifier).increment(),
),
const SizedBox(height: 8),
FloatingActionButton(
key: const Key('counterView_decrement_floatingActionButton'),
child: const Icon(Icons.remove),
onPressed: () => ref.read(counterStateProvider.notifier).decrement(),
),
],
),
);
}
}
전체 코드
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// A Counter example implemented with riverpod
class CounterNotifier extends StateNotifier<int> {
CounterNotifier(this.ref) : super(0);
final Ref ref;
Future<void> increment() async {
state++;
}
Future<void> decrement() async {
state--;
}
}
final counterStateProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier(ref);
});
void main() {
runApp(
// Adding ProviderScope enables Riverpod for the entire project
const ProviderScope(child: RiverpodCounterApp()),
);
}
class RiverpodCounterApp extends StatelessWidget {
const RiverpodCounterApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: CounterScreen());
}
}
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterStateProvider);
return Scaffold(
appBar: AppBar(title: const Text('Counter example')),
body: Center(
child: Text(
'$count',
style: const TextStyle(fontSize: 32.0),
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
key: const Key('counterView_increment_floatingActionButton'),
child: const Icon(Icons.add),
onPressed: () => ref.read(counterStateProvider.notifier).increment(),
),
const SizedBox(height: 8),
FloatingActionButton(
key: const Key('counterView_decrement_floatingActionButton'),
child: const Icon(Icons.remove),
onPressed: () => ref.read(counterStateProvider.notifier).decrement(),
),
],
),
);
}
}
실행 결과
References
https://github.com/rrousselGit/riverpod