Debounce와 Throttle.
masonfe-hyunsu
🧵 1. 개요.

프론트에서 이벤트는 생각보다 자주 온다. 스크롤, 입력, 리사이즈만 해도 한 동작에 핸들러가 수십 번 붙을 수 있다.
그럴 때마다 바로 일을 시키면 UI도 버벅이고, 검색처럼 API까지 물리면 서버만 바빠진다. 그래서 호출 빈도를 의도적으로 줄이는 패턴이 필요하다.
이 포스팅에서는 Debounce(입력이 멈춘 뒤에야 한 번 실행)랑 Throttle(일정 간격마다 최대 한 번 실행)을 구분하고, 어디에 쓰면 좋은지 짧게 정리해 본다.
개념만 짧게 보고 싶으면 Debounce와 Throttle (Docs)도 같이 보면 된다.
⚖️ 2. 둘의 차이.
둘 다 “너무 자주 오는 호출을 줄인다”는 목적은 같다. 줄이는 방식이 다르다.
Debounce: 연속 호출이 이어지는 동안은 계속 미룬다. 마지막 호출 뒤 delay가 지나야 실행한다.
Throttle: 연속 호출 중에도 delay마다 한 번씩은 실행한다.

검색은 타이핑이 끝난 뒤에 한 번이면 충분하다. 스크롤 중 헤더 투명도는 “가끔씩”이라도 따라가야 한다.
입력이 멈춘 뒤 한 번이 Debounce, 계속 오는 동안 간격마다 최대 한 번이 Throttle이다.
leading / trailing 옵션이 붙으면 첫 호출을 바로 쏠지, 마지막만 남길지가 갈린다. UX에 맞게 고르면 되고, 기본은 “검색은 trailing, 클릭 연타는 leading” 정도로 생각하면 편하다.
🛠️ 3. 어떨때 쓰면 좋을까?

검색, 태그 필터 입력. → Debounce
Posts 쪽 태그 검색처럼, 입력할 때마다 목록을 거르거나 API를 치면 타이핑 중간값이 다 나간다.
300~500ms Debounce를 걸면 "re"를 치는 동안은 조용히 두고, 손을 뗀 뒤 한 번만 돌린다. 자동완성, 주소 검색, admin 필터도 같은 패턴이다.
스크롤에 묶인 UI. → Throttle
이 블로그 Header, Profile는 scroll로 진행도를 읽는다. 지금은 매 스크롤마다 바로 읽는데, setState가 잦아지면 렌더가 과해진다.
100~200ms Throttle이면 스크롤 중에도 따라가되, 프레임을 다 잡아먹지는 않는다. passive: true도 같이 두는 편이 좋다. 핸들러에서 preventDefault를 안 쓰겠다는 신호라, 브라우저 기본 스크롤을 방해하지 않는다.
상세 글 목차(현재 섹션 하이라이트)도 비슷하다. 스크롤 위치를 보고 활성 heading을 바꾸는 일은 중간 업데이트가 필요해서 Debounce는 잘 안 맞는다. 이 블로그 목차는 requestAnimationFrame으로 프레임당 한 번만 돌리게 묶어 뒀다. timer Throttle도 같은 계열이다.
리사이즈, 윈도우 폭 계산. → Throttle (또는 Debounce)
resize도 연속으로 온다. 레이아웃을 실시간으로 맞출 거면 Throttle, “손 놓고 난 뒤 한 번만 재계산”이면 Debounce다.
차트 리사이즈, 사이드바 접힘 기준폭 같은 게 여기 해당한다.
버튼, 저장 연타. → Debounce / 짧은 Throttle
저장, 좋아요, 댓글 전송처럼 같은 요청이 연속으로 나가면 답이 꼬인다.
첫 클릭만 받고 나머지는 막는 leading Throttle, 또는 제출 후 잠깐 비활성도 같은 계열이다. “입력이 끝난 뒤”가 아니라 “연속 트리거를 한 번으로 접는다”에 가깝다.
스크롤 로딩, 무한 스크롤. → Throttle + 가드
하단 근처인지 매번 계산할 때도 Throttle이 기본이다. 다만 이미 fetch 중이면 플래그로 한 번 더 막는 게 안전하다. 호출 빈도만 줄이고, 중복 요청까지는 별도로 막는다.
📐 4. 고를 때 기준.
정리하면 이렇다.
최종 결과만 필요하면 Debounce (검색, 저장 확정, resize 후 재측정).
중간 상태도 보여야 하면 Throttle (스크롤 진행, 드래그 좌표, 진행률).
애니메이션 프레임에 맞출 거면
requestAnimationFrame도 후보. 스크롤에 묶인 페인트는 rAF가 Throttle 대용으로 자주 쓰인다.요소가 보이는지만 알면 되면 스크롤 핸들러 대신 IntersectionObserver(뷰포트에 들어왔는지 알려 주는 API)를 먼저 본다. 이 블로그 카드 fade-in이 그 쪽이다.
최적화 방향은 “함수를 싸맨다”만으로 끝나지 않는다.
리스너는
passive가 가능한지 본다. (preventDefault로 스크롤을 막지 않겠다고 브라우저에 알려 주는 옵션이다).React면 debounced/throttled 함수를 매 렌더마다 새로 만들지 않는다. lodash를 쓰면 cleanup에서
cancel()도 챙긴다.서버로 나가는 호출이면 클라이언트 제한 + 요청 취소(
AbortController)를 같이 둔다. 타이머만 줄여도 in-flight 요청은 남을 수 있다.
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, delay) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= delay) {
last = now;
fn(...args);
}
};
}구현은 Docs에 둔 최소형이다. 위 throttle은 간격 안에서 앞쪽 호출만 통과시키고, 마지막 호출을 꼭 보장하진 않는다. trailing이 필요하면 구현을 늘리거나 lodash를 쓰면 된다. lodash면 unmount 때 cancel()도 챙긴다. 핵심은 어느 쪽에 걸지를 먼저 정하는 일이다.
🧭 5. 정리.
Debounce는 입력이 멈춘 뒤 한 번, Throttle은 계속 오는 동안 간격마다 최대 한 번.
검색 입력은 Debounce, 스크롤에 묶인 UI나 목차 갱신은 Throttle/rAF 쪽이 자연스럽다. 스크롤마다 무거운 일을 넣고 있다면, 일단 빈도를 줄이고, 보이기만 하면 되는 일은 Observer로 빼는 순서가 부담이 적었다.
댓글