디바운스와 쓰로틀(Debounce & Throttle) - 최적화를 도와주는 기법

thumbnail

Debounce와 Throttle

디바운스(Debounce)와 쓰로틀(Throttle)은 둘 다 함수의 연속적인 실행을 제한하는 목적을 갖고서 설계되었습니다. 그 중 Debounce는, 특정 기간 동안 함수의 실행을 모두 취소하고, 마지막 실행만 수행합니다. 반대로 Throttle은 함수 실행 후 특정 기간 동안 추가적인 함수의 재실행을 모두 취소합니다. 이 둘은 매우 비슷해보이지만 서로 다른 특성을 갖고 있는, 정해진 시간 동안 얼마나 많은 함수의 실행을 허가할 것인가에 대한 테크닉입니다.

 

Debounce와 Throttle은 특히 Future와 Stream에 관련된 함수에서 자주 볼 수 있습니다.

 

API 요청 시에 Debounce와 Throttle이 특히 유용하게 사용됩니다. 불필요하게 API 요청이 1초에 30번 씩 발생한다면 어떨까요? 검색을 하는데, 한 글자 한 글자 타이핑을 할 때마다 검색 쿼리 문이 날라간다면요? 이는 서버에 엄청난 부하를 주게 될 것입니다.

 

서버 뿐만 아니라, 앱을 사용하는 사용자에게도 좋지 않은 경험을 줄 수 있습니다. 스크롤을 할 때마다, 무거운 이벤트가 발생한다면 어떨까요? John Resig은 2011년 이 문제를 제기하였습니다. 그는 스크롤 시에 무거운 이벤트를 리스닝하는 것은 현명한 방법이 아니라며, 250ms마다 이벤트를 발생시키는 방식을 제안했습니다. 현대에는 이보다 더 정교화된 기법인 Debounce와 Throttle을 사용하죠.

Debounce (디바운스)

Debounce는 함수를 마지막으로 호출한 후 일정 시간이 경과한 후에만 함수가 실행되도록 하는 방법입니다. 일반적으로 사용자가 입력이나 스크롤과 같은 이벤트 트리거를 중단할 때까지 함수 실행을 지연시키고자 하는 시나리오에서 사용됩니다.

 

예를 들어 사용자가 입력할 때마다 검색 기능을 트리거하는 검색창이 있다고 가정해 보겠습니다. 300millisec의 시간 간격으로 검색 기능을 Debounce하면 사용자가 최소 300밀리초 동안 입력을 멈출 때까지 해당 기능이 실행되지 않습니다. 사용자가 해당 간격 내에 계속 입력하면 타이머가 재설정되고 300millisec 동안 일시 중지될 때까지 기능이 실행되지 않습니다.

 

Debounce는 두 종류가 있습니다. Trailing과 Leading. 하나씩 살펴봅시다.

Trailing Debounce

Throttle의 개요
출처: https://llu.is/throttle-and-debounce-visualized/

맨 위에 줄은 실제 발생한 이벤트입니다. 예를 들어서 사용자가 버튼을 저만큼 연속해서 눌렀다고 해봅시다(이벤트). 버튼을 누르게 되면, 서버로 요청이 발생합니다(함수의 실행, 이벤트 리스닝).

 

이때 Debounce는 가장 마지막에 실행된 이벤트에 대해서만 실제 함수의 실행을 수행합니다. 여기서 기준이 있습니다. 바로 400ms(사용자가 설정한 임계 조건) 동안 이벤트가 발생하지 않으면, 그때 실행을 수행합니다. 두 번째 줄이 바로 Trailing Debounce가 실제로 작동한 모습입니다.

 

만약에 400ms 동안 어떤 이벤트가 발생하면, 타이머를 새롭게 초기화하고, 다시 400ms 동안 기다립니다. 이 과정을 반복합니다. 이것이 Trailing Debounce의 개념입니다.

 

그렇기 때문에 Trailing Debounce에서는, 임계 시간 안에 연속적으로 이벤트를 계속해서 발생하게 되면 이벤트가 끝날 때까지, 계속해서 함수의 실행은 뒤로 밀리게 됩니다. 그러다가 이벤트가 끝나고서, 임계조건이 끝나야만 실제 함수의 실행이 수행됩니다.

Trailing debounce
Trailing debounce

이때 둘째 줄에서 볼 수 있듯이, 마지막에 발생한 이벤트에 대해서도 400ms의 딜레이가 발생하고 있음을 알 수 있습니다. 이건 Debounce는 매 이벤트가 발생할 때마다 400ms를 기다리기 때문입니다.

Leading Debounce

Leading debounce
Leading debounce

위 그림이 Leading Debounce입니다. Leading Debounce는 Trailing의 정반대입니다. 맨 처음의 이벤트에 대해서만 함수를 딱 한 번만 실행하고, 나머지는 모두 무시합니다.

 

다시 말해서, 맨 처음 이벤트가 발생하면, 함수를 실행하면서 동시에 임계 조건인 400ms의 타이머를 작동시킵니다. 그리고, 400ms 이내에 다시 이벤트가 발생하면, 타이머를 초기화시키고 다시 400ms를 셉니다. 해당 이벤트는 무시합니다.

Trailing and Leading Debounce

Trailing and Leading
Trailing and Leading

위 그림에서는 Trailing과 Leading Debounce의 혼합인 Trailing and Leading 방식을 확인할 수 있습니다.

수강 신청 시 여러 번 클릭하면 안 되는 이유? Trailing Debounce!

이제 수강 신청 시 여러 번 클릭하면 안 되는 이유를 알 수 있습니다. 서버의 과부하를 줄이기 위해서, 클라이언트 단에서는 Trailing Debounce가 적용되어 있기 때문입니다. 임계 시간 안에 여러 번 버튼을 클릭하게 되면, 앞선 이벤트(수강 신청 버튼 클릭)은 모두 무시가 되고, 맨 마지막 이벤트만 리스닝됩니다.

Throttle (쓰로틀)

Throttle은 Debounce와 비슷하지만 완전히 다릅니다. Throttle은 연속해서 발생하는 이벤트에 대해서, 특정 시간을 주기로 끊어내는 개념입니다. 즉 지정된 시간 간격, Time Interval 안에 최대 한 번의 이벤트만 리스닝하겠다는 개념입니다. Throttle은 단위 구간에 대한 이야기입니다. Throttle은 함수를 주기적으로 호출하되 너무 자주 호출하지 않으려는 시나리오에 유용합니다.

 

예를 들어 API 호출을 트리거하는 버튼이 있다고 가정해 보겠습니다. 이 버튼의 클릭 이벤트에 1초 간격으로 Throttle을 적용하면 해당 1초 내에 버튼을 반복해서 클릭하면 API 호출이 한 번만 트리거됩니다. 해당 1초 내의 후속 클릭은 간격이 만료될 때까지 무시됩니다.

Throttle의 개요
출처: https://llu.is/throttle-and-debounce-visualized/

Throttle은 이벤트가 발생하고서, 주어진 임계 시간 동안 그 뒤에 일어나는 이벤트는 무시합니다. 주어진 임계 시간이 끝나면, 그제서야 새로운 이벤트에 대해서 함수를 실행합니다. 이때 Debounce와 다른 점은, 새로운 이벤트가 발생하더라도 타이머를 초기화시키지 않습니다!(Leading Throttle)

 

한편, Throttle에서는 Leading과 Trailing에 대해서 다루고 있는 정확한 글을 찾기가 힘들었습니다.

 

잘못된 정보도 많았고, 구현 방식에 차이가 있는 부분도 있었습니다. 여기서는 그냥 제가 구현한 방식대로 설명하겠습니다(어차피 큰 차이는 안 납니다).

 

새로운 그림을 만들었습니다. 아래 그림이 위에서 아래로 실제 이벤트 스트림, trailing and leading, leading, trailing 방식으로 이벤트를 처리하는 모습입니다. Throttle의 임계 조건은 10milliseconds라고 가정합니다.

Throttle의 이벤트 상황
Throttle의 이벤트 상황

Leading Throttle

Origin stream
Origin stream

입력 스트림에 이러한 이벤트가 주어진다고 가정해보겠습니다(given stream).

Leading throttle
Leading throttle

Leading Throttle을 적용할 경우 유효한 이벤트는 이렇습니다.

 

편의상, 이벤트가 발생하면 발생하였다고 하고, 그 중 유효한 이벤트에 의해서 실제 함수가 실행이 되면 ‘리스닝한다’고 표현합니다. 이벤트가 무효하여 실제 함수가 실행이 안 되면 ‘무시되었다’고 표현하겠습니다.

 

순서대로 가보겠습니다.

  1. [t=0] 첫 번째 이벤트 A가 발생하고, A를 리스닝합니다. A가 발생하고서, 10 msec의 구간 α(alpha)가 설정됩니다.
  2. [t=7] 이벤트 B가 발생하였습니다. 그러나 아직 α가 끝나지 않았기에, B는 무시됩니다.
  3. [t=10] 구간 α가 끝납니다.
  4. [t=12] 이벤트 C가 발생하고, 리스닝합니다. 여기서 새롭게 10msec 길이의 새로운 구간 β(beta)가 설정됩니다. 그렇다면 구간 β는 t=12부터 t=22까지겠죠?
  5. [t=22] 이벤트 D가 발생합니다. 그러나 아직 구간 β 안입니다(경계점 끝점도 포함한다고 가정). 그래서 D는 무시됩니다. 그리고 구간 β가 끝납니다.
  6. [t=25] 이벤트 E가 발생하고 리스닝합니다. 새로운 구간 γ(gamma)가 설정됩니다.
  7. [t=35] 구간 γ가 끝납니다.
  8. [t=45] 이벤트 F가 발생하고 리스닝합니다. 새로운 구간 δ(delta)가 설정됩니다.
  9. [t=55] 구간 δ가 끝이 납니다.

Trailing Throttle

Trailing은 조금 복잡합니다. Throttle은 한 구간 내에서 발생한 이벤트 중, 마지막으로 발생한 이벤트만 리스닝하고 나머지는 무시합니다. 그렇기 때문에, 한 구간 내에서 마지막으로 발생한 이벤트가 무엇인지 알기 위해서는, 구간이 끝날 때까지 기다려야 합니다. 그래서 trailing은 이벤트 발생과, 실제 함수가 실행되는 리스닝하는 시간의 차이(딜레이)가 존재합니다.

Origin stream
Origin stream
Trailing stream
Trailing stream

  1. [t=0] A가 발생합니다. 10 msec 길이의 구간 α가 시작됩니다. A를 기억합니다.
  2. [t=7, 구간 α] 새로운 이벤트 B가 발생했습니다. B를 기억합니다.
  3. [t=10] 구간 α가 끝납니다. 구간 α 안에서 발생한 마지막 이벤트인 B를 리스닝합니다. 그리고 B에 대해서 새로운 구간 β가 정의됩니다.
  4. [t=12, 구간 β] C가 발생합니다.
  5. [t=20] 구간 β가 끝납니다. β에서의 마지막 이벤트인 C가 리스닝됩니다. C가 끝난 후 새로운 구간 γ가 설정됩니다.
  6. [t=22, 구간 γ] 이벤트 D가 발생합니다. D를 기억합니다.
  7. [t=25, 구간 γ] 이벤트 E가 발생합니다. E를 기억합니다.
  8. [t=30] 구간 γ가 끝납니다. E를 리스닝합니다. 새로운 구간 δ를 설정합니다.
  9. [t=40] 구간 δ가 끝납니다.
  10. [t=45] 이벤트 F가 발생합니다. 새로운 구간 ε을 설정합니다.
  11. [t=55] 구간 ε이 끝납니다. F를 리스닝합니다.

참고로 구현에 따라서 세부적인 디테일은 다를 수 있습니다.

 

예를 들어서, 이벤트 리스너에 남아있는 이벤트가 없는 경우, 새로운 구간을 굳이 설정하지 않을 수 있습니다. 그렇게 된다면, C가 발생했을 때 새롭게 구간이 설정되고, 그 구간은 22초까지 이어져서 C가 아닌 D가 리스닝될 것입니다. E는, t=30이 아닌 t=35에 발생할 것입니다.

Trailing and Leading Throttle

Origin stream
Origin stream
Trailing and leading stream
Trailing and leading stream

Trailing과 Leading을 혼합한 방식은 더 위와 같습니다. 우선 Leading의 조건이 맞으면 Leading으로 수행하고, Leading 조건이 안 맞아도, Trailing 조건에 맞으면 Trailing으로 동작합니다.

  1. [t=0] 이벤트 A가 발생되고 리스닝됩니다(Leading 방식). 그리고 새로운 구간 α가 발생합니다.
  2. [t=7] 구간 α 내에서 이벤트 B가 발생됩니다. 이때 무시하는 게 아니라, 기억합니다(Trailing 방식).
  3. [t=10] 구간 α 내에서 마지막으로 발생한 이벤트 B를 리스닝합니다(Trailing). 새로운 구간 β를 설정합니다.
  4. [t=12] 구간 β 내에서 이벤트 C가 발생합니다. 이벤트 C를 기억합니다(Trailing).
  5. [t=20] 구간 β가 끝납니다. 기억해둔 이벤트 C를 리스닝합니다(Trailing). 새로운 구간 γ를 설정합니다.
  6. [t=22] 구간 γ 내에서 이벤트 D가 발생합니다. 기억합니다.
  7. [t=25] 구간 γ 내에서 이벤트 E가 발생합니다. 기억합니다.
  8. [t=30] 구간 γ가 끝납니다. 기억해둔 이벤트 E를 리스닝합니다(Trailing). 새로운 구간 δ를 설정합니다.
  9. [t=40] 구간 δ가 끝납니다.
  10. [t=45] 새로운 이벤트 F가 발생하고 리스닝됩니다(Leading).

Trailing과 마찬가지로 구현에 따라서 달라질 수 있습니다. 이번에도 똑같이 Trailing 시에, 남아있는 이벤트가 없을 시에 구간을 설정하지 않는다면, C는 t=22에 리스닝되고, E는 t=25에 Leading 방식으로 리스닝되었을 것입니다.

 

Throttle & Debounce behavior (lodash)에서는 같은 상황이지만, 저의 시나리오와 다르게 흘러가고 있습니다. 원래는 이 내용을 기반으로 하려고 했다가, 몇몇 부분에서 이상한 점을 발견해서 저의 시나리오로 설명했습니다.

 

혹시 그림이나 설명에서 잘못된 점이 있다면 알려주시기 바랍니다. 인터넷에서 설명하는 방식과 많이 다를 수 있습니다.

 

제가 사용한 코드입니다. Dart로 구현하고 있습니다.

import 'dart:async';

typedef VoidCallback = void Function();

class Throttle {
  final Function callback;
  final Duration wait;

  final bool leading;
  final bool trailing;

  Timer? _timeout;
  List<dynamic>? _lastCallArgs;

  Throttle({
    required this.callback,
    required this.wait,
    this.leading = true,
    this.trailing = true,
  });

  void later() {
    if (trailing && _lastCallArgs != null) {
      Function.apply(callback, _lastCallArgs!);
      _lastCallArgs = null;
      _timeout = Timer(wait, later);
    } else {
      _timeout = null;
    }
  }

  void call(List<dynamic> args) {
    if (_timeout != null) {
      _lastCallArgs = args;
      return;
    }
    if (leading) {
      Function.apply(callback, args);
    } else {
      _lastCallArgs = args;
    }
    _timeout = Timer(wait, later);
  }
}

void testThrottle(bool leading, bool trailing) {
  final startTime = DateTime.now();
  final events = {
    const Duration(seconds: 0): 'A',
    const Duration(seconds: 7): 'B',
    const Duration(seconds: 12): 'C',
    const Duration(seconds: 22): 'D',
    const Duration(seconds: 25): 'E',
    const Duration(seconds: 45): 'F',
  };

  final throttled = Throttle(
    callback: (String arg) {
      final givenTime = events.entries.firstWhere((e) => e.value == arg).key;
      final passedTime = DateTime.now().difference(startTime);
      print('$arg\t${givenTime.inSeconds}ms\t${passedTime.inSeconds}ms');
    },
    wait: const Duration(seconds: 10),
    leading: leading,
    trailing: trailing,
  );

  events.forEach((duration, event) {
    Timer(duration, () => throttled.call([event]));
  });
}

void main() {
  print("Scenario: leading == true && trailing == true");
  print('event | givenTime | actualTime');
  testThrottle(true, true);
  Timer(const Duration(seconds: 60), () {
    print("\nScenario: leading == false && trailing == true");
    testThrottle(false, true);
    Timer(const Duration(seconds: 60), () {
      print("\nScenario: leading == true && trailing == false");
      testThrottle(true, false);
    });
  });
}

결과:

Scenario: leading == true && trailing == true
event | givenTime | actualTime
A       0ms     0ms
B       7ms     10ms
C       12ms    20ms
E       25ms    30ms
F       45ms    45ms

Scenario: leading == false && trailing == true
B       7ms     10ms
C       12ms    20ms
E       25ms    30ms
F       45ms    55ms

Scenario: leading == true && trailing == false
A       0ms     0ms
C       12ms    12ms
E       25ms    25ms
F       45ms    45ms

혹시 모를 오차를 줄이기 위해서, milliseconds가 아닌 seconds, 즉 초 단위로 했습니다.

 

위 코드는 https://github.com/nx006/throttle_debounce_demo 이곳에서 확인할 수 있습니다.

Debounce와 Throttle의 차이점

Debounce와 Throttle의 시연 데모

위 영상에서 Debounce와 Throttle의 차이점을 한 번에 확인해볼 수 있습니다.

 

 

이처럼, debounce는 한 묶음의 이벤트를 하나의 이벤트로만 처리한다는 느낌이 강해서, 이벤트 묶음이 끝나야, 즉 일정 시간 동안 이벤트가 발생하지 않아야 합니다.

 

반대로 Throttle은 이벤트가 끊임 없이 일어나는 상황에서, 일정 시간마다 주기적으로만 실제 함수를 실행(이벤트를 리슨)합니다.

위에서 함수(setState)가 불린 횟수의 차이점을 보시면, 확연한 차이를 느낄 수 있습니다.

 

  디바운스 Debounce 쓰로틀 Throttle
공통점
  • 이벤트 덩어리(Event Chunk)들을 한 묶음으로 처리(Chunk 단위)
  • 덩어리 크기가 아무리 커도 하나의 이벤트만 리스닝할 수 있음
  • 시간 단위(Time interval) 단위를 한 묶음으로 처리(Interval 단위)
  • 연속적인 이벤트에 대해서 일정 시간 단위로 n개만 리스닝할 수 있음
Leading
  • 첫 번째 이벤트만 리스닝, 나머지 연속적인 이벤트들은 전부 무시
  • 첫 이벤트의 딜레이 없음
  • 첫 번째 이벤트를 리스닝하고, 일정 시간 동안 무시
  • 첫 이벤트의 딜레이 없음
Trailing
  • 이벤트 청크의 맨 마지막만 리스닝
  • 이벤트 발생과 리슨의 딜레이 존재
  • 구간 시간 내 마지막 이벤트만 리스닝
  • 이벤트 발생과 리슨의 딜레이 존재
Mixed
  • 첫 번째 이벤트와 마지막 이벤트만 리스닝
  • 첫 이벤트 딜레이 없음, 마지막 이벤트의 딜레이는 존재
  • 구간 시간 내 첫 번째 이벤트와 마지막 이벤트만 리스닝
  • 첫 이벤트 딜레이 없음, 마지막 이벤트의 딜레이는 존재

Flutter에서 Debounce와 Throttle 사용하기

위 영상의 코드는 Flutter로 작성했답니다.

debounce_throttle 패키지

설치

pub.dev에서 debounce_throttle을 검색해보시면 패키지가 하나 나옵니다.

$ flutter pub add debounce_throttle

패키지를 설치합니다.

Debouncer, Throttle 객체 선언하기

String generalText = '';  // 일반적인 텍스트
String debounceText = ''; // debounce 적용 텍스트
String throttleText = ''; // throttle 적용 텍스트
int generalCount = 0;
int debounceCount = 0;
int throttleCount = 0;

final debouncer = Debouncer<String>(
  const Duration(seconds: 1),
  initialValue: '',
  checkEquality: false,
);

final throttler = Throttle<String>(
  const Duration(seconds: 1),
  initialValue: '',
  checkEquality: false,
);

우선, Debouncer 객체랑 Throttle 객체를 선언해줍니다. 제네릭에는 리스닝하는 실제 값의 타입이 들어가며, 여기서는 String을 바꿀 예정입니다.

 

Duration을 무조건 설정해주어야 하는데요, Debouncer의 경우 얼마 동안 이벤트가 발생하지 않아야 함수를 실행할 지, 그리고 Throttle은 얼마만큼의 주기로 함수를 실행할 지 결정합니다.

여기서는 1초로 설정했습니다.

 

checkEquality는 동등성 체크인데, 이벤트가 발생을 해도 이전과 이후의 값이 바뀌지 않을 수 있습니다. checkEquality가 기본은 false로 되어 있는데, 이러면 이벤트 발생 이전과 이후의 값이 같지 않다면, 리스닝하는 함수를 호출하지 않습니다.

그러나 이전과 이후에 값이 같아도 호출이 되어야 하는 경우(값이 우연히 같아도 실행되어야 하거나, 혹은 애초에 이벤트가 멱등할 경우) checkEquality를 false로 해주시면 됩니다. 저도 그냥 false로 해놓겠습니다.

이벤트 리스닝 함수 추가하기

@override
void initState() {
  super.initState();

  debouncer.values.listen((event) {
    setState(() {
      debounceText = event;
      debounceCount++;
    });
  });

  throttler.values.listen((event) {
    setState(() {
      throttleText = event;
      throttleCount++;
    });
  });
}

이벤트를 리스닝하고 있으니깐, 이벤트가 발생했을 때 어떤 행동을 할 지 정의해주어야 합니다.

 

이 코드를 작성하는 위젯 자체가 statefulWidget 이므로, initState 안에서 초기화를 해주겠습니다.

 

event 에는 이벤트 발생 이후 결과물이 들어갑니다. 여기서는 String 이겠죠? 그러면 setState를 호출해서, debounceText, throttleText에 각각 값을 넣어주고, Count를 올려주면, 리스닝 작업 끝입니다.

이벤트 발생시키기

CustomTextFormField(
  onChanged: (value) {
    debouncer.setValue(value);
  },
),
CustomTextFormField(
  onChanged: (value) {
    throttler.setValue(value);
  },
),

CustomTextFormField는 제가 직접 정의한 컴포넌트 위젯인데, 어차피 똑같은 ValueChanged<String>? onChanged 함수를 받고 있으므로 일반 Material 3의 TextFormField 위젯이랑 똑같습니다.

 

debouncer, throttler에 있는 setValue 함수를 통해서 이벤트를 발생시키면 됩니다. 이 이벤트는 우리가 정해둔 Duration에 의거하여 이벤트를 선별적으로 발생시킬 것입니다.

 

setValue 함수로 넘어간 저 value가, listen 함수 속 콜백 함수의 인자인 event로 넘어가는 구조입니다.

위 데모 영상 속 레포지토리는 아래 링크에서 확인할 수 있습니다.

https://github.com/nx006/throttle_debounce_demo

직접 구현하기

조금 귀찮으나 원한다면 직접 구현할 수도 있습니다.

 

위에서의 Dart 코드도 엉성하게나마 Throttle을 직접 구현한 것입니다. Debounce는 새로운 이벤트가 발생할 때마다 타이머를 초기화하고, 타이머가 끝나면 이벤트를 리스닝한다. Throttle은 새로운 이벤트가 발생하면 타이머를 시작하고, 타이머가 끝날 때까지 이벤트를 무시한다. 타이머가 끝나면 다시 이벤트를 리스닝하기 시작한다.

 

이게 핵심입니다. 여기서 Trailing, Leading까지 구현한다면 더 완벽하겠죠? 사실 Flutter 쪽에는 Trailing, Leading이 적용된 패키지가 없습니다. 그래서 해당 기능까지 이용하려면 좋으나 싫으나 직접 구현해야 합니다.

 

JavaScript 진영 대비 확실히 힘이 약한 Flutter 생태계가 이런 곳에서 드러납니다😢😢.

import 'dart:async';
import 'package:meta/meta.dart';
import 'package:simple_observable/simple_observable.dart';

/// Debounces value changes by updating [onChanged], [nextValue], and [values]
/// only after [duration] has elapsed without additional changes.
class Debouncer<T> extends Observable<T> {
  Debouncer(this.duration, {required T initialValue, void Function(T value)? onChanged, bool checkEquality = true})
      : super(initialValue: initialValue, onChanged: onChanged, checkEquality: checkEquality);
  final Duration duration;
  Timer? _timer;

  /// The most recent value, without waiting for the debounce timer to expire.
  @override
  T get value => super.value;

  @override
  void notify(T val) {
    _timer?.cancel();
    _timer = Timer(duration, () {
      if (!canceled) {
        super.notify(val);
      }
    });
  }

  @override
  @mustCallSuper
  void cancel() {
    super.cancel();
    _timer?.cancel();
  }
}

/// Throttles value changes by updating [onChanged], [nextValue], and [values]
/// once per [duration] at most.
class Throttle<T> extends Observable<T> {
  Throttle(this.duration, {required T initialValue, void Function(T value)? onChanged, bool checkEquality = true})
      : super(initialValue: initialValue, onChanged: onChanged, checkEquality: checkEquality);
  final Duration duration;
  Timer? _timer;
  bool _dirty = false;

  /// The most recent value, without waiting for the throttle timer to expire.
  @override
  T get value => super.value;

  Timer _makeTimer() => Timer(duration, () {
        if (!canceled) {
          if (_dirty) {
            _dirty = false;
            _timer = _makeTimer();
            super.notify(value);
          } else {
            _timer = null;
          }
        }
      });

  @override
  void notify(T val) {
    if (_timer == null) {
      _dirty = false;
      super.notify(val);
      _timer = _makeTimer();
    } else {
      _dirty = true;
    }
  }

  @override
  @mustCallSuper
  void cancel() {
    super.cancel();
    _timer?.cancel();
  }
}

실제 pub.dev에 있는 debounce_throttle의 핵심 코드도 이게 전부입니다.

Debounce와 Throttle, 언제 사용하면 좋을까?

Debounce

Debounce의 경우 자주 사용되는 케이스가 있습니다. 바로 서버로의 API 요청입니다. 특히 이벤트가 여러 번 발생되는 환경에서, 모든 이벤트를 리스닝하여 API 호출을 해야 할 필요가 있을까요? 어차피 Stateless 연결로 설계된 서버라면, 대부분 마지막의 결과만 리스닝해서 서버로 Request를 보내면 됩니다.

API 호출
API 호출

예를 들어서 화면의 상황을 생각해볼 수 있습니다.

 

사용자가 상품을 장바구니에 추가할 수 있고, 이 장바구니 정보는 서버로 전송된다고 가정합니다. 이때 사용자가 매번 버튼을 클릭할 때마다, API 요청을 해야 할까요?

 

(Stateless로 설계되었다면) 어차피 서버로 넘어가는 요청은, 그냥 맨 마지막 정보만 전송하면 됩니다. 어차피 사용자의 현재 모든 장바구니 정보가 클라이언트에서 서버로 한꺼번에 넘어가기 때문에, 굳이 반복적으로 서버에 요청을 보낼 필요가 없습니다.

 

이때 Debounce를 적용할 수 있습니다.

리스닝 함수 설정하기

class BasketStateNotifier extends StateNotifier<List<BasketItemModel>> {
  final UserMeRepository repository;

  final updateBasketDebounce = Debouncer(
    const Duration(seconds: 1),
    initialValue: null,
    checkEquality: false,
  );

  BasketStateNotifier({
    required this.repository,
  }) : super([]) {
    updateBasketDebounce.values.listen(
      (event) {
        _patchBasket();
      },
    );
  }

  Future<void> _patchBasket() async {

Basket의 상태를 수신하는 BasketStateNotifier입니다. 여기에서 Debouncer를 이용하고 있습니다. DebouncerDuration은 1초입니다.

 

Debouncer가 이벤트를 리스닝하고 있다가 실행하는 함수는 _patchBasket() 입니다.

 

_patchBasket() 함수를 살펴보면…

Future<void> _patchBasket() async {
  await repository.patchBasket(
    body: PatchBasketBody(
      basket: state
          .map(
            (e) => PatchBasketBodyBasket(
              productId: e.product.id,
              count: e.count,
            ),
          )
          .toList(),
    ),
  );
}

Repository에서 Basket에 대해서 PATCH 요청을 보내고 있습니다.

 

즉 Debouncer에 의해서, 사용자의 입력이 1초 동안 없을 때, 사용자의 Basket 정보를 담아서 서버로 전송합니다. 따라서 버튼을 연속적으로 클릭한다고 해서 서버에 부하가 생기는 일은 없습니다.

Event 발생시키기

적절한 타이밍에 이벤트를 발생시키면 됩니다.

Future<void> addToBasket({required ProductModel product}) async {
    /// ...
    updateBasketDebounce.setValue(null);
}

참고로 여기서 setValue에 null을 넘겨주는 이유는, 애초에 사용자의 장바구니(Basket) 정보는 별도의 Provider가 들고 있게끔 상태 관리 툴로 설정해주었기 때문입니다. 이건 상태 관리 패키지의 역할이니 큰 의미는 없습니다.

 

Debounce 적용 시 Server 상황

영상입니다. 버튼을 클릭하고서 바로 서버에 요청이 가는 게 아니라, 버튼을 클릭하고서 1초 동안 이벤트가 없을 때 서버로 요청이 가고 있습니다. 서버에 부하를 상당히 줄여줄 수 있겠죠?

Debounce에서 Optimistic Response는 필수입니다

이때 주의해야 할 게 있습니다. Debounce는 사용자가 버튼을 계속해서 클릭하게 된다면, 즉 이벤트를 계속해서 발생시키면 리스닝이 되지 않습니다. 이는 보이지 않는 백단에서는 참 유용한데, 이걸 사용자에게 보여지는 View 단에서 Debounce를 적용하면 안 됩니다!

 

사용자 입장에서는 버튼을 누를 때마다 숫자가 올라가길 바랄 것입니다. 그래서, Debounce를 사용할 때는 Optimistic Response 방식으로 설계해야 합니다. 즉 서버에 실제 API 요청을 보내기 전에, 그냥 클라이언트 단에서 값을 올려버리는 것입니다. 이후에 서버에 PATCH 요청을 보내면 성공하겠지라는 낙관적인 반응을 기대하면서요.

 

Optimistic Response는 데이터의 Integrity를 중요시하는 개념이 아니기 때문에(즉 이후에 실제 서버로 요청을 보냈을 때 실패하면, 클라이언트가 들고 있는 정보와 서버가 들고 있는 정보가 달라질 수 있음) 이에 대한 대비 역시 되어 있어야 합니다.

 

위 Basket에 관한 앱은 코드팩토리 님의 강의를 듣고 클론 코딩한 결과입니다.

Throttle

Throttle은 적용할 대표적인 부분이 하나 있습니다. 바로 스크롤 상황인데요, 유저가 스크롤이라는 이벤트를 발생시킬 때, 모든 스크롤 이벤트를 리스닝하는 것은 원하지 않지만, 계속해서 스크롤이 발생할 때는 일정한 간격으로 이벤트를 리스닝하고 싶을 수 있습니다. 이때 Throttle을 사용하면 좋습니다.

 

대표적인 스크롤 상황이 바로 페이지네이션 상황입니다. 페이지네이션에서도 Throttle을 적용할 수 있습니다. 이는 다른 글로 다루겠습니다.

📚 참고 자료