주요 콘텐츠로 건너뛰기

네트워크요청 디바운싱/취소 (Debouncing/Cancelling)

애플리케이션이 복잡해짐에 따라 동시에 여러 개의 네트워크 요청이 발생하는 것이 일반적입니다. 예를 들어, 사용자가 검색창에 입력할 때마다 새로운 요청이 트리거될 수 있습니다. 사용자가 빠르게 입력하는 경우 애플리케이션에 동시에 많은 요청이 전송될 수 있습니다.

또는 사용자가 요청을 트리거한 후 요청이 완료되기 전에 다른 페이지로 이동할 수도 있습니다. 이 경우 애플리케이션에 더 이상 필요하지 않은 요청이 전송 중일 수 있습니다.

이러한 상황에서 성능을 최적화하기 위해 사용할 수 있는 몇 가지 기술이 있습니다:

  • 요청 '디바운스'. 즉, 사용자가 일정 시간 동안 입력을 멈출 때까지 기다렸다가 요청을 전송하는 방식입니다. 이렇게 하면 사용자가 빠르게 입력하더라도 주어진 입력에 대해 한 번의 요청만 전송할 수 있습니다.
  • 요청 '취소'. 즉, 요청이 완료되기 전에 사용자가 페이지에서 다른 곳으로 이동하는 경우 요청을 취소합니다. 이렇게 하면 사용자가 볼 수 없는 응답을 처리하느라 시간을 낭비하지 않아도 됩니다.

Riverpod에서는 이 두 가지 기술을 비슷한 방식으로 구현할 수 있습니다. 핵심은 ref.onDispose를 "자동 폐기(automatic disposal)" 또는 ref.watch와 함께 사용하여 원하는 동작을 달성하는 것입니다.

이를 보여주기 위해 두 페이지로 구성된 간단한 애플리케이션을 만들어 보겠습니다:

  • 새 페이지를 여는 버튼이 있는 홈 화면
  • Bored API에서 임의의 액티비티를 표시하는 상세 페이지로, 액티비티를 새로 고칠 수 있는 기능이 있습니다.
    당겨서 새로고침(pull to refresh)를 구현하는 방법에 대한 자세한 내용은 당겨서 새로고침(Pull to refresh)를 참조하세요.

그런 다음 다음 동작을 구현합니다:

  • 사용자가 세부 정보 페이지를 열었다가 즉시 다시 이동하면 액티비티에 대한 요청을 취소(cancel)합니다.
  • 사용자가 연속으로 여러 번 액티비티을 새로 고치면 요청을 디바운스(debounce)하여 사용자가 새로 고침을 중지한 후 한 번만 요청을 보내도록 합니다.

어플리케이션

Gif showcasing the application, opening the detail page and refreshing the activity.

먼저, 디바운스나 취소 없이 애플리케이션을 만들어 봅시다.
여기서는 멋진 것을 사용하지 않고, 세부 정보 페이지를 여는 Navigator.push가 있는 평범한 FloatingActionButton을 사용하겠습니다.

먼저 홈 화면을 정의하는 것부터 시작하겠습니다. 평소와 마찬가지로 애플리케이션의 루트에 ProviderScope를 지정하는 것을 잊지 마세요.

lib/src/main.dart
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) 사례 연구를 참조하세요.

lib/src/detail_screen.dart

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(ActivityRef 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를 사용하여 요청을 취소할 것입니다. 이 기능이 작동하려면 provider의 자동 폐기(automatic disposal)가 활성화되어 있어야 합니다.

요청을 취소하는 데 필요한 정확한 코드는 HTTP 클라이언트에 따라 다릅니다. 이 예에서는 package:http를 사용하지만 다른 클라이언트에도 동일한 원칙이 적용됩니다.

여기서 중요한 점은 사용자가 다른 곳으로 이동할 때 ref.onDispose가 호출된다는 것입니다. 이는 provider가 더 이상 사용되지 않으므로 자동 폐기를 통해 폐기되기 때문입니다.
따라서 이 콜백을 사용하여 요청을 취소할 수 있습니다. package:http를 사용하는 경우 HTTP 클라이언트를 닫으면 이 작업을 수행할 수 있습니다.


Future<Activity> activity(ActivityRef ref) async {
// package:http를 사용하여 HTTP 클라이언트를 생성합니다.
final client = http.Client();
// dispose 시 클라이언트를 닫습니다.
// 그러면 클라이언트에 있을 수 있는 모든 보류 중인 요청이 취소됩니다.
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가 호출되어 요청이 중단됩니다.

정보

요청을 중단하려면 자발적으로 던지는(throw) 것이 일반적입니다.
provider가 폐기된(disposed) 후에는 공급자 내부에 던지는(throw) 것이 안전합니다. 예외는 당연히 Riverpod에 의해 잡히고 무시됩니다.


Future<Activity> activity(ActivityRef ref) async {
// provider가 현재 폐기(disposed)되었는지 여부를 캡처합니다.
var didDispose = false;
ref.onDispose(() => didDispose = true);

// 사용자가 새로 고침(refreshing)을 중단할 때까지 기다리기 위해
// 요청을 500밀리초 지연합니다.
await Future<void>.delayed(const Duration(milliseconds: 500));

// 지연 중에 provider가 폐기(disposed)되었다면 사용자가 다시 새로고침했다는 의미입니다.
// 예외를 던져 요청을 취소합니다.
// 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가 폐기(disposed)되었다면 사용자가 다시 새로고침했다는 의미입니다.
// 예외를 던져 요청을 취소합니다.
// 리버포드에 의해 포착되므로 여기서 예외를 사용하는 것이 안전합니다.
if (didDispose) {
throw Exception('Cancelled');
}

// 이제 클라이언트를 생성하고 provider가 폐기되면 클라이언트를 닫습니다.
final client = http.Client();
onDispose(client.close);

// 마지막으로 클라이언트를 반환하여 provider가 요청을 할 수 있도록 합니다.
return client;
}
}

그런 다음 다음과 같이 공급자에서 이 확장 메서드를 사용할 수 있습니다:


Future<Activity> activity(ActivityRef ref) async {
// 앞서 만든 확장자를 사용하여 HTTP 클라이언트를 가져옵니다.
final client = await ref.getDebouncedHttpClient();

// 이제 "get" 함수 대신 클라이언트를 사용하여 요청을 수행합니다.
// 사용자가 페이지를 떠나면 요청은 자연스럽게 디바운스(debounced)되고 취소(cancelled)됩니다.
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));
}