Node.js v26.10.0의 debounce와 throttle로 호출 조절하기
Node.js 코어 API로 입력 호출을 모으고 외부 API 요청의 시작 속도와 대기열을 제어하는 방법을 설명한다.
2026-10-02
연속 입력과 API 요청을 먼저 구분한다
입력이 멈춘 뒤 마지막 값으로 작업해야 한다면 debounce가 맞다. 외부 API 요청을 일정 속도 안에서 시작해야 한다면 throttle을 쓴다. 두 함수는 Node.js v26.10.0 릴리스 노트에 추가 항목으로 올라와 있다. 적용 기준 버전은 Node.js v26.10.0 (2026-09-22) — util.debounce / util.throttle added in v26.10.0이다.
두 함수의 차이는 초과 호출을 처리하는 지점에 있다. 다음 그림은 연속 호출이 실제 함수 실행과 반환 Promise에 어떻게 연결되는지 비교한다.
debounce를 외부 API의 요청 속도 제한으로 쓰면 마지막 요청만 실행될 수 있다. 반대로 입력이 멈췄을 때 한 번 실행하려는 작업에 throttle을 쓰면 기본 설정에서 초과 호출이 대기열에 남는다. 선택 전에 호출을 합칠지, 각각 나중에 실행할지를 정해야 한다.
입력이 멈춘 뒤 실행할 때는 debounce를 쓴다
util.debounce 문서에 따르면 debounce(fn, wait)는 가장 최근 호출로부터 wait밀리초가 지난 뒤 fn을 실행한다. 기다리는 동안 다시 호출되면 타이머가 다시 시작되고, 실행 인자는 가장 최근 호출의 것을 쓴다. 기본 설정에서는 앞선 호출이 받은 Promise도 마지막 실행의 결과로 이행하거나 거부된다.
문서의 예제에서 fn(1)과 fn(2)는 각각 Promise를 반환하지만, 두 Promise에서 읽는 값은 모두 2다.
import { setTimeout as wait } from 'node:timers/promises';
import { debounce } from 'node:util';
const fn = debounce(async (value) => {
await wait(100);
return value;
}, 50);
const first = fn(1);
const second = fn(2);
console.log(await first); // 2
console.log(await second); // 2
앞선 호출에 별도의 실패 신호가 필요하다면 rejectOnCancel: true를 검토한다. 이 설정에서는 뒤 호출에 밀린 호출의 Promise가 AbortError로 거부된다. leading: true는 새 디바운스 구간의 첫 호출을 즉시 실행한다. 그 구간에 추가 호출이 있어야 마지막 호출에 따른 후속 실행도 생긴다.
작업을 즉시 확정해야 할 때는 반환된 함수의 flush()를, 현재 구간을 취소해야 할 때는 cancel()을 쓴다. 대기 중인 호출은 pending과 pendingCount로 확인할 수 있다. 이 선택은 실행 시점뿐 아니라 이미 호출한 쪽이 어떤 결과를 받는지도 바꾸므로, 호출부의 Promise 처리까지 함께 확인해야 한다.
외부 API 요청은 throttle의 대기 정책까지 정한다
throttle(fn, limit, interval)은 구간당 fn의 시작 횟수를 제한한다. 문서의 요청 예제는 한 구간에 최대 두 요청을 시작하며, 초과 호출의 인자를 유지한 채 기본 대기열에 넣는다.
import { throttle } from 'node:util';
const request = throttle(async (id) => {
const response = await fetch(`https://example.com/items/${id}`);
return response.json();
}, 2, 1_000);
// At most two requests begin during each one-second interval. All other calls
// remain queued and retain their original arguments.
const results = await Promise.all([
request(1),
request(2),
request(3),
request(4),
]);
속도 제한과 동시에 진행할 수 있는 작업 수는 별개다. concurrency는 반환값이 아직 확정되지 않은 fn 실행의 최대 수를 정하며, 기본값은 Infinity다. 함수는 속도 용량과 동시 실행 용량이 모두 있을 때 시작한다. 속도 용량은 호출이 대기열에 들어갈 때가 아니라 fn이 시작할 때 소모된다.
대기열을 무제한으로 두기 어려운 호출부라면 초과 호출의 처리를 명시한다. 다음 분기는 속도 또는 동시 실행 용량이 없을 때 overflow와 maxPending이 결과를 어떻게 바꾸는지 보여 준다.
| 설정 | 용량이 없을 때 | 호출부에서 확인할 점 |
|---|---|---|
기본 overflow: 'queue' |
받은 순서대로 대기 | 대기 호출이 쌓여도 되는가 |
'queue'와 maxPending |
대기 호출 수가 한도에 이르면 추가 호출 거부 | 허용할 대기 호출 수는 얼마인가 |
overflow: 'drop' |
대기시키지 않고 즉시 거부 | 해당 호출을 버려도 되는가 |
거부된 호출은 ERR_THROTTLED로 거부되는 Promise를 반환한다. 이 Promise는 처리된 것으로 표시되어 무시해도 'unhandledRejection' 이벤트를 내지 않지만, await하거나 명시적으로 처리하면 거부를 관찰할 수 있다. 요청 결과가 필요한 호출부라면 거부 경로도 다뤄야 한다. maxPending은 'drop' 정책에는 영향을 주지 않는다.
엄격한 호출 간격이 필요하면 strict를 확인한다
기본 throttle은 첫 실행과 함께 구간을 시작하고, 다음 구간이 시작될 때 대기 호출을 처리한다. 이 방식에서는 구간 경계 양쪽의 실행이 시간상 가까워질 수 있다. 어떤 이동하는 시간 구간에서도 limit를 넘지 않아야 한다면 strict: true를 쓴다. 문서에 따르면 이 설정은 각 실행 시각을 개별적으로 추적하며 추가 관리 비용이 든다.
다음 비교는 제한값이 같아도 구간을 계산하는 방식에 따라 보장 범위가 달라짐을 보여 준다.
대기 상태를 보고 싶다면 pendingCount는 실행을 기다리는 호출 수, activeCount는 반환값이 아직 확정되지 않은 실행 수다. hasImmediateCapacity()는 지금 호출하면 즉시 시작할 수 있는지 알려 주지만 용량을 예약하지는 않는다. 실제 호출 시 용량을 다시 검사하므로, 이 검사 결과만으로 후속 호출의 즉시 실행을 단정해서는 안 된다.
취소와 Promise 반환을 호출부 계약에 반영한다
두 함수 모두 signal을 받아 대기 중인 호출을 취소하고 이후 호출을 막는다. 이미 중단된 신호를 넘겨 함수를 만들면 AbortError가 발생한다. 대기 호출이 중단되면 반환 Promise는 AbortError로 거부되고, 신호의 사유는 오류의 cause에 담긴다.
취소 범위에는 차이가 있다. debounce의 cancel()은 현재 디바운스 구간의 대기 Promise를 거부한다. throttle의 cancel()은 대기열의 호출을 취소하고 현재 제한 구간을 재설정하지만, 이미 시작한 fn 실행은 취소하지 않는다. throttle에 전달한 신호가 중단돼도 이미 시작한 실행에는 영향을 주지 않는다.
기존 호출부를 코어 API로 옮길 때는 함수 이름만 바꾸지 말고 반환값을 읽는 곳부터 점검해야 한다. 두 함수의 호출 결과는 Promise다. debounce에서는 여러 호출이 하나의 실행 결과를 공유할 수 있고, throttle에서는 대기 또는 즉시 거부가 생길 수 있다. 특히 외부 API 요청에 적용할 때는 허용할 대기 호출 수, 즉시 거부 여부, 엄격한 이동 구간 보장이 필요한지를 정한 뒤 호출부의 성공·거부 처리를 맞춰야 한다.