Home

Blog

Debounce를 바닐라 자바스크립트, 그리고 리액트에서 구현하기

2023.10.08
27

Debounce의 동작방식을 다시 정리해봅시다. Debounce의 개념을 더 자세히 알고 싶으시다면 이전 포스팅인 Debounce vs Throttle을 읽어주세요!

Debounce는 일정 waiting time 동안 이벤트가 발생하지 않았을 때 가장 마지막 이벤트를 선택하여 콜백 함수를 실행합니다. 이렇게 마지막 이벤트를 처리하는 것이 trailing 방식이며, 처음 호출된 이벤트를 선택하는 방법은 leading 방식입니다.

Debounce를 어떻게 구현할까요?

구글링을 통해 다양한 디바운스 코드 예제를 살펴봤고, 바닐라 자바스크립트 예제의 경우 크게 두 가지 형식이 있었습니다. 코드의 모양새는 다르지만 기본적인 원리는 같은데요.

이벤트 발생 → waiting time 동안 동일한 이벤트가 또 발생하면 이전의 타이머를 지우고 새로운 타이머 생성, waiting time 동안 이벤트가 발생하지 않으면 이전의 타이머가 지워지지 않고 콜백 함수가 실행

디바운스는 위의 방식으로 구현됩니다.

위에서 언급했듯 바닐라 자바스크립트 코드의 두 가지 형식이 있는데 그 중 더 간단한 예제를 먼저 보겠습니다.

첫번째 예제는 아주 심플하게 전역 변수에 타이머를 저장하고, 이벤트가 waiting time 동안 발생했을 때 전역 변수에 저장해놓은 타이머를 clearTimeout하고 setTimeout으로 새로운 타이머 아이디를 할당합니다. 이 때, 디바운스 함수는 이벤트가 발생할 때마다 실행된다는 특징이 있습니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>example 1</title>
  </head>
  <body>
    <input type="text" id="input" />
    <script>
      const inputField = document.getElementById('input');

      let debounceTimer; // 전역 변수에 타이머 아이디를 저장

      const debounce = (callback, time) => {
        clearTimeout(debounceTimer); // 전역 변수에 저장해놓은 타이머를 취소하고
        debounceTimer = setTimeout(callback, time); // 새로운 타이머를 전역 변수에 할당
      };

      const inputHandler = (e) => console.log(e.target.value);

      inputField.addEventListener(
        'input',
        (e) => debounce(() => inputHandler(e), 500) // 이벤트가 실행될때마다 디바운스 함수 실행
      );
    </script>
  </body>
</html>

이 코드가 어떻게 동작하는 지 알아보기 위해 아래처럼 콘솔을 찍어보도록 하겠습니다.

const inputField = document.getElementById('input');

let debounceTimer;

const debounce = (callback, time) => {
  console.log('디바운스 호출');
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(callback, time);
};

const inputHandler = (e) => {
  console.log('콜백함수 호출');
  console.log(e.target.value);
};

inputField.addEventListener('input', (e) =>
  debounce(() => inputHandler(e), 500)
);

텍스트를 빠르게 입력하면

Screenshot 2023-10-05 at 8.28.16 PM.png

이렇게 매 이벤트마다 디바운스 함수가 호출되고, 최종적으로 콜백 함수는 한 번만 실행되는 모습입니다.

이 방식은 무척 단순하다는 장점이 있지만, 타이머 아이디를 전역 변수에 저장하므로써 확장성이 떨어진다는 단점이 있습니다. 만약, 디바운스 기법을 여러 이벤트에 적용해야 한다면 그에 따라 전역 변수와 디바운스 함수도 핸들링해야하는 이벤트의 갯수만큼 정의해야겠죠.

이 방식의 단점을 보완할 수 있는 두번째 예제 코드를 보겠습니다.

두번째 예제는 자바스크립트의 클로저라는 특성을 활용한 구현 방식입니다. 클로저는 간단하게 말해 중첩함수에서 발생하는 현상으로, 내부 함수가 외부 함수의 종료 이후에도 외부 함수의 변수를 기억하고 접근하여 사용할 수 있는 현상입니다.

이러한 클로저를 활용하면 타이머 아이디를 외부 함수의 내부 상태로 정의하여 외부로부터 보호할 수 있습니다. 이렇게 하면 디바운스 함수를 재사용하기 쉬워집니다. 디바운스 함수가 실행될 때 내부 상태인 타이머 아이디가 생성되므로, 첫번째 예제처럼 변수나 함수를 여러번 정의해야하는 수고로움은 없어지는 것이죠.

말이 어렵네요. 바로 코드를 통해 이해해봅시다.

  1. 전체 코드
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>example 2</title>
  </head>
  <body>
    <input type="text" id="input" />
    <script>
      const inputField = document.getElementById('input');

      const debounce = (callback, time) => {
        let debounceTimer;

        return (...args) => {
          clearTimeout(debounceTimer);
          debounceTimer = setTimeout(() => callback.apply(this, args), time);
        };
      };

      const inputHandler = (e) => console.log(e.target.value);

      const debouncedHandler = debounce(inputHandler, 500);

      inputField.addEventListener('input', debouncedHandler, 500);
    </script>
  </body>
</html>
  1. 디바운스 함수

이 전체 코드 중에서 디바운스 함수만 자세히 보겠습니다.

const debounce = (callback, time) => {
  // debounce는 외부 함수
  let debounceTimer; // 내부 상태로 타이머 아이디를 저장

  return (...args) => {
    clearTimeout(debounceTimer); // 반환되는 함수(내부 함수)가 실행될 때 외부 함수의 내부의 변수를 기억했다가 사용
    debounceTimer = setTimeout(() => callback.apply(this, args), time); // apply로 this의 문맥 보존
  };
};

이 디바운스 함수는 callback 함수와 time을 인자로 받고 있습니다.

  • callback : 이벤트 발생시 실행할 콜백 함수로, 빈도를 제어하고자 하는 대상
  • time : 콜백 함수를 실행하기 전에 기다려야 할 시간을 밀리초로 나타내는 값 (지연시간)

또한 함수를 반환하고 있는데요. 이 함수가 실행되면 저장해놓은 타이머를 취소하여 이전 이벤트 발생으로 인해 예약된 콜백 함수의 실행을 취소합니다. 그리고 현재 이벤트에 대한 콜백 함수의 실행을 예약하는 타이머를 저장합니다.

apply(this, args)가 뭐죠…?

debounce가 반환하는 함수가 어떤 문맥에 호출되었는지와 상관없이 원래 가지고 있던 this 문맥에서 실행하고 인수를 그대로 전달하기 위해 apply가 사용됩니다.

이 이유는 함수의 this가 동적으로 바인딩되는 특징 때문인데요. 함수의 this는 함수가 어떻게 호출되었는지에 따라 동적으로 바인딩됩니다. 이러한 특징 때문에 함수의 문맥을 제어하거나 변경하고 싶다면 call 이나 apply 메서드를 사용해야 합니다.

만약 위 예제에서 apply 메서드 없이 callback(…args)와 같은 방식으로 호출할 경우, callback 함수의 this는 전역 객체나 undefined가 될 수 있습니다. 물론 callback 함수가 this를 사용하는 상황일 경우에 유용합니다. 만약 callback 함수가 어떠한 경우에도 this를 사용하는 경우가 없다면 apply 메서드를 통해 this의 문맥을 보존할 필요가 없겠죠?

  1. 디바운스 함수의 사용 & 동작방식

맨 위에서 보았던 전체 코드에서 자바스크립트 부분만 살펴보겠습니다.

const inputField = document.getElementById('input');

const debounce = (callback, time) => {
  let debounceTimer;

  return (...args) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => callback.apply(this, args), time);
  };
};

// 'input' 이벤트 발생시 호출할 함수
const inputHandler = (e) => console.log(e.target.value);

// inputHandler에 디바운스를 적용한 함수
const debouncedHandler = debounce(inputHandler, 500);

inputField.addEventListener('input', debouncedHandler, 500);

이해하를 돕도록 일부 코드에 대한 설명입니다.

const inputHandler = (e) => console.log(e.target.value);

  • 'input' 이벤트 발생시 호출할 함수
  • console.log는 콜백 함수의 실행 빈도가 어떻게 제어되고 있는지 보기 위한 임시 로직
  • text input의 ‘input’이벤트의 콜백함수의 예시로 추천검색어에 대한 API 요청 등과 같은 로직이 사용될 수 있음

const debouncedHandler = debounce(inputHandler, 500);

  • debounce 함수가 실행되며 반환된 디바운스된 함수를 변수에 할당
  • 이 함수는 내부 상태(타이머)에 접근할 수 있는 클로저를 가지고 있다.

inputField.addEventListener('input', debouncedHandler, 500);

  • ‘input’ 이벤트가 발생할 때마다 debounceHandler가 호출
  • debounceHandler가 호출될 때마다 최초 debounce 함수가 실행되면서 반환했던 함수가 실행되며 clearTimeout과 setTimeout을 수행

여기서 유의해야할 점은 input 이벤트가 발생할 때마다 debouncedHandler는 실행되지만, 콜백 함수의 실행은 제어된다는 점입니다.

const inputField = document.getElementById('input');

const debounce = (callback, time) => {
  console.log('디바운스 호출');
  let debounceTimer;

  return (...args) => {
    console.log('디바운스된 함수 호출');
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => callback.apply(this, args), time);
  };
};

const inputHandler = (e) => {
  console.log('콜백함수 호출');
  console.log(e.target.value);
};

const debouncedHandler = debounce(inputHandler, 500);

inputField.addEventListener('input', debouncedHandler, 500);

이렇게 debounce와 디바운스된 함수(debouncedHandler), 그리고 콜백 함수(inputHandler)의 실행 빈도를 확인하기 위해 텍스트를 아주 빠르게 입력하여 콘솔을 찍어보면

Screenshot 2023-10-05 at 8.25.06 PM.png

이렇게 콘솔이 찍힙니다! 최초로 디바운스는 한 번만 실행되고, 디바운스된 함수는 ‘input’ 이벤트가 발생할 때마다 실행되며, 콜백함수는 최종 한 번만 실행되는 것을 알 수 있습니다.

*이 파트는 ‘정답 코드’를 바로 제공하지 않습니다. 리액트로 구현하며 겪은 삽질의 과정을 담고 있으니, 급하게 디바운스를 구현해야하는 분이라면 빠르게 ‘최종 코드’ 파트로 스크롤을 내려주세요!

이제 위에서 봤던 바닐라 자바스크립트 예제를 토대로 리액트에서 구현해볼건데요. 위 예제는 정말 간단했으니 리액트로 옮기기에 정말 쉬울 것 같습니다. 과연 그럴까요?

바닐라 스크립트 예시대로 그대로 리액트로 옮겨봤는데요. 이게 웬걸 정말 생각보다 쉽지 않습니다. 왜냐하면 리액트에서는 우리는 ‘state’를 사용하기 때문이죠! state 업데이트는 batching으로 렌더링이 끝난 후 한 번에 모아 실행되기 때문에 디바운스가 예상하는대로 동작하지 않았습니다. 그래서 최종 코드에 도달하기 위해 삽질과 구글링을 반복하며 무려 9개의 예제 코드를 작성하게 됐습니다. (즐거운 삽질)

code.png

삽질의 흔적…

전역 변수에 타이머 아이디를 저장하는 방식을 리액트로 그대로 구현하는 것은 설명하지 않겠습니다. 왜냐하면 너무 간단하니까요! (다만, 리액트에서는 바닐라 자바스크립트와 달리 일반 변수가 아닌 useState나 useRef를 사용해야 합니다. 컴포넌트 함수 내부에 정의하면 렌더링 될 때마다 undefined로 타이머 아이디가 생성되고 재할당하는 로직이 반복됩니다.)

여기서 디바운스는 텍스트 인풋의 onChange 이벤트에 적용하는 경우를 예시로 코드를 구현할 것입니다. 기본적인 형태은 아래와 같죠.

code.png

이 기본적인 형태에서 디바운스를 적용하여 구현할 것입니다. 클로저를 활용한 디바운스 함수를 리액트 컴포넌트 내에 그대로 구현하고, 이 로직을 커스텀 훅으로 분리하여 구현하는 것까지 단계별로 보겠습니다.

일단, 그대로 옮겨봅시다.

function App() {
  const [value, setValue] = useState('');

  const onChange = (e) => {
    setValue(e.target.value);
    requestSomething(e.target.value);
  };

  const requestSomething = (value) => {
    console.log(value);
  };

  const debounce = (callback, time) => {
    let debounceTimer;

    return (...args) => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => callback.apply(this, args), time);
    };
  };

  const debounceOnChange = debounce(onChange, 500); // onChange 로직 전체에 디바운스 적용

  return (
    <>
      <input type="text" value={value} onChange={debounceOnChange} />
    </>
  );
}

이 코드는 전혀 원하는대로 동작하질 않습니다. 왜냐구요? 우리는 보통 input의 입력값 상태값으로 저장하게 되는데요. (setValue(e.target.value);) 이 로직은 디바운스되어 실행 빈도가 제어되어야하는 것이 아닌, 매 이벤트마다 실행되어야하는 로직이기 때문입니다.

즉, onChange라는 이벤트 핸들러 자체에 디바운스를 적용하는 것이 아니라 requestSomething(e.target.value); 이 함수의 호출만 디바운스를 적용하여 실행 빈도를 제어하면 되는 것입니다.

requestSomething(e.target.value); 에만 디바운스를 적용할 수 있도록 코드를 바꿔보겠습니다.

function App() {
  const [value, setValue] = useState('');

  const onChange = (e) => {
    setValue(e.target.value);
    debounceRequest(e.target.value); // requestSomething을 디바운스한 함수를 사용
  };

  const requestSomething = (value) => {
    console.log(value);
  };

  const debounce = (callback, time) => {
    let debounceTimer;

    return (...args) => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => callback.apply(this, args), time);
    };
  };

  const debounceRequest = debounce(requestSomething, 500); // requestSomething에 디바운스 적용

  return (
    <>
      <input type="text" value={value} onChange={onChange} />
    </>
  );
}

export default App;

이제 이 코드는 제대로 동작할까요? 여전히 원하는대로 동작하지 않습니다. 이번에는 디바운스가 전혀 적용이 안되는 모습입니다. 콘솔을 살펴보면

screen.gif

이렇게 약간의 딜레이만 생길 뿐 발생한 모든 이벤트마다 requestSomthing이 실행된 것을 알 수 있습니다. 왜 이런 현상이 발생하는 것일까요?

바로 state의 변화로 인해 컴포넌트가 재렌더링 되기 때문입니다!

우리는 setState(e.target.value)를 통해 change 이벤트가 발생할 때마다 state에 input의 값을 저장했고, 이러한 state의 변화는 컴포넌트의 재렌더링을 trigger합니다. 이 때 컴포넌트가 재렌더링 될 때마다 debounce가 실행되어 새로운 debounceRequest를 생성하여 반환하기 때문에, 클로저를 가지는 debounceRequest가 컴포넌트가 재렌더링 될 때마다 새로운 클로저를 생성하여 매번 다른 debounceTimer를 참조하는 것이죠. (클로저가 제대로 동작하려면 debounceRequest가 실행될 때마다 동일한 클로저의 동일한 debounceTimer를 참조하여 저장해놓은 타이머 아이디를 clearTimeout해야합니다.)

screen.gif

이처럼 바닐라 자바스크립트 코드 예제에서 디바운스가 한 번만 실행되며 정상 작동하던 것과 달리, 디바운스 함수가 change 이벤트가 발생할 때마다 실행되는 것을 볼 수 있습니다.

그렇다면 여기서 디바운스가 제대로 동작하기 위해선 debounceRequest가 단 한 번만 생성되는 것이 보장되어야 합니다.

이러한 상황에서 사용할 수 있는 방법은 크게 두 가지가 있습니다.

  1. 첫번째 방법 : 재성성을 방지해야할 함수를 컴포넌트 함수 바깥에 정의하기
  2. 두번째 방법 : useCallback과 useMemo를 사용하여 메모이제이션 구현하기

먼저 첫번째 방법인 컴포넌트 함수 바깥에서 함수를 정의하여 함수의 재생성을 방지해보겠습니다.

function App() {
  const [value, setValue] = useState('');

  const onChange = (e) => {
    setValue(e.target.value);
    debounceRequest(e.target.value);
  };

  return (
    <>
      <input type="text" value={value} onChange={onChange} />
    </>
  );
}

export default App;

const debounce = (callback, time) => {
  let debounceTimer;

  return (...args) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => callback.apply(this, args), time);
  };
};

const requestSomething = (value) => {
  console.log(value);
};

// 함수 바깥에서 정의
const debounceRequest = debounce(requestSomething, 500);

step2의 코드에서 debounce 관련된 로직들을 컴포넌트 함수 바깥으로 빼보았습니다.

이제 이 코드는 원하는대로 동작합니다!

screen.gif

이제 두번째 방법인 useCallback, useMemo를 사용하여 함수의 재생성을 방지해봅시다.

어디에 적용할까?

우리가 메모이제이션을 적용해야할 곳은 컴포넌트 재렌더링시 재생성이 되어 문제가 되었던 주인공인 debounceRequest겠죠. 우리가 메모이제이션하려는 대상은 함수가 아닌 함수의 반환값이라는 것입니다! 그래서 useCallback이 아닌 useMemo를 사용해야 합니다.

import { useMemo, useState } from 'react';

function App() {
  const [value, setValue] = useState('');

  const debounce = (callback, time) => {
    let debounceTimer;

    return (...args) => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => callback.apply(this, args), time);
    };
  };

  const onChange = (e) => {
    setValue(e.target.value);
    debounceRequest(e.target.value);
  };

  const requestSomething = (value) => {
    console.log(value);
  };

  // 함수의 반환값 : useMemo로 저장
  const debounceRequest = useMemo(() => {
    return debounce(requestSomething, 500);
  }, []);

  return (
    <>
      <input type="text" value={value} onChange={onChange} />
    </>
  );
}

export default App;

이 코드도 똑같이 useMemo, debounce, requestSomething에 콘솔을 찍어보겠습니다.

screen.gif

아주 잘 동작하네요!

이게 최선일까?

원하는대로 디바운스가 잘 동작하긴 하지만, 코드가 영 맘에 들지 않습니다. UI 컴포넌트 함수 내부에 디바운스 관련 로직이 모두 드러날 필요가 있을까요? 아무래도 디바운스 로직이 재사용될 것을 감안하여 커스텀 훅으로 분리하여 구현하는 것이 좋겠습니다.

const debounce = (callback, time) => {
  let debounceTimer;

  return (...args) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => callback.apply(this, args), time);
  };
};

예제의 리액트 컴포넌트에서 계속 보았던 위 debounce 함수를 커스텀 훅으로 분리하여 구현해보겠습니다.

// useDebounce 훅
const useDebounce = (callback, delay) => {
  let debounceTimer;

  return (...args) => {
    if (debounceTimer) {
      clearTimeout(debounceTimer);
    }

    debounceTimer = setTimeout(() => {
      callback.apply(this, args);
    }, delay);
  };
};

// 컴포넌트
function App() {
  const [value, setValue] = useState('');

  const onChange = (e) => {
    setValue(e.target.value);
    debouncedRequest(e.target.value);
  };

  function requestSomething(val) {
    console.log(val);
  }

  const debouncedRequest = useDebounce(requestSomething, 500);

  return (
    <>
      <input type="text" value={value} onChange={onChange} />
    </>
  );
}

export default App;

과연 이 코드는 잘 작동할까요? (두근)

screen.gif

또 디바운스가 제대로 작동하질 않습니다! 이 이유는 step 2와 동일합니다.

  • change 이벤트발생하면 → state가 변함
  • state가 변할 때마다 → 컴포넌트가 재렌더링됨
  • 컴포넌트가 재렌더링될 때마다 → useDebounce가 실행됨
  • useDebounce가 실행될 때마다 → debounceTimer가 새로 생성됨

이러한 과정에 의해 결론적으로 이전에 생성된 타이머를 취소하지 못하고 있는 것입니다.

그렇다면 useDebounce가 실행될 때마다 새로운 debounceTimer가 생성되는 것이 아니라, 이전에 생성했던 변수를 참조할 수 있게 하는 방법은 무엇일까요?

useRef!

useRef는 DOM 요소를 저장할 때 뿐만아니라 렌더 사이에 값을 유지하고 싶을 때 사용합니다. 특히, useState와의 큰 차이점은 재렌더링을 유발하지 않는다는 것입니다. useRef를 사용해서 타이머나 인터벌 아이디에 대한 참조를 유지하는 것은 React 공식문서의 공식적인 예로 나와있습니다. (링크)

// useDebounce 훅
function useDebounce(callback, delay) {
  const debounceTimer = useRef(null);

  return (...args) => {
    if (debounceTimer.current) {
      clearTimeout(debounceTimer.current);
    }

    debounceTimer.current = setTimeout(() => {
      callback.apply(this, args);
    }, delay);
  };
}

// 컴포넌트
function App() {
  const [value, setValue] = useState('');

  const onChange = (e) => {
    setValue(e.target.value);
    debouncedRequest(e.target.value);
  };

  const requestSomething = (val) => {
    console.log(val);
  };

  const debouncedRequest = useDebounce(requestSomething, 500);

  return <input type="text" value={value} onChange={onChange} />;
}

export default App;

useDebounce와 콜백 함수의 콘솔, 그리고 참조하고 있는 타이머 아이디를 콘솔로 찍으면서 코드의 동작을 확인해보면

screen.gif

이렇게 원하는대로 잘 동작하는 것을 확인할 수 있습니다!

change 이벤트가 발생할 때마다 useDebounce가 실행되고, 최초로 useDebounce가 실행됐을 때는 타이머 아이디가 초기값 null이었다가, 그 이후의 useDebounce 실행마다 이전 실행에서 저장해뒀던 타이머 아이디를 참조하고 있는 것을 알 수 있습니다.

debounce라는 기법에 대한 이해 뿐만아니라 리액트에서 단계별로 구현해보며 리액트 컴포넌트의 렌더링에 대해 깊이 이해할 수 있는 시간이었습니다.

특히! 예상치 못했던 이득은 클로저에 대한 이해인데요. 공부를 해도 제대로 이해한건지 만건지 싶었던 클로저를 확실히 알 수 있게 됐습니다.

다음 글에서는 throttle의 예제와 리액트로 구현하는 과정을 들고 오겠습니다!

https://onlydev.tistory.com/151

https://webdesign.tutsplus.com/javascript-debounce-and-throttle--cms-36783t

https://velog.io/@soulee__/Javascript-쓰로틀링-디바운싱-throttle-debounce

Eunjee Lee • © 2023 • https://eun-jee.com