디바운스와 쓰로틀의 동작 원리를 비교 정리함
한창 Three.js를 활용해 3D 모델을 다루는 업무를 맡을 때였다.
마우스 커서의 움직임에 따라 3D 공간 속 모델과 인터랙션하는 레이캐스팅을 구현하고 그에 따른 이벤트와 UI를 함께 구현했는데,
그 결과가 너무 빠르고 연속적이며 복잡한 연산인 나머지 무거운 모델과의 인터랙션이 있을 때마다 화면이 뚝뚝 끊기거나 멈추는 프레임드랍, 프리징이 자주 일곤했다.
마우스 움직임 하나에 수백, 수천의 고성능 레이캐스팅 연산이 동시에 쏟아지며 브라우저가 비명을 지르며 쓰러진 것이다...
이 때 받은 피드백이 "디바운싱과 쓰로틀에 대해 알아보고 적용해볼 것"이었다.
그 때 이벤트 제어라는 개념을 실무에서 적용할 기회가 생겼고, 수많은 요청과 연산 속에서 프로그램이 숨쉴 틈을 만들어주는 최적화 단골 요소인 디바운스(Debounce)와 쓰로틀(Throttle)을 배웠기에 이번 포스팅에서 다시 정리하여 기록하고자 한다.

연산 장치나 브라우저/서버가 이런 비명을 지르는 상황에서 연속적인 이벤트 발생 빈도/통신 빈도 자체를 줄여주는 두 가지 대표적인 기법이 디바운스(Debounce) 와 쓰로틀(Throttle) 이다.
둘 다 "덜 자주 실행시킨다"는 목적은 같지만, 방식은 완전히 다르다. 이 차이를 헷갈리면 엉뚱한 곳에 잘못된 기법을 적용하게 된다.
디바운스는 연속된 이벤트를 하나로 묶어서, 마지막 이벤트로부터 일정 시간이 지난 뒤에 딱 한 번만 실행하는 방식이다.
디바운스(Debounce)의 어원은 "스위치가 튕기는 현상(Bounce)을 제거한다(De-)"는 뜻의 전자공학 용어이다.
기계식 스위치를 누를 때, 내부 금속 접점이 미세하게 통통통통통 튕기며 신호가 여러 번 중복 입력되는 현상이 발생하곤 했는데,
이 오작동을 막고, 신호를 딱 한 번만 깔끔하게 인식하게 만드는 하드웨어 기술"이 디바운스 기술이었다.
이 개념이 현대 웹 프로그래밍으로 넘어오면서 "연속으로 발생하는 이벤트를 하나로 묶어 마지막에 딱 한 번만 실행하는 기술"을 뜻하는 용어가 되었다.

쉽게 비유한 예들 중, 하나는 엘리베이터 예시가 있다.

당신이 엘리베이터를 타고 나서 올라가려는데, 옆집 사람이 달려온다.
그걸 보고 열림 버튼을 눌러 열어주고, 저기 멀리서 또 밑의 집 사람이 달려온다.
계속 반복하다, 마지막 사람을 위한 열림 버튼을 누르고 나서 아무도 오지않았을 때, 비로소 문은 닫히고 엘리베이터는 올라간다.
만약 이런 '배려'가 없었다면 당신이 먼저 타고 올라가고, 다시 내려온 엘리베이터를 타고 다음 사람이 타고 그 다음 사람이 다시...
몇 시간은 한 사람씩 타고 내리는 대환장 파티가 아파트 1층에서 벌어졌을 것이다.
이 문열림 버튼을 눌러주는 배려가 디바운싱이다.
마지막 사람을 위해 눌러주고 더 이상 탈 사람이 없어 누르지 않을 때, 싣어 올려 보내는 것.
디바운스를 적용하면 이벤트가 계속 발생하는 동안에는 실행을 계속 미루다가, 이벤트가 멈추고 나서야 비로소 함수가 실행된다.

검색창 자동완성이 대표적인 예시다. 사용자가 타이핑할 때마다 요청을 보내는 게 아니라, 타이핑이 멈춘 뒤에야 요청을 보내야 서버 부하도 줄고 화면도 덜 깜빡인다.
function debounce(fn, delay) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}debounce(fn, delay)는 그 자체로 실행되는 함수가 아니라, 새 함수를 만들어서 돌려주는 함수다.
그래서 안쪽 return function (...args) {...}가 실제로 이벤트에 연결되는 함수가 된다.
const debouncedInput = debounce(handleInput, 500);
input.addEventListener("input", debouncedInput);
// input 이벤트가 넘겨주는 이벤트 객체(e)가 그대로 args에 담긴다이 리턴된 함수는 debounce가 끝난 뒤에도 timerId, fn, delay를 계속 기억한다(클로저). 그래서 여러 번 호출해도 매번 같은 timerId를 공유하며 아래 동작이 가능해진다.
핵심은 clearTimeout이다.
함수가 호출될 때마다 이전에 걸어둔, 아직 실행 안 된 타이머를 취소하고 새 타이머를 다시 건다.
그래서 이벤트가 delay보다 짧은 간격으로 계속 들어오는 한, setTimeout의 콜백은 실행될 기회를 얻지 못하고 계속 뒤로 밀린다. 이벤트가 멈추고 delay 시간이 그대로 흘러야만 마지막으로 걸어둔 타이머가 살아남아 실행된다.
아래 데모를 통해 직접 디바운스를 경험해보자.
버튼을 빠르게 여러 번 눌러보면, 클릭 횟수는 계속 올라가지만 실제 실행(주황색 카운트)은 클릭을 멈추고 1초가 지난 뒤에야 딱 한 번 올라가는 걸 확인할 수 있다.
쓰로틀은 연속된 이벤트가 발생할 때, 설정한 일정 시간 간격(주기)마다 최대 한 번만 실행되도록 흐름을 제한하는 방식이다.
쓰로틀(Throttle)의 어원은 신체 부위인 '목구멍(Throat)'에서 유래했으며, '목을 졸라 숨통을 조이다'라는 뜻을 가진다.
흔히들 바이크나 자동차의 쓰로틀로 용어를 많이 알고 있을텐데,
기계공학에서는 자동차 엔진으로 들어가는 공기와 연료의 통로를 조절하는 '쓰로틀 밸브'를 의미했다.

가속 페달을 통해 이 목구멍을 열고 닫으면서, 엔진으로 유입되는 전체적인 유량의 흐름을 물리적으로 통제하고 과부하를 막는 기술이었다.
이 개념이 현대 웹 프로그래밍으로 넘어오면서 "이벤트가 아무리 폭포수처럼 연속으로 발생해도, 정해진 시간 주기마다 지속적으로 딱 한 번씩만 실행하는 기술"을 뜻하는 용어가 되었다.
디바운스처럼 실행을 미루는 게 아니라, 한 번 실행하고 나면 정해진 시간 동안 잠기고(lock), 일정 시간이 지나야 다시 실행 가능한 상태가 된다.

스크롤 위치를 추적해서 헤더를 숨기거나, mousemove로 드래그 좌표를 계산하는 경우가 대표적이다. 이런 경우엔 이벤트를 아예 안 받는 게 아니라, "일정 주기로는 꾸준히 반응은 해야 하는" 상황이라 디바운스보다 쓰로틀이 맞다.
function throttle(fn, interval) {
let locked = false;
return function (...args) {
if (locked) return;
locked = true;
fn.apply(this, args);
setTimeout(() => {
locked = false;
}, interval);
};
}디바운스가 타이머를 계속 취소/재등록하는 방식이라면, 쓰로틀은 locked 플래그로 문을 잠갔다 여는 방식이다.
첫 호출은 즉시 실행되고 동시에 문을 잠근다. 그 뒤로 들어오는 호출은 locked가 true인 동안 전부 무시되다가, interval이 지나 문이 열리면 다음 호출 한 번이 다시 즉시 실행된다.
버튼을 연타해보면, 시도 횟수는 계속 올라가지만 실제 실행은 1.2초에 한 번씩만 이루어지는 걸 확인할 수 있다. 잠긴 동안은 버튼 색이 바뀌고 진행 바가 채워진다.
두 데모를 나란히 눌러보면 차이가 명확해진다. 디바운스는 "멈춰야 실행"되고, 쓰로틀은 "주기적으로 실행"된다.

| 디바운스 | 쓰로틀 | |
|---|---|---|
| 실행 시점 | 이벤트가 멈춘 뒤 | 일정 간격마다 |
| 실행 횟수 | 연속 이벤트당 1번 | 간격마다 최대 1번 |
| 적합한 상황 | 검색 자동완성, 입력값 유효성 검사, 리사이즈 후처리 | 스크롤 위치 추적, 무한 스크롤, 드래그/마우스 이동 |
| 한 마디로 | "그만할 때까지 기다렸다가 한 번" | "하더라도 너무 자주는 안 된다" |
두 기법 모두 "이벤트 실행 빈도를 줄인다"는 목적은 같지만, 판단 기준은 하나로 요약할 수 있다.
도입부의 레이캐스팅 프리징 문제도 이 기준으로 접근했다.
onmousemove가 발생할 때마다 레이캐스팅 관련 연산을 돌리는 게 문제였는데, 마우스가 멈춰야만 결과가 필요한 게 아니라 움직이는 동안에도 계속 반응은 해야 하는 상황이라 디바운스는 맞지 않았다.
그래서 mousemove 핸들러에 쓰로틀을 걸어 연산 주기를 대폭 제한했고(UX가 상하지 않는 선에서), 이벤트 실행 횟수 자체를 줄이니 프레임드랍과 프리징이 잦아들었다.
lodash.debounce, lodash.throttle처럼 검증된 구현을 가져다 쓰는 경우가 많지만, 직접 구현해보면 setTimeout과 클로저만으로 이 정도 제어가 가능하다는 걸 체감할 수 있다.useDebounce, useThrottle 같은 커스텀 훅으로 감싸서 이벤트 핸들러에서 꺼내 쓰는 패턴이 일반적이다.