디바운스와 스로틀 — 이벤트 폭주를 제어하는 두 가지 전략
스크롤할 때마다 이벤트 핸들러가 초당 수십 번 실행된다면, 브라우저는 어떻게 될까요?
scroll, resize, input 같은 이벤트는 사용자가 조작하는 동안 끊임없이 발생합니다. 이 이벤트마다 무거운 작업(API 호출, DOM 계산 등)을 실행하면 브라우저가 버벅이거나 불필요한 네트워크 요청이 쏟아집니다. 디바운스와 스로틀은 이 "이벤트 폭주"를 제어하는 두 가지 전략입니다.
이벤트 폭주 문제
브라우저에서 이벤트가 얼마나 자주 발생하는지 직접 확인해 보겠습니다.
// 스크롤 이벤트 발생 횟수 확인
let count = 0;
window.addEventListener('scroll', () => {
count++;
console.log(`scroll 이벤트 발생: ${count}번`);
});
마우스 휠을 한 번 굴리기만 해도 10~30번의 이벤트가 발생합니다. resize도 마찬가지고, input 이벤트는 키를 누를 때마다 발생합니다.
이벤트마다 아래 같은 작업을 실행한다고 생각해 보면 문제가 명확해집니다.
- **검색 자동완성 **: 글자 하나 입력할 때마다 API 호출
- ** 스크롤 위치 계산 **: 매 스크롤마다 DOM 레이아웃 재계산
- ** 윈도우 리사이즈 **: 매 픽셀 변화마다 차트 다시 그리기
디바운스(Debounce)
디바운스는 ** 연속된 이벤트가 끝난 후, 마지막 이벤트로부터 일정 시간이 지나면 한 번만 실행 **하는 기법입니다.
타이핑을 예로 들면, 사용자가 타이핑을 멈추고 300ms가 지나야 비로소 콜백이 실행됩니다. 타이핑 중에는 타이머가 계속 리셋되니까요.
이벤트: ──X──X──X──X──────────────────X──X──────────
↓ 300ms 대기 후 ↓ 300ms 대기 후
실행: ─────────────────✓────────────────────────────✓──
검색 자동완성 예제
디바운스가 가장 빛나는 사례입니다.
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', (e) => {
// 글자 하나 입력할 때마다 API 호출 → 비효율적
fetchSuggestions(e.target.value);
});
"자바스크립트"를 검색하려고 6글자를 입력하면 API가 6번 호출됩니다. 디바운스를 적용하면 타이핑이 끝난 후 한 번만 호출됩니다.
디바운스 직접 구현
핵심은 clearTimeout + setTimeout 패턴입니다.
function debounce(callback, delay) {
let timerId = null; // 타이머 ID를 클로저로 기억
return function (...args) {
// 이전 타이머가 있으면 취소 (리셋)
clearTimeout(timerId);
// 새 타이머 설정
timerId = setTimeout(() => {
callback.apply(this, args); // this와 인자를 원본 그대로 전달
}, delay);
};
}
사용법은 간단합니다.
const searchInput = document.getElementById('search');
// 300ms 동안 입력이 없으면 실행
const handleSearch = debounce((e) => {
fetchSuggestions(e.target.value);
}, 300);
searchInput.addEventListener('input', handleSearch);
동작 순서를 정리하면 이렇습니다.
- 사용자가 "자"를 입력 → 300ms 타이머 시작
- 100ms 후 "바"를 입력 → 이전 타이머 취소, 새 300ms 타이머 시작
- 200ms 후 "스"를 입력 → 또 리셋
- ...타이핑 멈춤... 300ms 경과 →
fetchSuggestions("자바스크립트")한 번 실행
스로틀(Throttle)
스로틀은 ** 일정 시간 간격으로 최대 한 번만 실행 **을 보장하는 기법입니다.
디바운스와 다른 점은, 이벤트가 계속 발생하더라도 ** 주기적으로 실행 **된다는 것입니다. 디바운스는 이벤트가 멈춰야 실행되지만, 스로틀은 이벤트 중간에도 일정 간격으로 실행됩니다.
이벤트: ──X──X──X──X──X──X──X──X──X──X──
↓ ↓ ↓
실행: ────✓─────────✓─────────✓────────
200ms 간격 200ms 간격
무한 스크롤 예제
스크롤 위치를 감지해서 추가 데이터를 로드하는 무한 스크롤에서는 스로틀이 적합합니다.
window.addEventListener('scroll', () => {
// 스크롤할 때마다 실행 → 초당 수십 번 호출
checkScrollPosition();
});
스크롤 중에도 주기적으로 위치를 확인해야 하니까, 이벤트가 끝날 때까지 기다리는 디바운스보다는 일정 간격으로 실행하는 스로틀이 맞습니다.
스로틀 직접 구현 — timestamp 방식
마지막 실행 시각을 기록하고, 경과 시간을 비교하는 방식입니다.
function throttle(callback, limit) {
let lastCall = 0; // 마지막 실행 시각
return function (...args) {
const now = Date.now();
// 마지막 실행으로부터 limit ms가 지났으면 실행
if (now - lastCall >= limit) {
lastCall = now;
callback.apply(this, args);
}
// 아직 안 지났으면 무시
};
}
// 200ms 간격으로 스크롤 위치 체크
const handleScroll = throttle(() => {
const scrollY = window.scrollY;
const docHeight = document.documentElement.scrollHeight;
const winHeight = window.innerHeight;
// 하단 100px 이내에 도달하면 추가 로드
if (scrollY + winHeight >= docHeight - 100) {
loadMoreItems();
}
}, 200);
window.addEventListener('scroll', handleScroll);
스로틀 직접 구현 — setTimeout 방식
타이머를 사용하는 방식도 있습니다. 이 방식은 마지막 이벤트도 놓치지 않는 장점이 있습니다.
function throttle(callback, limit) {
let waiting = false; // 대기 중인지 여부
return function (...args) {
if (!waiting) {
callback.apply(this, args); // 즉시 실행
waiting = true;
setTimeout(() => {
waiting = false; // limit 후 다시 실행 가능
}, limit);
}
};
}
두 방식의 차이를 정리하면 이렇습니다.
| timestamp 방식 | setTimeout 방식 | |
|---|---|---|
| 첫 호출 | 즉시 실행 | 즉시 실행 |
| 마지막 이벤트 | 놓칠 수 있음 | 놓칠 수 있음 |
| 정확도 | Date.now() 기반 | 타이머 기반 |
| 구현 복잡도 | 단순 | 단순 |
두 방식 모두 기본 형태에서는 trailing 호출을 보장하지 않습니다. lodash 같은 라이브러리는 leading + trailing을 모두 처리하는 정교한 구현을 제공합니다.
디바운스 vs 스로틀 비교
| 디바운스(Debounce) | 스로틀(Throttle) | |
|---|---|---|
| ** 실행 시점** | 이벤트가 멈춘 후 | 일정 간격마다 |
| ** 동작 방식** | 타이머 리셋 | 실행 후 잠금 |
| ** 실행 보장** | 마지막 1회 | 간격당 최대 1회 |
| ** 검색 자동완성** | 적합 | 부적합 |
| ** 무한 스크롤** | 부적합 | 적합 |
| ** 윈도우 리사이즈** | 적합 (최종 크기만 필요) | 적합 (중간 피드백 필요) |
| ** 버튼 중복 클릭 방지** | 적합 (leading) | 적합 |
공부하다 보니 핵심 기준은 이것이었습니다.
- "결과만 중요하다" → 디바운스 (타이핑 끝난 후 검색)
- "중간 과정도 중요하다" → 스로틀 (스크롤 중에도 위치 확인)
Leading과 Trailing 옵션
기본 디바운스는 trailing(마지막에 실행)이고, 기본 스로틀은 leading(처음에 실행)입니다. 하지만 상황에 따라 반대로 설정해야 할 때가 있습니다.
Leading 디바운스
첫 번째 호출을 즉시 실행하고, 이후 연속 호출은 무시합니다. 버튼 중복 클릭 방지에 유용합니다.
function debounce(callback, delay, options = {}) {
let timerId = null;
const leading = options.leading || false;
return function (...args) {
const isFirstCall = timerId === null;
clearTimeout(timerId);
// leading: 첫 번째 호출이면 즉시 실행
if (leading && isFirstCall) {
callback.apply(this, args);
}
timerId = setTimeout(() => {
// trailing: leading이 아닐 때만 마지막에 실행
if (!leading) {
callback.apply(this, args);
}
timerId = null; // 리셋 — 다음 호출이 다시 "첫 번째"가 됨
}, delay);
};
}
// 결제 버튼 — 첫 클릭만 실행, 연타 무시
const handlePayment = debounce(
() => submitPayment(),
1000,
{ leading: true }
);
payButton.addEventListener('click', handlePayment);
Trailing 스로틀
기본 스로틀에 trailing 실행을 추가하면, 마지막 이벤트를 놓치지 않습니다.
function throttle(callback, limit, options = {}) {
let lastCall = 0;
let trailingTimer = null;
const trailing = options.trailing !== false; // 기본 true
return function (...args) {
const now = Date.now();
const remaining = limit - (now - lastCall);
// 간격이 지났으면 즉시 실행 (leading)
if (remaining <= 0) {
clearTimeout(trailingTimer);
lastCall = now;
callback.apply(this, args);
} else if (trailing && !trailingTimer) {
// 간격 내에서 마지막 호출을 예약 (trailing)
trailingTimer = setTimeout(() => {
lastCall = Date.now();
trailingTimer = null;
callback.apply(this, args);
}, remaining);
}
};
}
requestAnimationFrame을 이용한 스로틀
스크롤이나 리사이즈에서 UI를 업데이트한다면, setTimeout 대신 requestAnimationFrame(rAF)을 사용하는 것이 더 자연스럽습니다.
rAF는 브라우저의 ** 화면 갱신 주기 **(보통 60fps = 약 16.7ms 간격)에 맞춰 콜백을 실행합니다. 시간 간격을 직접 지정하는 것보다 렌더링과 동기화되어 더 부드럽습니다.
function throttleByRAF(callback) {
let ticking = false; // 프레임 요청 중인지 여부
return function (...args) {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
callback.apply(this, args);
ticking = false; // 다음 프레임 요청 가능
});
}
};
}
// 스크롤 시 요소 위치를 부드럽게 업데이트
const handleScroll = throttleByRAF(() => {
const scrollY = window.scrollY;
// 패럴랙스 효과나 스크롤 기반 애니메이션
parallaxElement.style.transform = `translateY(${scrollY * 0.5}px)`;
});
window.addEventListener('scroll', handleScroll);
rAF 스로틀은 간격을 직접 지정할 수 없다는 제약이 있습니다. "200ms마다"처럼 구체적인 간격이 필요하면 timestamp 방식의 스로틀을, 화면 갱신과 동기화가 중요하면 rAF를 사용하면 됩니다.
lodash와 React에서의 사용
직접 구현해 보면 원리를 이해하는 데 도움이 되지만, 실무에서는 검증된 라이브러리를 쓰는 것이 안전합니다.
lodash
lodash의 debounce와 throttle은 leading, trailing, maxWait 등 다양한 옵션을 지원합니다.
import { debounce, throttle } from 'lodash';
// 디바운스 — 300ms 후 실행
const handleSearch = debounce((query) => {
fetchSuggestions(query);
}, 300);
// 스로틀 — 200ms 간격으로 실행
const handleScroll = throttle(() => {
checkScrollPosition();
}, 200);
// 컴포넌트 언마운트 시 정리
handleSearch.cancel();
handleScroll.cancel();
lodash 전체를 import하면 번들 크기가 커집니다.
lodash/debounce처럼 개별 함수만 가져오거나,lodash-es를 사용해서 트리셰이킹을 활용하는 것이 좋습니다.
React에서 사용할 때 주의점
React 컴포넌트에서 디바운스/스로틀을 사용할 때는 리렌더링마다 새 함수가 생성되지 않도록 주의해야 합니다.
import { useMemo, useEffect } from 'react';
import { debounce } from 'lodash';
function SearchInput() {
// useMemo로 디바운스 함수가 리렌더링마다 재생성되지 않도록 방지
const handleSearch = useMemo(
() => debounce((query) => {
fetchSuggestions(query);
}, 300),
[] // 의존성 없음 — 한 번만 생성
);
// 컴포넌트 언마운트 시 타이머 정리
useEffect(() => {
return () => handleSearch.cancel();
}, [handleSearch]);
return (
<input
type="text"
onChange={(e) => handleSearch(e.target.value)}
placeholder="검색어를 입력하세요"
/>
);
}
useCallback으로 감싸는 것도 가능하지만, debounce 자체가 새 함수를 반환하므로 useMemo가 의도가 더 명확합니다.
정리
- ** 디바운스 **: 이벤트가 멈춘 후 한 번 실행. 검색 자동완성, 리사이즈 완료 감지에 적합
- ** 스로틀 **: 일정 간격으로 한 번 실행. 무한 스크롤, 드래그 위치 추적에 적합
- **rAF 스로틀 **: 브라우저 렌더링 주기에 맞춘 스로틀. 애니메이션, 패럴랙스에 적합
- 직접 구현의 핵심은
clearTimeout+setTimeout(디바운스),Date.now()비교 또는 플래그(스로틀) - 실무에서는 lodash나 프레임워크의 유틸을 사용하되, 원리를 알아야 옵션을 올바르게 선택할 수 있습니다