
쓰로틀링과 디바운스에 대해서 들어본 사람도 있을거고, 처음 들어본 사람도 있을겁니다. 예전에 자바스크립트를 공부할 때는 그냥 html 엘리먼트에 DOM 이벤트를 구현하는 것만 신경썼다보니 클라이언트에서 구현된 코드가 서버에 얼마나 큰 영향을 줄 지는 고려하지 않은 채로 코드를 짜곤 했습니다.
리액트로 사이드 프로젝트를 진행하면서도 API의 호출량이 서버에 부하를 줄 수 있는 것엔 큰 관심을 가지지 못했었는데 매 초마다 수많은 요청을 서버로 보낼 수 있는 API 통신을 경험한 적 없던 이유도 있었죠.
그러다가 팀 프로젝트로 개발한 앱에서 API를 호출하는 이벤트 핸들러와 연결된 버튼을 연속으로 클릭하면 'Too many requests (429)' 에러 메세지가 콘솔에 찍히는 걸 경험했습니다. 이를 해결하기 위해서, 또 실제 서비스에서는 서버의 비용을 줄이기 위해서 네트워크 요청 제어 기술에 대해서 공부해야 할 필요성을 느꼈습니다. 이에 사용되는 쓰로틀링과 디바운스에 대해 설명해보고자 합니다.
마우스 포인터를 브라우저에서 움직일 때마다 요청이 발생하는 지도 앱을 사용한다고 가정하면 드래그나 줌인, 줌아웃을 할 때 포인터의 좌표값이 변경되면 그 때마다 서버로 요청이 갑니다. 한 명이 사용해도 3초에 수백 번에서 많게는 천 번까지 요청이 발생한다고 치면, 수백 명의 사용자가 동시에 요청을 발생시킬 때 서버에는 수십만, 수백만 번의 요청이 발생해서 서버에 부하가 엄청나게 걸릴 수 있습니다. 이 문제는 서버의 비용, 사용자 경험과 직결되기 때문에 개선해야하는 문제입니다.
쓰로틀링은 setTimeout 메서드와 waiting이라는 변수를 사용합니다. 원리는 일정 간격으로 이벤트 실행이 이루어지는 방식입니다. 처음 이벤트가 한 번 실행되고 나면 setTimeout 타이머로 설정한 시간만큼 그 사이에 발생하는 이벤트가 모두 무시됩니다. 그리고 타이머가 끝난 뒤 다시 이벤트를 발생시킬 수 있습니다.
쓰로틀링을 사용하게 되면 지도 앱, 웹소켓 실시간 데이터 요청, 브라우저 위 마우스 포인터 좌표 추적 등 초당 요청량이 엄청난 API의 요청을 극적으로 감소시키면서도 사용자에게는 끊기지 않는 UX를 제공합니다.
const rect = document.querySelector('#rect');
const defaultText = document.querySelector('default');
const debounceText = document.querySelector('debounce');
const throttleText = document.querySelector('throttle');
function throttle(cb, delay = 1000) {
let shouldWait = false;
let waitingArgs;
const timeoutFunc = () => {
if (waitingArgs == null) {
shouldWait = false;
} else {
cb(...waitingArgs);
waitingArgs = null;
setTimeout(timeoutFunc, delay);
}
}
return (...args) => {
if (shouldWait) {
waitingArgs = args;
return;
}
cb(...args);
shouldWait = true;
setTimeout(timeoutFunc, delay);
}
}
function incrementCount(element) {
element.textContent = (parseInt(element.innerText) || 0) + 1;
}
const updateDebounceText = debounce(() => {
incrementCount(debounceText);
});
rect.addEventListener('mousemove', (e) => {
incrementCount(defaultText);
updateDebounceText();
})
디바운스는 setTimeOut 메서드만 사용하면 구현할 수 있습니다. 첫 번째 이벤트 실행시 setTimeout으로 설정한 타이머가 같이 시작되는데, 다음 이벤트가 실행되면서 바로 이전의 이벤트에 시작됐던 타이머를 초기화하고 현재 이벤트를 기점으로 다시 타이머가 시작됩니다. 그래서 정해진 시간 안에 다시 이벤트가 발생하면 처음부터 정해진 시간을 다시 기다리게 되는 방식입니다.
const rect = document.querySelector('#rect');
const defaultText = document.querySelector('default');
const debounceText = document.querySelector('debounce');
const throttleText = document.querySelector('throttle');
function debounce(cb, delay = 1000) {
let timeout;
return (...args) {
clearTimeout(timeout);
timeout = setTimeout (() => {
cb(...args);
}, delay);
};
}
function incrementCount(element) {
element.textContent = (parseInt(element.innerText) || 0) + 1;
}
const updateDebounceText = debounce(() => {
incrementCount(debounceText);
});
rect.addEventListener('mousemove', (e) => {
incrementCount(defaultText);
updateDebounceText();
})
실시간으로 수많은 요청을 보내는 서비스에서 어떤 방식을 사용할지는 사용자의 경험을 더 중요시 할 것인지, 서버의 비용을 줄이는 것을 극대화할 것인지에 따라 선택할 수 있습니다.
이 경우에는 쓰로틀링이 더 적합합니다. 쓰로틀링을 걸면 요청 숫자는 줄이면서, 사용자는 드래그를 하면서도 현재 위치의 상태를 볼 수 있습니다. 간헐적으로 타이머의 간격마다 계속 요청이 가기 때문에 UX의 흐름이 끊기지 않고 사용자에게 계속 제공되는 장점이 있습니다.
이 경우에는 디바운스가 더 적합합니다. 예를 들어서, 지도 앱을 사용중인 사용자가 카페를 검색한 후 지도를 살펴볼 때 드래그를 멈출 때까지 API가 호출되지 않다가 드래그를 멈추면, 멈춘 최종화면에서 보이는 위치의 ‘카페’를 검색합니다. 이러한 작동 방식 때문에 디바운스는 쓰로틀링에 비해서 API 호출 수가 적어 쓰로틀링보다 훨씬 더 비용을 절약할 수 있게 됩니다. 하지만 사용자가 드래그를 하는 도중에는 검색한 정보의 요청 결과를 볼 수 없는 단점이 있죠.
import { useRef, useCallback } from 'react';
export default function useThrottle(callback, delay) {
const lastRun = useRef(0);
const throttledFunction = useCallback(
(...args) => {
const now = Date.now();
if (now - lastRun.current >= delay) {
callback(...args);
lastRun.current = now;
}
},
[callback, delay]
);
return throttledFunction;
}
실제로 제가 사용하기 위해 커스텀 훅을 생성했습니다. 콜백 함수와 사용자 정의 지연 시간을 인자로 전달합니다. 그럼 커스텀 훅 내부에서 초기 호출로부터 ref로 시점을 참조한 다음 전달 받은 지연 시간이 지나기 전까지는 callback이 다시 호출되지 못하게 합니다. 외부에서는 내부 로직을 신경 쓸 필요가 없기 때문에 아주 편하죠.
두 방식 모두 서비스에서 서버 비용을 줄이기 위해 사용하는 기술이기 때문에 많이 사용되고 있습니다. 프론트엔드 개발자는 언제나 사용자의 경험 우선시와 서버 비용의 절감을 기본으로 전제하고 개발을 해나가야 하기에 꼭 알아둬야 할 구현 상식인 것 같네요.