서론
프론트엔드 개발을 하다 보면 스크롤 이벤트, 입력 처리, 윈도우 리사이즈 등 짧은 시간 내에 반복적으로 발생하는 이벤트를 효율적으로 제어할 필요가 생긴다. 이럴 때 가장 많이 사용하는 도구 중 하나가 lodash에서 제공하는 debounce와 throttle 함수이다.
프로젝트를 진행하며 성능 최적화를 위해 정확히 어떤 조건에서 실행되는지 궁금해졌고, 결국 실제 lodash 소스를 분석해보게 되었다. 이번 글에서는 그 분석을 바탕으로 두 함수의 동작 원리와 차이점을 정리해본다.
Debounce 분석
lodash의 debounce와 throttle은 내부적으로 모두 debounce 함수를 기반으로 구성되어 있다. 따라서 먼저 debounce 함수의 흐름을 분석해보았다.
function debounce(func, wait, options) {
var lastArgs,
lastThis,
maxWait,
result,
timerId,
lastCallTime,
lastInvokeTime = 0,
leading = false,
maxing = false,
trailing = true;
if (typeof func != 'function') {
throw new TypeError(FUNC_ERROR_TEXT);
}
wait = toNumber(wait) || 0;
if (isObject(options)) {
leading = !!options.leading;
maxing = 'maxWait' in options;
maxWait = maxing ? nativeMax(toNumber(options.maxWait) || 0, wait) : maxWait;
trailing = 'trailing' in options ? !!options.trailing : trailing;
}
function invokeFunc(time) {
var args = lastArgs,
thisArg = lastThis;
lastArgs = lastThis = undefined;
lastInvokeTime = time;
result = func.apply(thisArg, args);
return result;
}
function leadingEdge(time) {
// Reset any `maxWait` timer.
lastInvokeTime = time;
// Start the timer for the trailing edge.
timerId = setTimeout(timerExpired, wait);
// Invoke the leading edge.
return leading ? invokeFunc(time) : result;
}
function remainingWait(time) {
var timeSinceLastCall = time - lastCallTime,
timeSinceLastInvoke = time - lastInvokeTime,
timeWaiting = wait - timeSinceLastCall;
return maxing
? nativeMin(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
}
function shouldInvoke(time) {
var timeSinceLastCall = time - lastCallTime,
timeSinceLastInvoke = time - lastInvokeTime;
// Either this is the first call, activity has stopped and we''re at the
// trailing edge, the system time has gone backwards and we''re treating
// it as the trailing edge, or we''ve hit the `maxWait` limit.
return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
(timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait));
}
function timerExpired() {
var time = now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// Restart the timer.
timerId = setTimeout(timerExpired, remainingWait(time));
}
function trailingEdge(time) {
timerId = undefined;
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgs) {
return invokeFunc(time);
}
lastArgs = lastThis = undefined;
return result;
}
function cancel() {
if (timerId !== undefined) {
clearTimeout(timerId);
}
lastInvokeTime = 0;
lastArgs = lastCallTime = lastThis = timerId = undefined;
}
function flush() {
return timerId === undefined ? result : trailingEdge(now());
}
function debounced() {
var time = now(),
isInvoking = shouldInvoke(time);
lastArgs = arguments;
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timerId === undefined) {
return leadingEdge(lastCallTime);
}
if (maxing) {
// Handle invocations in a tight loop.
clearTimeout(timerId);
timerId = setTimeout(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
debounce함수의 return값은 내부 method인 debounced이다. 우리가 반환된 함수를 호출할 때, 내부의 timer와 최초호출 여부(물론 이는 전달한 option에 따라 다르지만, 여기서는 기본값으로 가정)에 따라 timer를 등록하고 callback함수를 호출하지 않거나, 최초 호출 후 전달한 wait시간만큼이 지났다면 callback함수를 호출한다. 즉, debounce는 전달한 시간(wait) 동안 함수 호출이 연속되면 그 시간 동안 실행을 지연시키고, 호출이 멈췄을 때 함수가 실행되도록 한다.
이를 정리해보면
🔸 최초 호출
- shouldInvoke()를 통해 최초 호출 여부를 판단한다. lastCallTime이 undefined이므로 isInvoking은 true이고, leadingEdge에서 최초 호출은 무시되고 단순히 타이머만 설정된다. ( leading 옵션이 false인 경우 )
🔸 이후 호출이 wait 이전일 때
- func()는 호출되지 않으며 이전 result 값만 유지된다. 즉, callback함수를 호출하지 않는다.
- 이때, lastCallTime이 현재 시점으로 갱신됨에 따라 이전에 등록한 setTimeOut이 만료되어 setTimeOut의 callback함수인 timerExpired가 호출되어도 shouldInvoke가 false이게 된다. 그리고 timerExpired에서는 timer를 remainingWait(wait과 lastCallTime만큼의 차이)시간만큼 다시 기다리게 등록한다. 이를 통해 wait시간 안에 계속 debounced 함수를 호출하여도 사용자가 debounce에 전달한 callback함수의 호출 지연시킬 수 있는 것이다.
🔸 타이머 만료 시 (timerExpired)
- shouldInvoke 조건이 충족되기 때문 trailingEdge()에서 invokeFunc()를 호출해 실제 func를 실행한다.
Throttle 분석
function throttle(func, wait, options) {
var leading = true,
trailing = true;
if (typeof func != 'function') {
throw new TypeError(FUNC_ERROR_TEXT);
}
if (isObject(options)) {
leading = 'leading' in options ? !!options.leading : leading;
trailing = 'trailing' in options ? !!options.trailing : trailing;
}
return debounce(func, wait, {
'leading': leading,
'maxWait': wait,
'trailing': trailing
});
}
throttle은 leading이 true이며, maxWait = wait로 설정한 debounce라고 볼 수 있다. 이 설정 덕분에 일정 시간 간격으로 무조건 func()가 실행되며, 연속 호출 중에서도 주기적으로 실행되도록 보장한다.
이때 leading이 true이기 때문에 최초 호출 시 무조건 func()가 실행된다. 반면, debounce는 기본적으로 leading: false이기 때문에 최초 호출은 실행되지 않는다. 이는 두 함수의 가장 큰 실행 타이밍 차이점이다.
실제 예시
import _ from 'lodash';
// 카운터 초기화
let countDebounce = 0;
let countThrottle = 0;
// 디바운스: 마지막 호출 이후 1초 동안 아무 호출 없으면 실행
const debouncedFn = _.debounce(() => {
console.log(`[Debounce] 실행됨: ${++countDebounce}`);
}, 1000);
// 스로틀: 호출되자마자 실행, 이후 1초에 한 번만 실행
const throttledFn = _.throttle(() => {
console.log(`[Throttle] 실행됨: ${++countThrottle}`);
}, 1000);
// 시작 시간 기록
const startTime = Date.now();
const intervalId = setInterval(() => {
const elapsed = Date.now() - startTime;
const seconds = (elapsed / 1000).toFixed(1).padStart(4, ' ');
console.log(`[Tick] ${seconds}s 경과`);
debouncedFn();
throttledFn();
if (elapsed >= 2000) clearInterval(intervalId); // 2초 후 종료
}, 100);
실제로 debouce와 throttle 함수를 100ms간격으로 실행해보았다. 실행한 결과는 아래와 같다.
그 결과를 보면 throttle은 1초 간격으로 꾸준히 실행되는 반면, debounce는 전체 호출이 끝나고 1초가 지난 뒤에 단 한 번 실행되었다. 이는 두 함수의 동작 원리를 완전히 이해하는 데 중요한 단서가 된다.
debounce는 함수가 호출될 때마다 lastCallTime을 현재 시각으로 계속 갱신한다. 그리고 타이머가 만료될 때마다 shouldInvoke()가 실행되며, wait 시간이 충분히 지났는지를 확인한다. 만약 그렇지 않다면, 즉 호출이 연속적으로 이루어지고 있어 wait만큼 경과하지 않았다면, remainingWait()을 계산해 남은 시간만큼 다시 타이머를 설정한다. 이 과정이 반복되면 func()는 실행되지 않고 계속 지연된다. 따라서 debounce는 wait 시간보다 짧은 간격으로 호출이 계속되면 절대 실행되지 않는다는 특성이 있다.
이번 실험에서처럼 debounced() 함수를 100ms마다 호출하면, lastCallTime이 계속 갱신되고, 타이머가 만료되더라도 remainingWait()이 늘어나게 된다. 결과적으로 debounce는 "마지막 호출 이후 wait 시간 동안 아무 호출도 없을 때만" 실행된다.
이러한 특성은 debounce가 어떤 상황에서 유용한지를 분명하게 보여준다. 예를 들어, 사용자가 입력을 하고 있을 때마다 서버에 요청을 보내는 대신, 입력이 끝났다고 판단되는 시점에 단 한 번 서버 요청을 보내고 싶다면 debounce가 적절하다. 검색 자동완성, 자동 저장, 폼 유효성 검사 등이 이에 해당한다.
반면 throttle은 일정 간격마다 함수를 강제 실행하기 때문에, 사용자의 행동이 연속되는 상황에서도 주기적인 처리를 보장할 수 있다. 대표적인 예는 스크롤 이벤트, 윈도우 리사이즈 이벤트, 마우스 이동 추적 등이다. 이러한 이벤트는 호출 빈도가 매우 높기 때문에 throttle로 호출 횟수를 제한하지 않으면 성능에 문제가 생기기 쉽다.
정리하자면, debounce는 최소한의 실행, throttle은 실행 빈도 제한이라는 목적에 최적화된 도구이다.이번 분석을 통해 그 구조와 동작의 본질을 직접 확인하고 나니, 상황에 따라 어떤 방식이 적합한지를 보다 명확히 판단할 수 있게 되었다.
Reference
https://github.com/lodash/lodash/blob/main/dist/lodash.js#L10372
'JS' 카테고리의 다른 글
| 브라우저에게 양보하기 (0) | 2025.09.28 |
|---|---|
| [JS] Javascript에서의 Closures (0) | 2025.09.19 |
| ES2021에서 추가된 신기능 5가지 (1) | 2025.02.13 |
| JavaScript 이벤트 루프 동작방식: Microtask Queue, Macrotask Queue (0) | 2024.11.21 |
| [JS] Promise와 Async/Await의 이해 (0) | 2024.08.23 |