최근에 검색어 입력에는 디바운싱을, 검색어 자동완성에는 쓰로틀링을 사용했다. 프로젝트를 하면서 이해도를 높인 만큼 포스팅을 하면서 개념을 다시 한 번 정리해보려고 한다. 프로젝트에서는 라이브러리를 사용했지만, 이번 포스팅에서는 자바스크립트와 리액트 코드로도 만들어서 원리를 파헤쳐보려고 한다.
사용자에 의해 연속적인 이벤트가 trigger되었을 때 여러 이벤트 중 부분적인 이벤트만 실행되도록 하는 것이다. 왜 여러 이벤트 중 하나만 실행해야 하는 경우가 생길까?
검색 네트워크 요청을 생각해보자. 이 검색창은 검색 버튼을 따로 클릭하지 않아도 사용자가 검색창에 키워드를 타이핑하면 자동으로 네트워크 요청을 보낸다. 이 때 다음과 같이 입력한다.
ㄷ 디 딥 디바 디방 디바우 디바운 디바운ㅅ 디바운시 디바운싱
ㄷ도 검색하고 디바도 검색하고 디바우도 검색하기에는 api 호출이 지나치게 빈번하다. 네트워크 요청 보내기, DB에서 검색하기, 최종 검색 결과 출력하기.. 이 모든 일련의 과정을 타이핑 한 글자 한 글자 할 때마다 매번 거치기에는 리소스 낭비가 크다. 만약 검색 횟수가 제한된 유료 API를 사용한다면? 한 글자 한 글자 검색 결과 보여주는 것이 모두 비용이다. 성능 측면에서 지나치게 많은 이벤트를 다 처리하려고 하면 부담이 될 수 밖에 없다.
따라서 디바운싱과 쓰로틀링과 같은 최적화 방법을 사용하면 성능을 개선하고 불필요한 비용을 절약할 수 있다.
연속적인 이벤트 중 마지막 이벤트만 처리한다.
예를 들어 사용자가 마지막 글자를 타이핑하는 순간까지 기다렸다가, 마지막 한번 네트워크 요청을 보내려고 할 때 유용하다.

이벤트 실행 주기를 만든다.
예를 들어 나는 검색어 자동완성에 쓰로틀링을 사용하였는데, 사용자가 글자를 타이핑하는 0.3초마다 네트워크 요청을 보내려고 할 때 사용할 수 있다.
또는 스크롤할 때 특정 이벤트를 걸어놓았다면, 연속적으로 모든 이벤트를 다 실행하는 것이 아니라 일정 주기로 이벤트를 처리하여 성능을 유지하고자 할 때 유용하다.
function debounce(callback, delay) {
let timer;
return function (...args) {
clearTimeout(timer); // 연속적으로 타이핑하면 한글자 한글자 입력할 때마다 이전 timer를 제거하는 것이다.
timer = setTimeout(() => {
callback(...args); // 한 글자 타이핑 후 delay 시간이 지난 후에야 callback 실행
}, delay);
};
}
// 사용 예시
const searchInput = document.getElementById('search');
searchInput.addEventListener(
'input',
// outer 함수를 호출하여 inner 함수 반환하여 전달. 이 때 외부함수의 변수인 timer를 기억한다.
debounce((event) => {
console.log('검색어:', event.target.value); // 마지막 입력 후 실행
}, 300)
);
function throttle(callback, delay) {
let lastTime = 0; // 마지막 실행 시간을 저장
return function (...args) {
const now = Date.now(); // 이벤트가 발생할 때마다 갱신된다.
if (now - lastTime >= delay) {
callback(...args); // delay 시간이 지나면 실행
lastTime = now; // 마지막 실행 시간 갱신
}
};
}
// outer 함수를 호출하여 inner 함수 반환하여 전달. 이 때 외부함수의 변수인 lastTime을 기억한다.
const scrollHandler = throttle(() => {
console.log('스크롤 이벤트');
}, 500);
window.addEventListener('scroll', scrollHandler);
import { useEffect, useState } from 'react';
export const useDebounce = (value: string, limit: number): string => {
// 초기값을 컴포넌트가 마운트된 시점의 value로 지정했다가, handler가 호출될 때마다 새로 지정됨
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// limit 시간이 지나면 debouncedValue를 업데이트
const handler = setTimeout(() => {
setDebouncedValue(value);
}, limit);
// useEffect가 재호출되거나 컴포넌트가 언마운트될 때 타이머를 정리
return () => {
clearTimeout(handler);
};
}, [value, limit]); // limit도 의존성 배열에 포함하여 변경 시 타이머가 재설정되도록 함
return debouncedValue;
};
import { useEffect, useState } from 'react';
export const useThrottle = (value: string, limit: number): string => {
// 초기값을 컴포넌트가 마운트된 시점의 value로 지정했다가, handler가 호출될 때마다 새로 지정됨
const [throttledValue, setThrottledValue] = useState(value);
// 마지막으로 실행된 시간을 상태로 저장. default 값은 컴포넌트가 마운트되는 시점.
const [lastExecuted, setLastExecuted] = useState(Date.now());
useEffect(() => {
const handler = setTimeout(() => {
const now = Date.now();
// limit 시간이 지났다면 값 업데이트 및 마지막 실행 시간 갱신
if (now - lastExecuted >= limit) {
setThrottledValue(value);
setLastExecuted(now);
}
// 남은 시간 후에 핸들러 실행
}, limit - (Date.now() - lastExecuted));
// useEffect가 재호출되거나 컴포넌트가 언마운트될 때 타이머를 정리
return () => {
clearTimeout(handler);
};
}, [value, limit, lastExecuted]); // 타이핑할 때마다 useEffect 재실행. lastExecuted도 의존성 배열에 포함하여 변경 시 타이머가 재설정되도록 함
return throttledValue;
};
이번 글에서는 디바운싱과 쓰로틀링의 공통점과 차이점을 알아보았다. 이벤트의 연속적인 호출이 필요할 때 적절히 사용해서 성능과 리소스의 사용을 최적화할 수 있겠다.