검색창에 한 글자 칠 때마다 API 호출, scroll 한 번에 handler 가 초당 수백 번 실행 — UI 가 버벅인다. 문제는 event 자체가 아니라빈도. scroll · resize · mousemove · input 은 초당 수십~수백 번 발생하고, 그때마다 비싼 handler 를 돌리면 main thread 가 막힌다. 해법은 호출 횟수를 줄이는 것 — debounce 와 throttle. 이 가이드는 둘의 차이, 정확한 vanilla JS 구현, React 에서의 함정, 그리고 INP 까지 정리한다.
문제 — event 는 폭주한다
# 검색 input, 사용자가 "hello" 타이핑
h → API 호출
he → API 호출
hel → API 호출
hell → API 호출
hello→ API 호출
= 5 번 호출. 마지막 1 번만 의미 있음
# scroll 한 번 (마우스 휠 1 틱)
scroll event 가 16ms 마다 ~ 초당 60+ 번 발생
handler 에 layout 측정 + setState → main thread 점유 → jank핵심 직관 — debounce 는 "조용해질 때까지 기다린다",throttle 는 "일정 간격마다 한 번만 통과시킨다".
Debounce — 폭주를 하나로 합친다
연속된 호출을 묶어, 마지막 호출 후 N ms 동안 추가 호출이 없을 때 딱 한 번 실행. "타이핑 멈추면 검색" 이 전형:
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const search = debounce((q) => {
fetch("/api/search?q=" + encodeURIComponent(q));
}, 300);
input.addEventListener("input", (e) => search(e.target.value));매 호출이 이전 timer 를 clearTimeout 으로 취소하고 새 timer 를 건다. 호출이 300ms 이상 멎어야 비로소 fn 이 실행된다. "hello" 를 빠르게 치면 API 는 1 번.
Leading edge vs trailing edge
burst: | | | | | (5 번 호출, 간격 < delay)
──────────────────▶ 시간
trailing (기본): 마지막 호출 + delay 후 1 번 실행
|...wait...| → fire (끝)
leading: 첫 호출 즉시 1 번 실행, 이후 burst 무시
fire → | | | | (무시)
both: 첫 호출 즉시 + 마지막 후에도 한 번trailing 이 기본 — "다 끝나면 한 번". 검색·autosave 에 적합. leading 은 "첫 클릭은 즉시 반응" 이 필요할 때 (예: 중복 제출 방지 버튼). 옵션을 받는 구현:
function debounce(fn, delay, opts = {}) {
const {leading = false, trailing = true} = opts;
let timer = null;
let calledLeading = false;
function debounced(...args) {
if (leading && !timer) {
fn.apply(this, args);
calledLeading = true;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (trailing && !(leading && calledLeading)) {
fn.apply(this, args);
}
timer = null;
calledLeading = false;
}, delay);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
calledLeading = false;
};
return debounced;
}cancel() 은 대기 중인 호출을 폐기한다. unmount 시 cleanup 에 필수 (아래 함정 참조). flush() 를 더해 "대기 중인 호출을 지금 즉시 실행" 도 자주 쓰인다.
Debounce 가 맞는 곳
- 검색 자동완성 — 타이핑 멈추면 query
- autosave — 편집이 멎으면 저장
- validate-on-stop — 입력 끝나면 검증
- resize 종료 처리 — 창 크기 조절이 끝나면 layout 재계산
Throttle — 일정 간격마다 한 번
burst 가 이어지는 동안 N ms 에 최대 한 번만 실행. debounce 와 달리중간에도 주기적으로 실행된다. scroll 위치 추적처럼 "끝까지 기다리면 안 되는" 경우에 쓴다. timestamp 기반 구현:
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
window.addEventListener(
"scroll",
throttle(() => {
updateScrollProgressBar(window.scrollY);
}, 100)
);이 단순판은 leading-only — 첫 호출 즉시 실행하고 interval 안의 나머지는 버린다. 단점 — burst 의 마지막 호출이 interval 경계 직전이면 누락된다. 마지막 값까지 보장하려면 trailing timer 를 더한다:
function throttle(fn, interval) {
let last = 0;
let timer = null;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) {
// leading edge — 즉시 실행
if (timer) {
clearTimeout(timer);
timer = null;
}
last = now;
fn.apply(this, args);
} else if (!timer) {
// trailing edge — interval 경계에서 마지막 값 실행
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn.apply(this, args);
}, remaining);
}
};
}이제 leading + trailing 둘 다. burst 가 멈춰도 마지막 한 번이 보장. 이것이 lodash throttle 의 기본 동작이다.
Throttle 이 맞는 곳
- scroll 위치 추적 — progress bar, sticky header
- infinite scroll trigger — 바닥 근처 도달 감지
- drag / mousemove — 끌기 중 좌표 갱신
- analytics — 고빈도 event 의 샘플링 전송
requestAnimationFrame throttle — frame 에 맞춘다
scroll · drag 처럼 화면을 다시 그리는 작업은 시간 간격(100ms) 보다 frame 에 맞추는 게 낫다. 브라우저는 ~16.7ms (60fps) 마다 paint 하므로, 그보다 자주 갱신해도 화면엔 안 보인다. requestAnimationFrame 으로 frame 당 한 번만:
function rafThrottle(fn) {
let scheduled = false;
let lastArgs = null;
return function (...args) {
lastArgs = args;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
fn.apply(this, lastArgs);
});
};
}
window.addEventListener(
"scroll",
rafThrottle(() => {
header.style.transform = "translateY(" + (-window.scrollY * 0.3) + "px)";
})
);장점 — paint 주기와 자동 정렬, 백그라운드 탭에서는 rAF 가 멈춰 불필요한 작업도 안 한다. 시각 효과엔 rAF, 비시각 로직(API 호출 등)엔 시간 기반 throttle 이 원칙.
Debounce vs throttle — 결정 규칙
한 줄 규칙 — "마지막 값만 중요하면 debounce, 진행 중에도 주기적으로 반응해야 하면 throttle".
debounce throttle
──────────────────────────────────────────────────────
실행 시점 조용해진 후 1 번 N ms 마다 1 번
burst 중 실행 안 함 주기적으로 실행
대표 비유 "타이핑 멈추면 검색" "초당 최대 N 번"
──────────────────────────────────────────────────────
검색 input ✓ △ (덜 적합)
autosave ✓ △
scroll 추적 ✗ (끝까지 안 옴) ✓
drag 좌표 ✗ ✓
resize 종료 ✓ △
infinite scroll △ ✓둘을 헷갈리면 결과가 어긋난다. scroll progress 에 debounce 를 쓰면 스크롤 도중엔 bar 가 안 움직이고 멈춰야 튄다. 검색에 throttle 을 쓰면 타이핑 중간값으로도 호출이 나간다.
React 에서의 함정 — inline debounce 는 깨진다
// 깨진 코드 — 매 render 마다 새 debounce 함수 생성
function SearchBox() {
const [q, setQ] = useState("");
// 매 render 마다 debounce(...) 가 새로 만들어짐
// → 내부 timer 도 매번 초기화 → debounce 효과 0
const onChange = debounce((v) => fetchResults(v), 300);
return <input onChange={(e) => onChange(e.target.value)} />;
}문제 — setQ 로 state 가 바뀌면 컴포넌트가 re-render 되고, 그때마다 debounce(...) 가 새 함수 를 반환한다. 새 함수는 새 timer 변수를 가지므로 직전 호출의 대기 timer 와 무관 — 디바운스가 전혀 안 걸린다.
해법 1 — useMemo 로 debounce 함수를 한 번만 만든다:
function SearchBox() {
const [q, setQ] = useState("");
const debouncedFetch = useMemo(
() => debounce((v) => fetchResults(v), 300),
[] // 의존성 없음 — 마운트 시 1 번만 생성
);
// unmount 시 대기 timer 정리
useEffect(() => () => debouncedFetch.cancel(), [debouncedFetch]);
return (
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
debouncedFetch(e.target.value);
}}
/>
);
}해법 2 — useRef 로 안정적인 인스턴스를 잡는다.useMemo 는 React 가 캐시를 버릴 수 있다고 명시하므로, "절대 재생성 금지" 가 필요하면 ref 가 더 안전하다:
function SearchBox() {
const fetchRef = useRef(null);
if (!fetchRef.current) {
fetchRef.current = debounce((v) => fetchResults(v), 300);
}
useEffect(() => () => fetchRef.current.cancel(), []);
// fetchRef.current 를 onChange 에서 호출
}useCallback 의 함정 — stale closure
useCallback 으로 핸들러를 메모해도, debounce 로 감싼 함수가 오래된 props/state 를 capture 하면 stale closure 버그가 난다:
// debouncedSave 가 처음 만들어질 때의 count 를 영원히 기억
const save = useMemo(
() => debounce(() => api.save(count), 500),
[] // count 가 빠짐 → 항상 첫 count(0) 저장
);고치는 법 — 최신 값을 ref 에 흘려 넣고 debounce 내부에서 ref 를 읽는다. 그러면 debounce 함수 자체는 재생성 없이 항상 최신 값을 본다:
const countRef = useRef(count);
countRef.current = count; // 매 render 최신화
const save = useMemo(
() => debounce(() => api.save(countRef.current), 500),
[]
);React 자체 대안 — useDeferredValue / useTransition
입력에 따라 무거운 렌더링(큰 리스트 필터링 등) 이 느려지는 경우엔 timer 기반 debounce 대신 React 18+ 의 동시성 기능이 더 자연스럽다:
function Filter() {
const [query, setQuery] = useState("");
// query 는 즉시 반영, 무거운 파생값은 뒤로 미룸
const deferred = useDeferredValue(query);
const list = useMemo(() => filterHugeList(deferred), [deferred]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results data={list} />
</>
);
}useDeferredValue 는 input 은 즉각 반응시키되 파생 렌더를 우선순위 낮게 미룬다. useTransition 은 상태 업데이트를 명시적으로 "긴급하지 않음" 으로 표시한다. 둘은 고정 N ms 가 아니라 브라우저 여유에 맞춰 적응한다는 점에서 debounce 와 다르다. 단, 네트워크 호출(API) 줄이기엔 여전히 debounce 가 맞다 — 동시성 기능은 렌더 부하용이다.
흔한 함정
1. unmount 시 timer 미정리
대기 중인 timer 를 안 지우면 unmount 후 콜백이 실행돼 사라진 컴포넌트에 setState → 경고 / 메모리 누수. cleanup 에서 cancel() 또는 clearTimeout 필수:
useEffect(() => {
const handler = throttle(onScroll, 100);
window.addEventListener("scroll", handler);
return () => {
window.removeEventListener("scroll", handler);
handler.cancel && handler.cancel(); // 대기 timer 정리
};
}, []);2. stale closure
위에서 본 것처럼 debounce/throttle 함수가 오래된 변수를 가둔다. 의존성 배열을 비우면 빠른 대신 stale, 채우면 매번 재생성되어 debounce 가 깨진다. ref 패턴으로 둘 다 피한다.
3. 같은 함수 인스턴스로 등록·해제
addEventListener(throttle(fn)) 처럼 인라인으로 감싸면removeEventListener 가 다른 인스턴스를 받아 해제 실패. 반드시 변수에 담아 같은 참조로 add/remove.
4. delay 값 감각
- 검색 debounce — 250~400ms (사람의 타이핑 멈춤 인지)
- scroll throttle — 100~200ms, 시각 효과는 rAF
- 너무 길면 둔하게, 너무 짧으면 효과 미미
왜 이게 성능에 중요한가 — INP
고빈도 handler 를 그대로 두면 main thread 에 long task(50ms+ 점유) 가 쌓이고, 사용자의 클릭·입력 반응이 밀린다. 이게 바로 Core Web Vitals 의 INP (Interaction to Next Paint) 를 악화시키는 주범이다. debounce/throttle 로 handler 실행 횟수를 줄이면 long task 가 줄고 INP 가 개선된다.
한 단계 더 — handler 안에서 getBoundingClientRect 같은 layout 읽기 + DOM 쓰기를 섞으면 forced reflow 가 반복된다. throttle 로 횟수를 줄이고, 시각 갱신은 rAF 로 묶어 read / write 를 분리하는 것이 정석. event 가 어떻게 queue 에 쌓이고 처리되는지는 event loop, INP 측정은 Core Web Vitals 가이드에서 더 다룬다.
라이브러리를 쓸까, 직접 쓸까
lodash.debounce / lodash.throttle 은 leading/trailing/maxWait/cancel/flush 를 검증된 형태로 제공한다. 이미 lodash 를 쓴다면 직접 구현할 이유가 적다. 단 의존성을 더하기 싫거나 동작을 완전히 이해하고 싶다면 위 10~20 줄로 충분하다. React 라면 use-debounce 훅 같은 전용 패키지도 ref 함정을 대신 처리해 준다.
요약
- 고빈도 event(scroll/resize/mousemove/input) 를 그대로 처리하면 main thread 가 막혀 jank.
- Debounce — 조용해진 후 1 번. 검색·autosave· validate-on-stop·resize 종료.
- Throttle — N ms 마다 1 번. scroll 추적·infinite scroll·drag·analytics.
- 결정 규칙 — 마지막 값만 중요하면 debounce, 진행 중에도 주기적 반응이면 throttle.
- 시각 갱신은
requestAnimationFramethrottle 로 frame 에 정렬 (~60fps). - React — inline
debounce(fn)은 매 render 재생성되어 깨진다.useMemo/useRef로 인스턴스 고정, 최신 값은 ref 로 전달해 stale closure 회피. - 렌더 부하엔
useDeferredValue/useTransition, 네트워크 호출엔 debounce. - unmount cleanup 에서 timer
cancel()— 안 하면 누수 / setState-after-unmount. 호출 줄이기 = long task 감소 = INP 개선.