[Flutter] RiverPod 학습
지난 시간에 데이터 상태관리(Data State Management)에 대해 학습을 해봤다.
선언형 UI와 명령형 UI의 차이점, Flutter에서 데이터 상태관리하는 방법, 데이터 상태 관리 도구(Data State management Tool)를 사용하는 이유 등
그러면 이제 RiverPod에 대해 학습해보자.
RiverPod의 공식문서를 보면 필수 사항등이 있다.
- 첫 번째 provider/네트워크 요청 만들기
- 부가 작업 수행(Performing side effects)
- 요청에 인자 전달하기
- 웹소켓 및 동기 실행
- 요청 결합하기
- 캐시 지우기 및 상태 폐기(disposal)에 반응하기
- providers의 빠른 초기화(Eager initialization)
- providers 테스트하기
- 로깅 및 오류 보고
- 자주 묻는 질문(FAQ)
- 권장사항(DO/DON'T)
나는 위의 필수사항은 모두 읽을것이지만 학습하며 정리할 내용은 아래와 같다.
현재 나에게 중요한 기준으로 나머지 내용들도 물론 중요하지만 시간관계상 모든 내용을 학습하고 정리할수는 없다.
이 중 몇몇개의 중요한 내용만 따로 학습하며 정리하려고 한다.
provider / 네트워크 요청 만들기
네트워크 요청은 거의 모든 애플리케이션에서 빠지지 않는 중요한 내용이다. 그래서 나는 이 부분을 학습하고 정리하려고 한다.
애플리케이션에서 네트워크 요청을 한다면 고려해야할 부분이 있다.
- 요청이 이루어지는 동안 UI는 로딩 상태를 렌더링해야 합니다. (로딩)
- 오류는 정상적으로 처리되어야 합니다. (오류 핸들링)
- 요청은 가능하면 캐시되어야 합니다. (속도 개선)
이 조건들은 RiverPod에서 자연스럽게 처리할 수 있다.
ProviderScope 설정하기
네트워크 요청을 시작하기 전에 애플리케이션의 루트에 ProviderScope가 추가되었는지 확인하자.
void main() {
runApp(
// Riverpod을 설치하려면 다른 모든 위젯 위에 이 위젯을 추가해야 합니다.
// 이 위젯은 "MyApp" 내부가 아니라 "runApp"에 직접 파라미터로 추가해야 합니다.
ProviderScope(
child: MyApp(),
),
);
}
이렇게 하면 전체 애플리케이션에 대해 Riverpod이 활성화된다.
"provider"에서 네트워크 요청 수행하기
네트워크 요청 작업을 수행하는 것은 일반적으로 “비즈니스 로직”이라고 부른다.
RiverPod에서 비즈니스 로직은 "provider" 내부에 배치한다.
provider는 함수이지만 여러 가지 이점이 있다.
- 캐시
- 기본적인 오류/로딩 처리
- 리스너 추가
- 데이터가 변경될 시 자동으로 다시 실행
이러한 이점들은 어디서 사용하기 좋을까? 바로 GET 네트워크 요청에 가장 적합하다.
즉, providers는 GET 네트워크 요청에 가장 적합하다.
예시 보기
왜 providers가 GET 네트워크 요청에 가장 적합한지 예시를 보며 알아보자
간단한 활동을 제안하는 애플리케이션을 개발해보며 알아보자.
나는 Bored API를 사용할 것이다. GET 요청을 하면 JSON 객체가 반환되고 이 객체를 Dart 클래스 인스턴스로 파싱한다.
그 다음, UI에 표시하면 된다. 이때 로딩 상태를 렌더링하고 오류를 정상적으로 처리한다.
모델 정의하기
시작하기전 API에서 수신한 데이터의 모델을 정의하여 사용한다.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'activity.freezed.dart';
part 'activity.g.dart';
/// `GET /api/activity` 엔드포인트의 응답입니다.
///
/// `freezed`와 `json_serializable`을 사용하여 정의됩니다.
@freezed
class Activity with _$Activity {
factory Activity({
required String key,
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;
/// JSON 객체를 [Activity] 인스턴스로 변환합니다.
/// 이렇게 하면 API 응답을 유형 안전하게 읽을 수 있습니다.
factory Activity.fromJson(Map<String, dynamic> json) => _$ActivityFromJson(json);
}
Provider 만들기
모델이 생겼으니 API 쿼리를 만들 것이다. provider를 만들어서 사용해보자.
// provider를 정의하는 코드
@riverpod
Result myFunction(Ref ref) {
<your logic here>
}
- annotation(어노테이션)
- 모든 providers는 @riverpod || @RiverPod()으로 어노테이션해야 한다.
- 이 어노테이션은 전역함수나 클래스에 배치할 수 있다.
- 예를 들어, @Riverpod(keepAlive: true)를 작성하여 "auto-dispose"를 비활성화할 수 있다.
- 그러면 annotation이 일반적으로 무엇인지 정리해보자. 공식문서를 보면, 개발자가 소스 코드를 정적으로 분석하여 추론할 수 없는 의도를 표현하는 데 사용할 수 있는 주석이란다. 🧐 주석은 개발자에게 코드에 대해 알려주는 정보를 제공하여 개발자 경험을 더 좋게 해준다. 예시로 See also @deprecated and @override in the dart:core library.가 될 수 있다.
- 즉 어노테이션은 주석이란 의미를 갖고 있고 “@”를 이용하여 주석처럼 달아 특수한 의믜를 부여한다. 소스 코드가 컴파일되거나 실행될 때 컴파일러 및 다른 프로그램에게 필요한 정보를 전달해주는 문법이다.
- 🤩 아하 그래서 Riverpod 공식문서에서 “providers는 @riverPod || @RiverPod()을 어노테이션해야 한다” 라고 했던 것이다.
- annotated function(어노테이션된 함수)
- 어노테이션된 함수의 이름에 따라 provider와 상호작용하는 방식이 결정된다.
- 주어진 함수 function에 대해 생성된 functionProvider 변수가 생선된다.
- 어노테이션된 함수는 첫 번째 매개변수로 "ref"를 지정해야 한다.
- 그 외에도 함수는 제네릭을 포함하여 여러 개의 매개변수를 가질 수 있다. 이 함수는 원할 경우 Future/Stream을 반환할 수도 있다.
- 이 함수는 provider를 처음 읽을 때 호출하고 그 이후는 캐시된 값을 반환한다.
- Ref
- 다른 providers와 상호작용하는 데 사용되는 객체이다.
- 모든 providers에는 provider 함수의 매개변수 또는 Notifier의 property으로 하나씩 ref를 갖고 있다.
- 이 객체의 타입은 함수/클래스에 의해 결정된다.
Riverpod에 사용되는 provider 문법에 대해 알아봤으니 이제 사용해보자.
코드
Bored API에서 Activity를 GET하려고 한다. GET은 비동기 연산이므로 Fututre<Activity>를 생성해야 한다.
provider.dart
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'activity.dart';
// 코드 생성이 작동하는 데 필요합니다.
part 'provider.g.dart';
/// 그러면 이 함수의 결과를 캐시할
/// `activityProvider`라는 provider가 생성됩니다.
@riverpod // 어노테이션 사용해주고
Future<Activity> activity(Ref ref) async {
// package:http를 사용하여 Bored API에서 임의의 activity를 가져옵니다.
final response = await http.get(Uri.https('boredapi.com', '/api/activity'));
// 그런 다음 dart:convert를 사용하여 JSON 페이로드를 맵 데이터 구조로 디코딩합니다.
final json = jsonDecode(response.body) as Map<String, dynamic>;
// 마지막으로 맵을 Activity 인스턴스로 변환합니다.
return Activity.fromJson(json);
}
위의 코드는 http를 사용하여 response 변수에 API에서 임의의 activity를 가져온다.
그후 jsonDecode를 통해 Map<String, dynamic> 자료구조 형태로 변경하여 json 객체에 넣어준다.
그 후 Activity 객체로 반환하여 넘겨준다.
Riverpod을 사용하면 위의 함수의 결과를 캐시할 ‘activityProvider’라는 provider가 생성된다.
activityProvider의 역할
- 네트워크 요청은 UI가 provider를 한 번 이상 읽을 때까지 실행되지 않는다.
- 이후 읽기는 네트워크 요청을 다시 실행하지 않고 이전에 가져온 활동(activity)을 반환한다.
- UI가 이 provider의 사용을 중단하면 캐시가 삭제 된 후 UI가 이 provider를 다시 사용하면 새로운 네트워크 요청이 이루어진다.
- 오류는 catch되지 않았다. 이는 provider들이 기본적으로 오류를 처리하기 때문에 자발적인 조치이다. 네트워크 요청이나 JSON 파싱에서 에러가 발생하면 riverpod에서 에러를 catch 후 UI에 오류 페이지를 렌더링하는 데 필요한 정보가 자동으로 표시된다.
Provider는 "지연(lazy)"입니다. provider를 정의해도 네트워크 요청이 실행되지 않습니다.
대신 provider를 처음 읽을 때 네트워크 요청이 실행됩니다.
UI에서 네트워크 요청의 응답 렌더링하기
provider를 통해 UI를 변경할 수 있다.
provider와 상호 작용하려면 "ref"라는 객체가 필요한. provider는 당연히 "ref" 객체에 액세스할 수 있다.
위젯에서 ref를 어떻게 불러올 수 있을까? 바로 Consumer(소비자)를 통해 UI가 providers를 읽을 수 있다.
Consumer는 Builder와 비슷한 위젯이지만 ref를 제공한다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'activity.dart';
import 'provider.dart';
/// 애플리케이션 홈페이지
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
// activityProvider를 읽습니다.
// 아직 시작되지 않은 경우 네트워크 요청이 시작됩니다.
// ref.watch를 사용하여
// 이 위젯은 activityProvider가 업데이트될 때마다 다시 빌드됩니다.
// 다음과 같은 경우에 발생할 수 있습니다:
// - 응답이 "loading"에서 "data/error"로 바뀐 경우
// - 요청이 refreshed된 경우
// - 결과가 로컬에서 수정된 경우 (예: 부가작업(side-effects)을 수행할 때)
// ...
final AsyncValue<Activity> activity = ref.watch(activityProvider);
return Center(
/// 네트워크 요청은 비동기식이며 실패할 수 있습니다,
/// 오류 상태와 로딩 상태를 모두 처리해야 합니다.
/// 이를 위해 패턴 매칭을 사용할 수 있습니다.
/// 또는 `if (activity.isLoading) { ... } else if (...)`를 사용할 수도 있습니다.
child: switch (activity) {
AsyncData(:final value) => Text('Activity: ${value.activity}'),
AsyncError() => const Text('Oops, something unexpected happened'),
_ => const CircularProgressIndicator(),
},
);
},
);
}
}
위의 코드는 Consumer를 사용하여 activityProvider를 읽고 그것에 대한 결과를 표시하는 코드이다.
로딩과 오류 상태에 대한 처리를 하여 개발자가 모든 상황에 대한 코드를 사용하지 않고 provider만 이용하여 처리할 수 있다. 그리고 캐시 처리를 하여 다른 위젯에서 activityProvider를 사용할 때 네트워크 요청없이 사용이 가능하다.
// 정보
위젯은 원하는 만큼 많은 providers를 수신(listen)할 수 있습니다.
그렇게 하려면 ref.watch 호출을 더 추가하기만 하면 됩니다.
Consumer 대신 ConsumerWidget을 사용하여 코드 들여쓰기 제거하기
위의 방법은 문제가 있는 방법은 아니지만 들여쓰기가 추가되면 코드 가독성이 떨어질 수 있다.
Riverpod은 동일한 결과를 얻을 수 있는 다른 방법을 제공한다
StatelessWidget/StatefulWidget이 Consumer를 반환하는 코드를 작성하는 대신 ConsumerWidget과 ConsumerStatefulWidget을 정의할 수 있다.
ConsumerWidget과 ConsumerStatefulWidget은 StatelessWidget/StatefulWidget + Consumer이다.
즉,ConsumerWidget과 ConsumerStatefulWidget은 ref를 사용할 수 있다.
/// "StatelessWidget" 대신 "ConsumerWidget"을 서브클래스화했습니다.
/// 이는 "StatelessWidget"을 만들고 "Consumer"를 재조정하는 것과 같습니다.
class Home extends ConsumerWidget {
const Home({super.key});
@override
// 이제 "build"가 추가 매개변수 "ref"를 받는 방식에 주목하세요
Widget build(BuildContext context, WidgetRef ref) {
// "Consumer"를 사용했을 때와 마찬가지로 위젯 내부에서 "ref.watch"를 사용할 수 있습니다.
final AsyncValue<Activity> activity = ref.watch(activityProvider);
// 렌더링 로직은 동일하게 유지됩니다.
return Center(/* ... */);
}
}
// ConsumerStatefulWidget을 확장합니다.
// 이것은 "Consumer" + "StatefulWidget"과 동일합니다.
class Home extends ConsumerStatefulWidget {
const Home({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _HomeState();
}
// "State" 대신 "ConsumerState"를 확장한 것을 볼 수 있습니다.
// 이것은 "ConsumerWidget" 대 "StatelessWidget"과 동일한 원리를 사용합니다.
class _HomeState extends ConsumerState<Home> {
@override
void initState() {
super.initState();
// 상태 수명 주기에도 "ref"에 액세스할 수 있습니다.
// 이를 통해 특정 provider에 리스너를 추가하여 대화 상자/스낵바를 표시하는 등의 작업을 수행할 수 있습니다.
ref.listenManual(activityProvider, (previous, next) {
// TODO 스낵바/대화 상자 표시
});
}
@override
Widget build(BuildContext context) {
// "ref"는 더 이상 매개변수로 전달되지 않고 대신 "ConsumerState"의 프로퍼티가 됩니다.
// 따라서 "build" 내에서 "ref.watch"를 계속 사용할 수 있습니다.
final AsyncValue<Activity> activity = ref.watch(activityProvider);
return Center(/* ... */);
}
}
위의 코드를 이용하여 StatefulWidget&State 대신 ConsumerWidget&ConsumerState으로 사용할 수 있다. 추가적으로 HookWidget과 결합하는 방법이 있는데 나는 State로 View간의 데이터를 넘길 것이기에 따로 공부하지 않을 것이다.
결론
Flutter의 RiverPod 학습을 통해 상태 관리의 효율적인 방법을 알아보았다. RiverPod은 네트워크 요청과 같은 비동기 작업을 처리할 때 매우 유용한 도구이다. activityProvider를 통해 API 호출 결과를 캐시하고 관리할 수 있으며, 이는 불필요한 네트워크 요청을 줄이고 성능을 향상시킨다.
UI 구현에 있어 Consumer나 ConsumerWidget을 사용하여 provider와 상호작용할 수 있다. 특히 ConsumerWidget은 기존 StatelessWidget에 ref 기능을 추가한 형태로, 코드의 가독성을 향상시키면서도 provider의 모든 기능을 활용할 수 있게 해준다. 또한 ConsumerStatefulWidget을 통해 상태 관리가 필요한 위젯에서도 동일한 방식으로 provider를 활용할 수 있다.
RiverPod은 로딩 상태, 에러 처리, 데이터 상태 등을 자동으로 관리해주어 개발자가 별도의 상태 관리 코드를 작성할 필요가 없다는 장점이 있다. 이를 통해 더 안정적이고 유지보수가 용이한 Flutter 애플리케이션을 개발할 수 있다.
'Flutter' 카테고리의 다른 글
[Flutter] Riverpod 학습 - Performing side effects & Passing arguments to your requests (0) | 2025.01.07 |
---|---|
[Flutter] RiverPod 학습 - State management (0) | 2025.01.03 |
[Flutter] [Error Handling] Result Pattern (간편로그인 적용) (0) | 2024.12.26 |
[Flutter] [Error Handling] Result Pattern (1) | 2024.12.24 |
[Flutter] [Firbase] 간편 로그인 구현하기(Apple🍎) (0) | 2024.12.24 |