네트워크요청 디바운싱/취소 (Debouncing/Cancelling)
애플리케이션이 복잡해짐에 따라 동시에 여러 개의 네트워크 요청이 발생하는 것이 일반적입니다. 예를 들어, 사용자가 검색창에 입력할 때마다 새로운 요청이 트리거될 수 있습니다. 사용자가 빠르게 입력하는 경우 애플리케이션에 동시에 많은 요청이 전송될 수 있습니다.
또는 사용자가 요청을 트리거한 후 요청이 완료되기 전에 다른 페이지로 이동할 수도 있습니다. 이 경우 애플리케이션에 더 이상 필요하지 않은 요청이 전송 중일 수 있습니다.
이러한 상황에서 성능을 최적화하기 위해 사용할 수 있는 몇 가지 기술이 있습니다:
- 요청 '디바운스'. 즉, 사용자가 일정 시간 동안 입력을 멈출 때까지 기다렸다가 요청을 전송하는 방식입니다. 이렇게 하면 사용자가 빠르게 입력하더라도 주어진 입력에 대해 한 번의 요청만 전송할 수 있습니다.
- 요청 '취소'. 즉, 요청이 완료되기 전에 사용자가 페이지에서 다른 곳으로 이동하는 경우 요청을 취소합니다. 이렇게 하면 사용자가 볼 수 없는 응답을 처리하느라 시간을 낭비하지 않아도 됩니다.
Riverpod에서는 이 두 가지 기술을 비슷한 방식으로 구현할 수 있습니다.
핵심은 ref.onDispose
를 "자동 폐기(automatic disposal)" 또는 ref.watch
와 함께 사용하여 원하는 동작을 달성하는 것입니다.
이를 보여주기 위해 두 페이지로 구성된 간단한 애플리케이션을 만들어 보겠습니다:
- 새 페이지를 여는 버튼이 있는 홈 화면
- Bored API에서 임의의 액티비티를 표시하는 상세 페이지로, 액티비티를 새로 고칠 수 있는 기능이 있습니다.
당겨서 새로고침(pull to refresh)를 구현하는 방법에 대한 자세한 내용은 당겨서 새로고침(Pull to refresh)를 참조하세요.
그런 다음 다음 동작을 구현합니다:
- 사용자가 세부 정보 페이지를 열었다가 즉시 다시 이동하면 액티비티에 대한 요청을 취소(cancel)합니다.
- 사용자가 연속으로 여러 번 액티비티을 새로 고치면 요청을 디바운스(debounce)하여 사용자가 새로 고침을 중지한 후 한 번만 요청을 보내도록 합니다.
어플리케이션
먼저, 디바운스나 취소 없이 애플리케이션을 만들어 봅시다.
여기서는 멋진 것을 사용하지 않고, 세부 정보 페이지를 여는 Navigator.push
가 있는 평범한 FloatingActionButton
을 사용하겠습니다.
먼저 홈 화면을 정의하는 것부터 시작하겠습니다.
평소와 마찬가지로 애플리케이션의 루트에 ProviderScope
를 지정하는 것을 잊지 마세요.
void main() => runApp(const ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
routes: {
'/detail-page': (_) => const DetailPageView(),
},
home: const ActivityView(),
);
}
}
class ActivityView extends ConsumerWidget {
const ActivityView({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Home screen')),
body: const Center(
child: Text('Click the button to open the detail page'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).pushNamed('/detail-page'),
child: const Icon(Icons.add),
),
);
}
}
그런 다음 세부 정보 페이지를 정의해 보겠습니다. 활동을 가져오고 당겨서 새로고침(pull to refresh)를 구현하려면 당겨서 새로고침(Pull to refresh) 사례 연구를 참조하세요.
class Activity with _$Activity {
factory Activity({
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;
factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}
Future<Activity> activity(Ref ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
class DetailPageView extends ConsumerWidget {
const DetailPageView({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Detail page'),
),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
AsyncValue(:final valueOrNull?) => Text(valueOrNull.activity),
AsyncValue(:final error?) => Text('Error: $error'),
_ => const Center(child: CircularProgressIndicator()),
},
],
),
),
);
}
}
요청 취소하기
이제 애플리케이션이 작동하므로 취소(cancellation) 로직을 구현해 보겠습니다.
이를 위해 사용자가 페이지에서 다른 곳으로 이동할 때 ref.onDispose
를 사용하여 요청을 취소할 것입니다.
이 기능이 작동하려면 providers의 자동 폐기(automatic disposal)가 활성화되어 있어야 합니다.
요청을 취소하는 데 필요한 정확한 코드는 HTTP 클라이언트에 따라 다릅니다.
이 예에서는 package:http
를 사용하지만 다른 클라이언트에도 동일한 원칙이 적용됩니다.
여기서 중요한 점은 사용자가 다른 곳으로 이동할 때 ref.onDispose
가 호출된다는 것입니다.
이는 provider가 더 이상 사용되지 않으므로 자동 폐기를 통해 폐기되기 때문입니다.
따라서 이 콜백을 사용하여 요청을 취소할 수 있습니다.
package:http
를 사용하는 경우 HTTP 클라이언트를 닫으면 이 작업을 수행할 수 있습니다.
Future<Activity> activity(Ref ref) async {
// package:http를 사용하여 HTTP 클라이언트를 생성합니다.
final client = http.Client();
// 폐기 시 클라이언트를 닫습니다.
// 그러면 클라이언트에 있을 수 있는 모든 보류 중인 요청이 취소됩니다.
ref.onDispose(client.close);
// 이제 'get' 함수 대신 클라이언트를 사용하여 요청을 수행합니다.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
// 나머지 코드는 이전과 동일합니다.
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
요청 디바운싱(Debouncing)
이제 취소를 구현했으니 이제 디바운싱을 구현해 보겠습니다.
현재로서는 사용자가 활동을 연속으로 여러 번 새로 고치면 새로 고칠 때마다 요청을 보내게 됩니다.
기술적으로는 취소를 구현했으므로 문제가 되지 않습니다. 사용자가 활동을 연속으로 여러 번 새로 고치면 새 요청이 이루어질 때 이전 요청이 취소됩니다.
하지만 이는 이상적이지 않습니다. 여전히 여러 요청을 전송하고 대역폭과 서버 리소스를 낭비하게 됩니다.
대신 사용자가 일정 시간 동안 활동 새로 고침을 중지할 때까지 요청을 지연시키는 방법을 사용할 수 있습니다.
여기서 로직은 취소 로직과 매우 유사합니다. 다시 ref.onDispose
를 사용합니다.
하지만 여기서는 HTTP 클라이언트를 닫는 대신 onDispose
에 의존하여 요청이 시작되기 전에 중단한다는 점이 다릅니다.
그런 다음 요청을 보내기 전에 임의로 500ms를 기다립니다.
그런 다음 사용자가 500ms가 경과하기 전에 활동을 다시 새로고침하면 onDispose
가 호출되어 요청이 중단됩니다.
요청을 중단하려면, 자발적(voluntarily)으로 던지는(throw) 것이 일반적입니다.
provider가 폐기된(disposed) 후에는 providers 내부에 던져도(throw) 안전합니다.
예외는 당연히 Riverpod에 의해 잡히고 무시됩니다.
Future<Activity> activity(Ref ref) async {
// provider가 현재 폐기되었는지 여부를 캡처합니다.
var didDispose = false;
ref.onDispose(() => didDispose = true);
// 사용자가 새로 고침을 중단할 때까지 요청을 500밀리초 지연합니다.
await Future<void>.delayed(const Duration(milliseconds: 500));
// 지연 중에 provider가 dispose되었다면, 사용자가 다시 새로고침했다는 의미입니다.
// 예외를 던져 요청을 취소합니다.
// Riverpod에 의해 포착되므로 예외를 사용하는 것이 안전합니다.
if (didDispose) {
throw Exception('Cancelled');
}
// 다음 코드는 이전 스니펫에서 변경되지 않았습니다.
final client = http.Client();
ref.onDispose(client.close);
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
더 나아가기: 두 가지를 한 번에 수행하기
이제 요청을 디바운스하고 취소하는 방법을 알았습니다.
하지만 현재 다른 요청을 수행하려면 동일한 로직을 여러 곳에 복사하여 붙여넣어야 합니다. 이것은 이상적이지 않습니다.
하지만 여기서 더 나아가 재사용 가능한 유틸리티를 구현하여 두 가지 작업을 한 번에 수행할 수 있습니다.
여기서는 단일 메서드에서 취소와 디바운싱을 모두 처리하는 확장 메서드(extension method)를 Ref
에 구현하는 것이 아이디어입니다.
extension DebounceAndCancelExtension on Ref {
/// [duration](기본값은 500ms) 동안 기다린 다음 요청에 사용할 수 있는 [http.Client]를 반환합니다.
///
/// 해당 클라이언트는 provider가 폐기되면 자동으로 닫힙니다.
Future<http.Client> getDebouncedHttpClient([Duration? duration]) async {
// 먼저 디바운싱을 처리합니다.
var didDispose = false;
onDispose(() => didDispose = true);
// 사용자가 새로 고침을 중단할 때까지 요청을 500밀리초 지연합니다.
await Future<void>.delayed(duration ?? const Duration(milliseconds: 500));
// 지연 중에 provider가 dispose되었다면, 사용자가 다시 새로고침했다는 의미입니다.
// 예외를 던져 요청을 취소합니다.
// Riverpod에 의해 포착되므로 예외를 사용하는 것이 안전합니다.
if (didDispose) {
throw Exception('Cancelled');
}
// 이제 클라이언트를 생성하고 provider가 폐기되면 닫습니다.
final client = http.Client();
onDispose(client.close);
// 마지막으로 클라이언트를 반환하여 provider가 요청을 할 수 있도록 합니다.
return client;
}
}
그런 다음 다음과 같이 providers에서 이 확장 메서드를 사용할 수 있습니다:
Future<Activity> activity(Ref ref) async {
// 앞서 만든 확장자를 사용하여 HTTP 클라이언트를 가져옵니다.
final client = await ref.getDebouncedHttpClient();
// 이제 'get' 함수 대신 클라이언트를 사용하여 요청을 수행합니다.
// 사용자가 페이지를 떠나면 요청이 자연스럽게 디바운스되고 취소됩니다.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}