주요 콘텐츠로 건너뛰기

첫 번째 provider/네트워크 요청 만들기

네트워크 요청은 모든 애플리케이션의 핵심입니다. 하지만 네트워크 요청을 할 때는 고려해야 할 사항이 많습니다:

  • 요청이 이루어지는 동안 UI는 로딩 상태를 렌더링해야 합니다.
  • 오류는 정상적으로 처리되어야 합니다.
  • 요청은 가능하면 캐시되어야 합니다.

이 섹션에서는 Riverpod이 이 모든 것을 자연스럽게 처리하는 데 어떻게 도움이 되는지 살펴보겠습니다.

ProviderScope 설정하기

네트워크 요청을 시작하기 전에 애플리케이션의 루트에 ProviderScope가 추가되었는지 확인하세요.

void main() {
runApp(
// Riverpod을 설치하려면 다른 모든 위젯 위에 이 위젯을 추가해야 합니다.
// 이 위젯은 "MyApp" 내부가 아니라 "runApp"에 직접 파라미터로 추가해야 합니다.
ProviderScope(
child: MyApp(),
),
);
}

이렇게 하면 전체 애플리케이션에 대해 Riverpod이 활성화됩니다.

노트

riverpod_lint 설치와 같은 전체 설치 단계는 시작하기를 확인하세요

"provider"에서 네트워크 요청 수행하기

네트워크 요청을 수행하는 것을 보통 "비즈니스 로직"이라고 부릅니다. Riverpod에서 비즈니스 로직은 "providers" 내부에 배치됩니다.
provider는 초강력(super-powered) 함수입니다. 일반 함수처럼 동작하지만 다음과 같은 이점이 추가됩니다:

  • 캐시됩니다.
  • 기본적인 오류/로딩 처리를 제공합니다.
  • 리스너를 추가할 수 있습니다.
  • 데이터가 변경될 때 자동으로 다시 실행됩니다.

따라서 providers는 GET 네트워크 요청에 가장 적합합니다. (POST/etc 요청의 경우, 부가 작업 수행(Performing side effects) 참조)

예를 들어, 지루할 때 할 수 있는 무작위 활동을 제안하는 간단한 애플리케이션을 만들어 보겠습니다.
이를 위해 Bored API를 사용하겠습니다. 특히 /api/activity 엔드포인트에서 GET 요청을 수행하겠습니다. 그러면 JSON 객체가 반환되며, 이 객체를 Dart 클래스 인스턴스로 파싱합니다.
다음 단계는 이 활동을 UI에 표시하는 것입니다. 또한 요청이 이루어지는 동안 로딩 상태를 렌더링하고 오류를 정상적으로 처리해야 합니다.

멋지게 들리나요? 시작해보세요!

모델 정의하기

시작하기 전에 API에서 수신할 데이터의 모델을 정의해야 합니다. 이 모델에는 JSON 객체를 Dart 클래스 인스턴스로 파싱하는 방법도 필요합니다.

일반적으로 JSON 디코딩을 처리할 때는 Freezed 또는 json_serializable과 같은 코드 생성기를 사용하는 것이 좋습니다. 물론 수작업으로 처리하는 것도 가능합니다.

어쨌든, 여기 우리의 모델이 있습니다:

activity.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'activity.freezed.dart';
part 'activity.g.dart';

/// `GET /api/activity` 엔드포인트의 응답입니다.
///
/// `freezed`와 `json_serializable`을 사용하여 정의됩니다.

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()로 어노테이션해야 합니다. 이 어노테이션은 전역 함수나 클래스에 배치할 수 있습니다.
이 어노테이션을 통해 provider를 설정(config)할 수 있습니다.

예를 들어, @Riverpod(keepAlive: true)를 작성하여 "auto-dispose"(나중에 살펴보겠습니다)를 비활성화할 수 있습니다.

어노테이션된 함수(annotated function)

어노테이션된 함수의 이름에 따라 provider와 상호작용하는 방식이 결정됩니다.
주어진 함수 myFunction에 대해 생성된 myFunctionProvider 변수가 생성됩니다.

어노테이션된 함수는 첫 번째 매개변수로 "ref"를 지정해야 합니다.
그 외에도 함수는 제네릭을 포함하여 여러 개의 매개변수를 가질 수 있습니다. 이 함수는 원할 경우 Future/Stream을 반환할 수도 있습니다.

이 함수는 provider를 처음 읽을 때 호출됩니다.
이후 읽기는 함수를 다시 호출하지 않고 대신 캐시된 값을 반환합니다.

Ref

다른 providers와 상호작용하는 데 사용되는 객체입니다.
모든 providers에는 provider 함수의 매개변수(parameter) 또는 Notifier의 속성(property)으로 하나씩 가지고 있습니다. 이 객체의 타입은 함수/클래스의 이름에 의해 결정됩니다.

여기서는 API에서 Activity를 GET하고자 합니다.
GET은 비동기 연산이므로 Future<Activity>를 생성해야 합니다.

따라서 앞서 정의한 구문을 사용하여 다음과 같이 provider를 정의할 수 있습니다:

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가 생성됩니다.

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);
}

이 코드조각에서는 UI가 임의의 액티비티를 가져오는 데 사용할 수 있는 activityProvider라는 이름의 provider를 정의했습니다. 다음과 같은 것은 주목할 가치가 있습니다:

  • 네트워크 요청은 UI가 provider를 한 번 이상 읽을 때까지 실행되지 않습니다.
  • 이후 읽기는 네트워크 요청을 다시 실행하지 않고 이전에 가져온 활동(activity)을 반환합니다.
  • UI가 이 provider의 사용을 중단하면 캐시가 삭제됩니다. 그런 다음 UI가 이 provider를 다시 사용하면 새로운 네트워크 요청이 이루어집니다.
  • 오류는 catch되지 않았습니다. 이는 provider들이 기본적으로 오류를 처리하기 때문에 자발적인 조치입니다.
    네트워크 요청이나 JSON 파싱에서 에러가 발생하면 riverpod에서 에러를 catch합니다. 그러면 UI에 오류 페이지를 렌더링하는 데 필요한 정보가 자동으로 표시됩니다.
정보

Provider는 "지연(lazy)"입니다. provider를 정의해도 네트워크 요청이 실행되지 않습니다. 대신 provider를 처음 읽을 때 네트워크 요청이 실행됩니다.

UI에서 네트워크 요청의 응답 렌더링하기

Now that we have defined a provider, we can start using it inside our UI to display the activity.

provider와 상호 작용하려면 "ref"라는 객체가 필요합니다. provider는 당연히 "ref" 객체에 액세스할 수 있으므로 이전에 provider 정의에서 이 객체를 보셨을 것입니다.
하지만 여기서는 provider가 아니라 위젯에 있습니다. 그렇다면 "ref"는 어떻게 얻을 수 있을까요?

해결책은 Consumer라는 커스텀 위젯을 사용하는 것입니다. ConsumerBuilder와 비슷한 위젲이지만, "ref"를 제공한다는 추가적인 이점이 있습니다. 이를 통해 UI가 provider들을 읽을 수 있습니다. 다음 예제는 Consumer를 사용하는 방법을 보여줍니다:

consumer.dart
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});


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를 읽고 Activity를 표시했습니다. 또한 로딩/오류 상태도 우아하게 처리했습니다.
provider에서 특별한 작업을 하지 않고도 UI가 어떻게 로딩/오류 상태를 처리할 수 있었는지 주목하세요.
동시에 위젯이 다시 빌드될 경우 네트워크 요청이 올바르게 다시 실행되지 않습니다. 다른 위젯도 네트워크 요청을 다시 실행하지 않고도 동일한 provider에 액세스할 수 있습니다.

정보

위젯은 원하는 만큼 많은 providers를 수신(listen)할 수 있습니다. 그렇게 하려면 ref.watch 호출을 더 추가하기만 하면 됩니다.

linter를 설치하세요. 그러면 IDE에서 자동으로 Consumer를 추가하거나 StatelessWidgetConsumerWidget으로 변환하는 리팩터링 옵션을 이용할 수 있습니다.

설치 단계는 시작하기를 참고하세요.

더 살펴보기: Consumer 대신 ConsumerWidget을 사용하여 코드 들여쓰기 제거하기.

이전 예제에서는 Consumer를 사용하여 provider를 읽었습니다.
이 접근 방식에 문제가 있는 것은 아니지만, 들여쓰기가 추가되면 코드를 읽기 어렵게 만들 수 있습니다.

Riverpod은 동일한 결과를 얻을 수 있는 다른 방법을 제공합니다: StatelessWidget/StatefulWidgetConsumer를 반환하는 코드를 작성하는 대신 ConsumerWidget/ConsumerStatefulWidget을 정의할 수 있습니다.
ConsumerWidgetConsumerStatefulWidget은 사실상 StatelessWidget/StatefulWidgetConsumer를 결합한 것입니다. 이들은 원래의 짝과 동일하게 동작하지만 "ref"를 제공한다는 추가적인 이점이 있습니다.

이전 예제를 다음과 같이 ConsumerWidget을 사용하도록 다시 작성할 수 있습니다:


/// "StatelessWidget" 대신 "ConsumerWidget"을 서브클래스화했습니다.
/// 이는 "StatelessWidget"을 만들고 "Consumer"를 재조정하는 것과 같습니다.
class Home extends ConsumerWidget {
const Home({super.key});


// 이제 "build"가 추가 매개변수 "ref"를 받는 방식에 주목하세요
Widget build(BuildContext context, WidgetRef ref) {
// "Consumer"를 사용했을 때와 마찬가지로 위젯 내부에서 "ref.watch"를 사용할 수 있습니다.
final AsyncValue<Activity> activity = ref.watch(activityProvider);

// 렌더링 로직은 동일하게 유지됩니다.
return Center(/* ... */);
}
}

ConsumerStatefulWidget의 경우 대신 다음과 같이 작성합니다:


// ConsumerStatefulWidget을 확장합니다.
// 이것은 "Consumer" + "StatefulWidget"과 동일합니다.
class Home extends ConsumerStatefulWidget {
const Home({super.key});


ConsumerState<ConsumerStatefulWidget> createState() => _HomeState();
}

// "State" 대신 "ConsumerState"를 확장한 것을 볼 수 있습니다.
// 이것은 "ConsumerWidget" 대 "StatelessWidget"과 동일한 원리를 사용합니다.
class _HomeState extends ConsumerState<Home> {

void initState() {
super.initState();

// 상태 수명 주기에도 "ref"에 액세스할 수 있습니다.
// 이를 통해 특정 provider에 리스너를 추가하여 대화 상자/스낵바를 표시하는 등의 작업을 수행할 수 있습니다.
ref.listenManual(activityProvider, (previous, next) {
// TODO 스낵바/대화 상자 표시
});
}


Widget build(BuildContext context) {
// "ref"는 더 이상 매개변수로 전달되지 않고 대신 "ConsumerState"의 프로퍼티가 됩니다.
// 따라서 "build" 내에서 "ref.watch"를 계속 사용할 수 있습니다.
final AsyncValue<Activity> activity = ref.watch(activityProvider);

return Center(/* ... */);
}
}

Flutter_hooks 고려 사항: HookWidgetConsumerWidget의 결합

주의

"훅(Hooks)"에 대해 들어본 적이 없다면 이 섹션을 건너뛰셔도 됩니다.
Flutter_hooks는 Riverpod과는 별개의 패키지이지만 Riverpod과 함께 사용되는 경우가 많습니다. Riverpod을 처음 사용하는 경우 "훅(Hooks)" 사용을 권장하지 않습니다. 자세한 내용은 훅(hooks)에 대한 정보에서 확인하세요.

flutter_hooks를 사용하는 경우 HookWidgetConsumerWidget을 결합하는 방법이 궁금할 수 있습니다. 결국 둘 다 확장된(extended) 위젯 클래스를 변경해야 합니다.

Riverpod는 이 문제에 대한 해결책으로 HookConsumerWidgetStatefulHookConsumerWidget을 제공합니다. ConsumerWidgetConsumerStatefulWidgetConsumerStatelessWidget/StatefulWidget의 결합인 것과 유사하게, HookConsumerWidgetStatefulHookConsumerWidgetConsumerHookWidget/HookStatefulWidget의 결합입니다. 따라서 동일한 위젯에서 훅(Hooks)와 providers를 모두 사용할 수 있습니다.

이를 보여주기 위해 이전 예제를 다시 한 번 다시 작성해 보겠습니다:


/// "HookConsumerWidget"을 서브클래스화했습니다.
/// 이것은 "StatelessWidget" + "Consumer" + "HookWidget"을 함께 결합합니다.
class Home extends HookConsumerWidget {
const Home({super.key});


// 이제 "build"가 추가 매개변수를 받는 방식에 주목하세요: "ref"
Widget build(BuildContext context, WidgetRef ref) {
// 위젯 내부에서 "useState"와 같은 훅을 사용할 수 있습니다.
final counter = useState(0);

// providers 읽기도 사용할 수도 있습니다.
final AsyncValue<Activity> activity = ref.watch(activityProvider);

return Center(/* ... */);
}
}