Debounce vs Throttle: Definitive Visual Guide 를 읽어보다가 중간에 나오는 throwBall()을 이미지로 표현한 것이 꽤나 재밌어서 번역하면서 생각을 적어보려고 한다.
💡 아이콘이 있는 내용은 제가 따로 추가한 내용입니다.
debounce(디바운스)와 throttle(스로틀)에 대해 개발자들은 종종 혼동을 한다. 이 글은 디바운스와 스로틀을 구분하고 각각의 사용 시기를 더 잘 이해하는데 유용할 것이다.
디바운스와 스로틀은 이벤트 처리를 최적하는 두 가지 방법이다.
이벤트는 시스템에서 발생하는 작업이다. 프론트엔드 개발에서 시스템은 브라우저이다. 우리는 이벤트가 발생했을 때 로직을 실행한다. 이러한 로직을 이벤트를 처리하기 때문에 핸들러 함수라고 한다. 예를 들어, 'resize'라는 이벤트가 발생하면 UI 요소 업데이트하는 핸들러 함수를 통해 이벤트를 처리한다.
자바스크립트에서 이벤트 리스너(Event Listener)를 사용해 이벤트에 반응할 수 있다. 이벤트 리스너는 DOM 요소에서 지정된 이벤트를 수신하고 해당 이벤트가 발생할 때마다 핸들러 함수를 실행하는 함수이다. 엘리멘트(대상)에 이벤트 리스너를 추가하려면 addEventListener 함수를 사용해야 한다.
element.addEventListener(eventName, listener, options)
여기 버튼을 누르면 볼을 던지는 기계가 있다. 버튼 클릭과 볼을 던지는 것을 버튼 요소에 addEventListener
사용해서 인과 관계를 설명할 수 있다.
// 페이지에서 button 요소를 찾습니다.
const button = document.getElementById('button')
// 그리고 클릭 이벤트를 설정합니다.
button.addEventListener('click', function () {
throwBall()
})
이렇게 하면 버튼을 클릭하면 throwBall()
함수를 실행한다.
사이트의 직접 실행해보시길 바란다.
버튼을 누르면 "클릭" 이벤트가 발생하고, 이벤트 리스너는throwBall()
함수를 호출한다. 즉, 버튼을 한 번 클릭하면 핸들러 함수가 한 번 호출되고 공이 한 번 던져진다.
기본적으로 이벤트 리스너는 이벤트 호출에 대해 일대일 비율로 실행된다.
이벤트 리스너가 호출되는 횟수를 제한할 수 없을까? 일반적으로 이벤트 리스너를 제어하는 방법은 스로틀링과 디바운싱이다.
💡 element.addEventListener(eventName, listener, options)
의 엘리멘트와 파라미터가 동일한 다수의 이벤트 리스너가 있어도 한 번만 호출된다.
const func = () => {
throwBall()
}
button.addEventListener('click', func, false)
button.addEventListener('click', func, false)
// 한 번만 호출된다.
스로틀링은 일정시간동안 함수를 호출할 수 있는 횟수를 제한하는 것이다. 예를 들어 몇 초에 한 번, 또는 몇 밀리초에 한 번씩만 실행하는 것이다.
function throttle(func, duration) {
let shouldWait = false
return function (...args) {
if (!shouldWait) {
func.apply(this, args)
shouldWait = true
setTimeout(function () {
shouldWait = false
}, duration)
}
}
}
lodash.throttle
및_.throttle
패키지를 살펴볼 것을 적극 권장합니다.
위 코드의 throttle
함수는 스로틀을 하는 func
와 스로틀링 간격의 지속 시간인 duration
를 인자로 받고 스로틀된 함수를 리턴한다.
기계의 버튼 클릭을 스로틀링하려면 throttle
함수의 첫 번재 인수로 이벤트 핸들러 함수를 전달하고, 두 번재 인수로 스로틀링 간격을 지정해야 한다.
button.addEventListener(
'click',
throttle(function () {
throwBall()
}, 500)
)
(스로틀링 간격 사이에 몇 번을 클릭해도 공은 하나만 나온다)
아무리 버튼을 눌러도 공은 스로틀링 간격에 한 번만 던져진다 볼 머신이 과열되는 것을 방지할 수 있는 좋은 방법이다.
스로틀은 공을 던지는 스프링과 같다. 공이 날아간 후 다시 수축하는데 시간이 필요하므로 준비가 되지 않으면 더 이상 공을 던질 수 없다.
잦은 이벤트에 일관되게 반응하려면 스로틀링을 사용하라.
이 기술은 주어진 시간 간격 내에서 일관된 함수 실행을 보장한다. 스로틀은 고정된 시간 프레임에 바인딩되므로 이벤트 리스너는 이벤트의 중간 상태를 받아들일 준비가 되어 있어야 한다.
resize
조정 후 일관된 UI 업데이트디바운스된 함수는 마지막 호출 이후 N 시간이 경과한 후에 호출된다. 이는 이벤트와 함수 호출 사이에 지연이 있음을 의미한다.
function debounce(func, duration) {
let timeout
return function (...args) {
const effect = () => {
timeout = null
return func.apply(this, args)
}
clearTimeout(timeout)
timeout = setTimeout(effect, duration)
}
}
lodash.debounce
및_.debounce
패키지를 살펴볼 것을 권장합니다.
위의 debounce
함수는 디바운스를 하는func
와 마지막 함수 호출에서 경과한 시간인 duration
를 인자로 받고 디바운스된 함수를 리턴한다.
예제에 디바운스를 적용하려면 버튼 클릭 핸들러를 debounce
함수로 감싸야 한다.
button.addEventListener(
'click',
debounce(function () {
throwBall()
}, 500)
)
debounce
함수의 실행은 throttle
함수 실행과 유사한 경우가 많지만, 적용하면 훨씬 다른 효과를 낸다. 버튼 클릭이 디바운스 될 경우 머신이 어떻게 작동하는지 살펴보자.
(버튼 클릭이 멈추고 500ms 후에 공이 나온다)
버튼을 계속 빠르게 누르면 마지막 클릭 이후 디바운스 기간(500ms)이 지나지 않는 한 공이 전혀 던져지지 않는다. 이는 기계가 정해진 시간 동안의 버튼 클릭 횟수를 하나의 이벤트로 간주하여 각각 처리하는 경우이다.
디바운스는 과부하된 웨이터와 같다. 만약 계속 웨이터에게 질문을 하면 당신의 질문이 끝나고 약간의 시간 뒤 마지막 질문에만 대답하는 하는 것과 같다.
빈번한 이벤트에 마지막으로 반응하고 싶으면 디바운스를 써라.
디바운스는 중간 상태가 필요하지 않고 이벤트의 최종 상태에 응답하고자 할 때 유용하다. 즉, debounce
를 사용할 때는 이벤트와 이에 대한 응답 사이에 불가피한 지연이 발생할 수 있다는 점을 고려해야 한다.
이러한 속도 제한 함수를 작업할 때 가장 흔한 실수 중 하나는 반복적으로 함수를 다시 선언하는 것이다.
클릭 이벤트 핸들러를 예를 들어보겠다.
button.addEventListener('click', function handleButtonClick() {
return debounce(throwBall, 500)
})
언뜻 보기에는 괜찮아 보이지만 실제로는 디바운스 되지 않는다. handleButtonClick
함수는 디바운스되지 않고 대신 throwBall
함수를 디바운스하기 때문이다.
대신, handleButtonClick
함수 전체를 debounce로 감싸야 한다.
button.addEventListener('click', debounce(function handleButtonClick() {
return throwBall()
}, 500)
)
이벤트 핸들러 함수는 단 한 번만 디바운스 또는 쓰로틀되어야 하며, 반환된 함수는 어떤 이벤트 리스너에게든 제공되어야 한다.
아래 코드는 잘못된 예시이다.
function MyComponent() {
const handleButtonClick = () => {
console.log('The button was clicked')
}
return (
<button onClick={debounce(handleButtonClick, 500)}>
Click the button
</button>
)}
debounce
는 렌더링 중에 호출된다. 버튼 클릭 이벤트가 발생할 때마다 새로운 handleButtonClick
함수를 생성하고 이 함수를 디바운스하지만, 이 함수는 클릭마다 새로 생성되므로 디바운스가 제대로 동작하지 않는다.
function MyComponent() {
const handleButtonClick = debounce(() => {
console.log('The button was clicked')
}, 500)
return (
<button onClick={handleButtonClick}>
Click the button
</button>
)}
대신 handleButtonClick
선언을 디바운스해야 합니다.
위의 React 예시는 상태 변경으로 인해 리렌더링이 되면 다시 handleButtonClick
를 선언하게 된다. 그렇다면 다시 선언하게 된다. 그러므로 useCallback
을 사용하여 한 번만 렌더링 될 수 있도록 해야 한다.
function MyComponent() {
const handleButtonClick = () => {
console.log('The button was clicked')
}
// useCallback을 사용하여 debouncedCallback을 React의 재렌더링에서 제외해야 합니다.
const debouncedCallback = useCallback(debounce(handleButtonClick, 500), [])
return (
<button onClick={debouncedCallback}>
Click the button
</button>
)}
디바운스와 스로틀 모두 UX와 성능에 최적화된 지속 시간을 찾는 것이 중요하다.
사실, 사용 사례마다 시간 간격이 다르기 때문에 마법의 숫자는 없다. 제가 드릴 수 있는 가장 좋은 조언은 무턱대고 간격을 복사하지 말고 애플리케이션/사용자/서버에 가장 적합한 간격을 테스트하는 것이다. A/B 테스트를 수행하여 다음을 확인할 수 있다.
Debounce와 Throttle은 모두 함수를 호출하는 빈도를 제한하는 기술이다. Debounce는 함수를 일정 시간 내에 한 번만 호출하고,Throttle은 함수를 일정 시간마다 호출한다.
Debounce와 Throttle 모두 UX와 성능을 향상시키기 위해 사용될 수 있다.
예를 들어, 검색 창에서 입력할 때마다 검색 결과를 업데이트하는 것이 성능에 영향을 미칠 수 있다. Debounce를 사용하면 일정 시간 동안 입력이 없을 때만 검색 결과를 업데이트할 수 있다. 이는 성능을 향상시키는 동시에 사용자가 검색 결과를 기다리는 시간을 줄일 것이다.
또한, 드롭다운 메뉴에서 항목을 선택할 때마다 서버에 요청을 보내는 것도 성능에 영향을 미칠 수 있다. Throttle을 사용하면 일정 시간마다 한 번만 서버에 요청을 보낼 수 있다. 이는 성능을 향상시키는 동시에 서버에 과도한 부하가 걸리는 것을 방지한다.
Debounce와 Throttle 모두 사용 사례에 따라 적절한 시간 간격을 선택하는 것이 중요하다. 시간 간격이 너무 짧으면 성능에 영향을 미치지 않지만, 시간 간격이 너무 길면 UI가 느리게 느껴질 수 있다.
최적의 시간 간격을 찾는 가장 좋은 방법은 테스트인 것 같다. 다양한 시간 간격을 테스트하여 가장 적합한 시간 간격을 찾아야 할 것이다.