본문으로 건너뛰기
yutils

debounce 와 throttle 는 어떻게 동작할까?

scroll · resize · input 같은 고빈도 event 를 길들이는 debounce 와 throttle — leading/trailing edge, 실제 JS 코드, React 함정, INP 개선까지.

약 8분 읽기

검색창에 한 글자 칠 때마다 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.
  • 시각 갱신은 requestAnimationFrame throttle 로 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 개선.
가이드 목록으로