Debounce와 Throttle을 선택하는 기준
Debounce와 Throttle을 선택하는 기준
Debounce는 연속 이벤트가 멈춘 뒤 최종 의도를 처리하고, Throttle은 이벤트가 계속되는 동안 일정 빈도로 중간 상태를 처리한다. 둘 다 호출 수를 줄이지만 사용자에게 보이는 지연과 보존하는 정보가 다르다. 타이머만 줄일 것이 아니라 예약 작업 취소, 마지막 호출 보장, 오래된 네트워크 응답 무시까지 함께 설계해야 한다.
검색창에 한 글자를 입력할 때마다 API를 호출하거나 스크롤 이벤트마다 레이아웃을 다시 계산하면 불필요한 작업이 빠르게 늘어난다. 이때 흔히 debounce와 throttle을 사용한다.
const search = debounce(fetchResults, 300);
const updateScroll = throttle(renderProgress, 100);
코드는 비슷해 보이지만 의미는 다르다. 잘못 선택하면 서버 요청 수는 줄어도 검색 결과가 지나치게 늦게 나타나거나, 드래그가 끊기거나, 마지막 상태가 화면에 반영되지 않는다.
이 글에서는 구현 문법보다 다음 질문을 중심으로 두 기법을 비교한다.
- 중간 이벤트를 버려도 되는가?
- 사용자가 멈추기 전에도 결과를 보여야 하는가?
- 첫 이벤트와 마지막 이벤트 중 무엇을 보장해야 하는가?
- 비동기 작업이 이미 시작된 뒤에는 무엇을 취소해야 하는가?
목차
- #먼저 이벤트의 시간축을 본다
- #Debounce는 멈춤을 기다린다
- #Throttle은 흐름 중간을 샘플링한다
- #Leading과 trailing을 결정하기
- #Cancel과 flush가 필요한 이유
- #검색 요청은 타이머와 네트워크를 함께 취소한다
- #React에서 함수 인스턴스를 안정적으로 유지하기
- #스크롤과 resize에는 requestAnimationFrame도 고려한다
- #Debounce와 React의 지연 기능을 구분하기
- #자주 발생하는 실패 패턴
- #측정과 선택 체크리스트
- #마무리
- #관련 노트
- #참고 자료
먼저 이벤트의 시간축을 본다
사용자가 80ms 간격으로 여섯 번 입력했다고 가정하자.
이벤트 e1---e2---e3---e4---e5---e6----------------
시간 0 80 160 240 320 400 ms
300ms trailing debounce는 마지막 이벤트 이후 조용한 시간이 300ms 이어졌을 때 한 번 실행한다.
이벤트 e1---e2---e3---e4---e5---e6----------------
실행 -----------------------------------D--------
700ms
200ms throttle은 이벤트가 계속되는 동안 최대 200ms에 한 번 정도 실행한다. leading과 trailing 설정에 따라 정확한 시점은 달라진다.
이벤트 e1---e2---e3---e4---e5---e6----------------
실행 T-------------T-------------T---------------
0 200 400
둘의 차이는 단순한 호출 횟수가 아니다.
| 질문 | Debounce | Throttle |
|---|---|---|
| 보존하려는 것 | 연속 입력의 마지막 의도 | 진행 중인 상태의 표본 |
| 실행 시점 | 이벤트가 멈춘 뒤 | 이벤트가 계속돼도 일정 간격 |
| 대표 사례 | 검색어, 자동 저장, validation | 스크롤 진행률, pointer 이동, resize 관측 |
| 사용자가 느끼는 비용 | 결과 시작이 늦어짐 | 중간 상태가 성기게 보임 |
| 마지막 값 보장 | trailing 설정 필요 | trailing 설정에 따라 달라짐 |
“이 이벤트가 끝났다는 것을 기다려야 의미가 생기는가?”라면 debounce, “계속되는 동안에도 현재 상태를 보여야 하는가?”라면 throttle을 먼저 고려한다.
Debounce는 멈춤을 기다린다
Debounce는 호출될 때마다 기존 타이머를 취소하고 새로 예약한다.
type AnyFunction = (...args: never[]) => void;
export function debounce<T extends AnyFunction>(
callback: T,
waitMs: number,
) {
let timerId: ReturnType<typeof setTimeout> | undefined;
return (...args: Parameters<T>) => {
if (timerId !== undefined) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
callback(...args);
timerId = undefined;
}, waitMs);
};
}
구조를 설명하기 위한 단순 예시다. 실제 공용 유틸리티라면 this 보존, 반환값, leading, trailing, maxWait, cancel, flush 타입까지 결정해야 한다. 검증된 라이브러리를 사용하는 편이 나을 수 있다.
검색 입력에 적용하면 마지막 키 입력이 끝난 뒤 요청한다.
const searchInput =
document.querySelector<HTMLInputElement>("#search");
const requestResults = debounce((keyword: string) => {
void fetchResults(keyword);
}, 300);
searchInput?.addEventListener("input", (event) => {
const target = event.currentTarget;
requestResults(target.value.trim());
});
Debounce가 잘 맞는 경우는 이벤트 중간값보다 최종값이 중요한 작업이다.
- 사용자가 입력을 멈춘 뒤 검색
- 입력 중 연속 validation을 줄이기
- 문서 변경이 잠잠해진 뒤 자동 저장
- 창 크기 변경이 끝난 뒤 비싼 전체 레이아웃 계산
하지만 wait 시간이 길면 사용자가 매번 그만큼 기다린다. 서버 부하를 줄이는 값과 사용자에게 자연스러운 지연 사이의 균형을 실제 입력 흐름에서 확인해야 한다.
이벤트가 wait보다 짧은 간격으로 계속 발생하면 trailing debounce는 영원히 실행되지 않을 수 있다. 자동 저장처럼 일정 시간 안에는 반드시 실행해야 한다면 maxWait가 필요하다.
Throttle은 흐름 중간을 샘플링한다
Throttle은 일정 시간 창 안에서 실행 횟수를 제한한다. 가장 단순한 timestamp 기반 leading throttle은 다음과 같다.
export function throttle<T extends (...args: never[]) => void>(
callback: T,
intervalMs: number,
) {
let lastExecutedAt = -Infinity;
return (...args: Parameters<T>) => {
const now = performance.now();
if (now - lastExecutedAt < intervalMs) {
return;
}
lastExecutedAt = now;
callback(...args);
};
}
이 구현은 첫 이벤트를 즉시 처리하지만 interval 안에 들어온 마지막 이벤트를 버린다.
스크롤 진행률에서 사용하면 움직이는 동안 결과가 주기적으로 보인다.
const updateProgress = throttle(() => {
const scrollable =
document.documentElement.scrollHeight - window.innerHeight;
const ratio =
scrollable <= 0 ? 1 : window.scrollY / scrollable;
progressBar.style.transform = `scaleX(${ratio})`;
}, 100);
window.addEventListener("scroll", updateProgress, {
passive: true,
});
사용자가 스크롤을 멈춘 정확한 마지막 위치까지 반영하려면 trailing 호출을 지원하거나 별도의 종료 처리가 필요하다.
Throttle이 잘 맞는 경우는 진행 과정의 관측이 필요한 작업이다.
- 스크롤 위치에 따른 진행률
- pointer 이동에 따른 미리보기
- 지속적인 resize 중 크기 표시
- 무한 스크롤 임계점 확인
- 일정 빈도의 telemetry 샘플
관측 빈도가 낮으면 UI가 끊겨 보이고, 높으면 최적화 효과가 작다. 16ms, 100ms 같은 숫자를 관습적으로 복사하지 말고 화면 갱신인지 네트워크 전송인지에 따라 정한다.
Leading과 trailing을 결정하기
두 기법 모두 첫 호출과 마지막 호출을 어떻게 처리할지 선택할 수 있다.
flowchart TD
E["연속 이벤트 시작"] --> Q{"첫 결과가 즉시 필요한가?"}
Q -- "예" --> L["leading 실행"]
Q -- "아니오" --> W["대기"]
L --> M{"마지막 상태도 필요한가?"}
W --> M
M -- "예" --> T["trailing 실행"]
M -- "아니오" --> X["중간 호출만 제한"]| 설정 | 동작 | 어울리는 사례 |
|---|---|---|
| leading false, trailing true | 멈춘 뒤 마지막 한 번 | 검색, validation |
| leading true, trailing false | 시작 즉시 한 번 | 중복 클릭 방지와 비슷한 흐름 |
| leading true, trailing true | 시작과 마지막 보장 | 진행 상태와 최종 위치 |
| maxWait | 계속 호출돼도 최대 대기 후 실행 | 자동 저장, telemetry batch |
버튼 제출 중복 방지는 debounce보다 버튼 disabled, 요청 멱등성, 서버 idempotency key가 더 직접적인 해결책일 수 있다. 시간 제어 유틸리티를 비즈니스 무결성 장치로 사용해서는 안 된다.
검색에서는 첫 글자를 즉시 요청하는 leading 방식이 오히려 의미 없을 수 있다. 반면 suggestion cache가 충분하고 첫 결과가 빨리 필요한 제품에서는 leading과 trailing을 함께 사용할 수도 있다.
Cancel과 flush가 필요한 이유
컴포넌트가 사라졌는데 예약된 callback이 실행되면 이미 폐기된 UI를 갱신하거나 불필요한 저장을 수행할 수 있다. 공용 debounce에는 최소한 cancel 동작을 고려한다.
type Debounced<T extends (...args: never[]) => void> = {
(...args: Parameters<T>): void;
cancel(): void;
};
export function cancellableDebounce<
T extends (...args: never[]) => void,
>(
callback: T,
waitMs: number,
): Debounced<T> {
let timerId: ReturnType<typeof setTimeout> | undefined;
const debounced = (...args: Parameters<T>) => {
if (timerId !== undefined) clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = undefined;
callback(...args);
}, waitMs);
};
debounced.cancel = () => {
if (timerId !== undefined) clearTimeout(timerId);
timerId = undefined;
};
return debounced;
}
flush()는 대기 중인 마지막 작업을 즉시 실행하는 기능이다. 자동 저장 화면을 닫기 직전에 마지막 편집을 저장해야 한다면 유용할 수 있다. 다만 unload 시점 네트워크는 별도 제약이 있으므로 flush 하나로 전송 완료를 보장한다고 생각하면 안 된다.
예약 작업의 수명 주기를 먼저 정의한다.
- 입력 컴포넌트 unmount: 취소할 것인가?
- 폼 submit: 마지막 validation을 즉시 실행할 것인가?
- 검색어가 빈 문자열이 됨: 예약 요청과 진행 요청을 모두 취소할 것인가?
- 페이지 숨김: telemetry를 flush할 것인가?
검색 요청은 타이머와 네트워크를 함께 취소한다
Debounce는 시작 전 호출을 줄인다. 이미 시작된 fetch는 자동으로 취소하지 않는다.
다음 상황을 보자.
"rea" 요청 시작 ───────────────────── 응답 A
"react" 요청 시작 ───── 응답 B
나중 요청 B가 먼저 도착한 뒤 오래된 A가 도착하면 화면이 이전 검색어 결과로 돌아갈 수 있다. 요청 수를 줄였어도 응답 순서 역전 문제는 남는다.
React에서는 Effect의 cleanup과 AbortController를 함께 사용할 수 있다.
"use client";
import { useEffect, useState } from "react";
type SearchResult = {
id: string;
title: string;
};
export function SearchBox() {
const [keyword, setKeyword] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [pending, setPending] = useState(false);
useEffect(() => {
const normalized = keyword.trim();
if (normalized.length < 2) {
setResults([]);
setPending(false);
return;
}
const controller = new AbortController();
const timerId = window.setTimeout(async () => {
setPending(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(normalized)}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const data = (await response.json()) as SearchResult[];
setResults(data);
} catch (error) {
if (
error instanceof DOMException &&
error.name === "AbortError"
) {
return;
}
reportSearchError(error);
} finally {
if (!controller.signal.aborted) {
setPending(false);
}
}
}, 300);
return () => {
window.clearTimeout(timerId);
controller.abort();
};
}, [keyword]);
return (
<section>
<label htmlFor="keyword">검색</label>
<input
id="keyword"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
{pending && <p role="status">검색 중</p>}
<SearchResults items={results} />
</section>
);
}
cleanup은 다음 keyword Effect가 시작되기 전과 unmount 때 호출된다.
- 타이머가 아직 대기 중이면
clearTimeout이 요청 시작을 막는다. - 요청이 진행 중이면
abort()가 fetch와 response body 소비를 중단한다. - abort 오류는 사용자에게 실패로 표시하지 않는다.
취소가 서버 작업 완료를 반드시 막는 것은 아니다. 요청이 서버에 도착한 뒤라면 서버가 계속 처리할 수 있다. 검색처럼 읽기 작업에는 주로 클라이언트 자원과 오래된 결과 반영을 막는 의미가 있다. 쓰기 작업에는 멱등성과 서버 측 취소 정책이 별도로 필요하다.
React에서 함수 인스턴스를 안정적으로 유지하기
렌더마다 debounce 함수를 새로 만들면 각 인스턴스가 별도 타이머를 가진다.
function SearchBox() {
const search = debounce(fetchResults, 300);
return (
<input
onChange={(event) => search(event.target.value)}
/>
);
}
state 업데이트로 재렌더될 때 이전 인스턴스의 타이머를 새 인스턴스가 취소할 수 없다. 결과적으로 debounce가 풀린 것처럼 여러 callback이 실행될 수 있다.
선택지는 크게 두 가지다.
값의 변화를 Effect에서 지연한다
앞 절의 검색 예시처럼 원본 input state는 즉시 업데이트하고 Effect 안에서 타이머를 관리한다. 의존성과 cleanup 관계가 명시적이다.
debounce 인스턴스를 안정적으로 보관한다
외부 이벤트 API에 callback을 전달해야 한다면 useMemo나 useRef로 수명을 유지하고 unmount 때 취소한다.
function ResizeReporter({
report,
}: {
report: (width: number) => void;
}) {
const debouncedReport = useMemo(
() => cancellableDebounce(report, 250),
[report],
);
useEffect(() => {
const onResize = () => {
debouncedReport(window.innerWidth);
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
debouncedReport.cancel();
};
}, [debouncedReport]);
return null;
}
부모가 report 함수를 매번 새로 만들면 debounce 인스턴스도 바뀐다. callback 안정성을 부모 계약으로 둘지, 최신 callback을 ref로 읽을지 결정해야 한다.
함수 identity를 고정하면서 오래된 props를 캡처하는 stale closure가 생길 수 있다. 안정적인 wrapper가 실행 시점에 어떤 값을 읽어야 하는지 테스트한다.
이 주제는 useEffect 의존성 배열을 거짓말하면 생기는 문제와 연결된다.
스크롤과 resize에는 requestAnimationFrame도 고려한다
화면의 스타일이나 위치를 갱신하는 작업은 고정된 16ms throttle보다 requestAnimationFrame이 브라우저 paint 주기에 맞는다.
let frameId: number | null = null;
let latestScrollY = 0;
function onScroll() {
latestScrollY = window.scrollY;
if (frameId !== null) return;
frameId = window.requestAnimationFrame(() => {
frameId = null;
renderHeader(latestScrollY);
});
}
window.addEventListener("scroll", onScroll, {
passive: true,
});
이 패턴은 한 frame 안에서 여러 scroll 이벤트가 발생해도 최신 값으로 한 번만 렌더한다.
sequenceDiagram
participant E as Scroll events
participant Q as rAF queue
participant P as Browser paint
E->>Q: 최신 scrollY 기록
E->>Q: 이미 예약됨, 추가 예약 안 함
Q->>P: paint 직전 한 번 갱신requestAnimationFrame은 대부분의 브라우저에서 background tab일 때 일시 중지된다. 화면 그리기에는 적절하지만 서버에 일정 주기로 telemetry를 보내는 용도로는 의미가 다르다.
용도별 선택은 다음과 같다.
| 작업 | 우선 고려 |
|---|---|
| paint와 동기화된 DOM 갱신 | requestAnimationFrame |
| 일정 간격으로 분석 이벤트 샘플 | time-based throttle |
| 입력이 멈춘 뒤 검색 | debounce |
| 요소가 viewport에 들어왔는지 관측 | IntersectionObserver |
| 요소 크기 변화 관측 | ResizeObserver |
이벤트를 줄이기 전에 더 직접적인 브라우저 API가 있는지도 확인한다.
Debounce와 React의 지연 기능을 구분하기
useDeferredValue와 startTransition은 debounce처럼 보일 수 있지만 같은 도구가 아니다.
Debounce는 일정 시간 동안 callback 자체를 실행하지 않는다. API 요청 수를 실제로 줄일 수 있다.
React의 transition과 deferred value는 urgent update와 덜 중요한 render update의 우선순위를 구분한다. 고정된 300ms를 기다리는 것이 아니며 중간 render가 중단될 수 있어도 데이터 요청이 자동으로 debounce되는 것은 아니다.
const [keyword, setKeyword] = useState("");
const deferredKeyword = useDeferredValue(keyword);
return (
<>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<ExpensiveLocalList keyword={deferredKeyword} />
</>
);
이 예시는 입력 state를 즉시 반영하면서 비싼 로컬 목록 렌더를 뒤로 미룬다. 네트워크 요청 수 제한이 목적이라면 별도의 debounce 또는 서버 상태 도구의 정책이 필요하다.
| 목적 | 적합한 도구 |
|---|---|
| callback 호출 횟수 제한 | debounce / throttle |
| 입력은 즉시, 비싼 render는 낮은 우선순위 | deferred value / transition |
| 이미 시작한 fetch 중단 | AbortController |
| paint당 DOM 갱신 한 번 | requestAnimationFrame |
하나의 검색 UI에서 debounce와 deferred value를 함께 쓸 수도 있지만 각각 해결하는 비용을 구분해 측정해야 한다.
자주 발생하는 실패 패턴
wait 숫자를 근거 없이 복사한다
300ms가 모든 검색창에 적합한 것은 아니다. 로컬 필터인지 원격 검색인지, 평균 응답 시간이 얼마인지, 한국어 IME 조합을 어떻게 처리하는지에 따라 다르다.
마지막 이벤트가 사라진다
leading-only throttle은 사용자가 멈춘 최종 위치를 놓칠 수 있다. 저장이나 정확한 진행률에는 trailing 보장이 필요한지 확인한다.
unmount 뒤 예약 callback이 실행된다
타이머를 cleanup하지 않으면 사라진 컴포넌트의 작업이 실행된다. 개발 Strict Mode의 setup→cleanup→setup 흐름에서도 대칭적으로 동작해야 한다.
debounce만 하고 오래된 fetch는 취소하지 않는다
연속 요청 수는 줄지만 느린 이전 응답이 최신 결과를 덮을 수 있다. abort 또는 request sequence 검사가 필요하다.
let latestRequestId = 0;
async function search(keyword: string) {
const requestId = ++latestRequestId;
const result = await fetchResults(keyword);
if (requestId !== latestRequestId) return;
renderResults(result);
}
취소를 지원하지 않는 비동기 작업에는 최신 요청 ID 검사가 대안이 될 수 있다.
입력 state 자체를 늦춘다
controlled input의 value 업데이트까지 debounce하면 타이핑이 끊겨 보인다. 화면 입력 state는 즉시 반영하고 비싼 파생 작업만 지연한다.
rate limit과 혼동한다
클라이언트 throttle은 사용자가 코드를 우회할 수 있다. 서버 보호를 위한 rate limit은 서버나 gateway에서 별도로 적용한다.
측정과 선택 체크리스트
측정할 때는 callback 수만 세지 않는다.
- 이벤트 발생 수와 실제 처리 수
- 첫 입력부터 결과 표시까지의 시간
- 취소된 요청과 완료된 요청 수
- main thread 작업 시간
- 마지막 상태의 정확성
- 오류와 abort를 구분한 관측
호출 횟수가 절반으로 줄어도 사용자가 결과를 500ms 더 기다리게 됐다면 좋은 변경인지 제품 기준으로 판단해야 한다.
마무리
Debounce와 Throttle은 모두 빈번한 이벤트를 제어하지만 보존하는 의미가 다르다. Debounce는 조용한 구간을 기다려 마지막 의도를 처리한다. Throttle은 흐름을 유지하면서 일정 빈도로 중간 상태를 샘플링한다.
실무에서는 leading과 trailing, maxWait, cancel이 선택의 일부다. React에서는 렌더마다 새 wrapper를 만들지 않고 Effect cleanup으로 예약 작업을 정리해야 한다. 네트워크 요청이 시작됐다면 타이머 취소만으로는 부족하므로 AbortController나 최신 요청 검사를 사용한다. 화면 갱신이라면 시간 기반 throttle보다 requestAnimationFrame이 더 자연스러울 수 있다.
어떤 기법을 쓸지는 이벤트 이름이 아니라 버려도 되는 정보와 사용자가 기다릴 수 있는 시간을 기준으로 정한다. 호출 횟수 감소, 최종 상태의 정확성, 체감 지연을 함께 측정해야 실제 최적화가 된다.
관련 노트
- AbortController로 오래 걸리는 비동기 작업 취소하기
- 40장 이벤트
- React 렌더링과 재렌더링의 차이
- useEffect 의존성 배열을 거짓말하면 생기는 문제
- React Query에서 서버 상태를 따로 관리하는 이유
- 라우트 기반 코드 스플리팅의 효과