스크롤할 때마다 이벤트 핸들러가 초당 수십 번 실행된다면, 브라우저는 어떻게 될까요?

scroll, resize, input 같은 이벤트는 사용자가 조작하는 동안 끊임없이 발생합니다. 이 이벤트마다 무거운 작업(API 호출, DOM 계산 등)을 실행하면 브라우저가 버벅이거나 불필요한 네트워크 요청이 쏟아집니다. 디바운스와 스로틀은 이 "이벤트 폭주"를 제어하는 두 가지 전략입니다.

이벤트 폭주 문제

브라우저에서 이벤트가 얼마나 자주 발생하는지 직접 확인해 보겠습니다.

JS
// 스크롤 이벤트 발생 횟수 확인
let count = 0;
window.addEventListener('scroll', () => {
  count++;
  console.log(`scroll 이벤트 발생: ${count}번`);
});

마우스 휠을 한 번 굴리기만 해도 10~30번의 이벤트가 발생합니다. resize도 마찬가지고, input 이벤트는 키를 누를 때마다 발생합니다.

이벤트마다 아래 같은 작업을 실행한다고 생각해 보면 문제가 명확해집니다.

  • **검색 자동완성 **: 글자 하나 입력할 때마다 API 호출
  • ** 스크롤 위치 계산 **: 매 스크롤마다 DOM 레이아웃 재계산
  • ** 윈도우 리사이즈 **: 매 픽셀 변화마다 차트 다시 그리기

디바운스(Debounce)

디바운스는 ** 연속된 이벤트가 끝난 후, 마지막 이벤트로부터 일정 시간이 지나면 한 번만 실행 **하는 기법입니다.

타이핑을 예로 들면, 사용자가 타이핑을 멈추고 300ms가 지나야 비로소 콜백이 실행됩니다. 타이핑 중에는 타이머가 계속 리셋되니까요.

PLAINTEXT
이벤트:  ──X──X──X──X──────────────────X──X──────────
                          ↓ 300ms 대기 후               ↓ 300ms 대기 후
실행:    ─────────────────✓────────────────────────────✓──

검색 자동완성 예제

디바운스가 가장 빛나는 사례입니다.

JS
const searchInput = document.getElementById('search');

searchInput.addEventListener('input', (e) => {
  // 글자 하나 입력할 때마다 API 호출 → 비효율적
  fetchSuggestions(e.target.value);
});

"자바스크립트"를 검색하려고 6글자를 입력하면 API가 6번 호출됩니다. 디바운스를 적용하면 타이핑이 끝난 후 한 번만 호출됩니다.

디바운스 직접 구현

핵심은 clearTimeout + setTimeout 패턴입니다.

JS
function debounce(callback, delay) {
  let timerId = null; // 타이머 ID를 클로저로 기억

  return function (...args) {
    // 이전 타이머가 있으면 취소 (리셋)
    clearTimeout(timerId);

    // 새 타이머 설정
    timerId = setTimeout(() => {
      callback.apply(this, args); // this와 인자를 원본 그대로 전달
    }, delay);
  };
}

사용법은 간단합니다.

JS
const searchInput = document.getElementById('search');

// 300ms 동안 입력이 없으면 실행
const handleSearch = debounce((e) => {
  fetchSuggestions(e.target.value);
}, 300);

searchInput.addEventListener('input', handleSearch);

동작 순서를 정리하면 이렇습니다.

  1. 사용자가 "자"를 입력 → 300ms 타이머 시작
  2. 100ms 후 "바"를 입력 → 이전 타이머 취소, 새 300ms 타이머 시작
  3. 200ms 후 "스"를 입력 → 또 리셋
  4. ...타이핑 멈춤... 300ms 경과 → fetchSuggestions("자바스크립트") 한 번 실행

스로틀(Throttle)

스로틀은 ** 일정 시간 간격으로 최대 한 번만 실행 **을 보장하는 기법입니다.

디바운스와 다른 점은, 이벤트가 계속 발생하더라도 ** 주기적으로 실행 **된다는 것입니다. 디바운스는 이벤트가 멈춰야 실행되지만, 스로틀은 이벤트 중간에도 일정 간격으로 실행됩니다.

PLAINTEXT
이벤트:  ──X──X──X──X──X──X──X──X──X──X──
              ↓         ↓         ↓
실행:    ────✓─────────✓─────────✓────────
         200ms 간격    200ms 간격

무한 스크롤 예제

스크롤 위치를 감지해서 추가 데이터를 로드하는 무한 스크롤에서는 스로틀이 적합합니다.

JS
window.addEventListener('scroll', () => {
  // 스크롤할 때마다 실행 → 초당 수십 번 호출
  checkScrollPosition();
});

스크롤 중에도 주기적으로 위치를 확인해야 하니까, 이벤트가 끝날 때까지 기다리는 디바운스보다는 일정 간격으로 실행하는 스로틀이 맞습니다.

스로틀 직접 구현 — timestamp 방식

마지막 실행 시각을 기록하고, 경과 시간을 비교하는 방식입니다.

JS
function throttle(callback, limit) {
  let lastCall = 0; // 마지막 실행 시각

  return function (...args) {
    const now = Date.now();

    // 마지막 실행으로부터 limit ms가 지났으면 실행
    if (now - lastCall >= limit) {
      lastCall = now;
      callback.apply(this, args);
    }
    // 아직 안 지났으면 무시
  };
}
JS
// 200ms 간격으로 스크롤 위치 체크
const handleScroll = throttle(() => {
  const scrollY = window.scrollY;
  const docHeight = document.documentElement.scrollHeight;
  const winHeight = window.innerHeight;

  // 하단 100px 이내에 도달하면 추가 로드
  if (scrollY + winHeight >= docHeight - 100) {
    loadMoreItems();
  }
}, 200);

window.addEventListener('scroll', handleScroll);

스로틀 직접 구현 — setTimeout 방식

타이머를 사용하는 방식도 있습니다. 이 방식은 마지막 이벤트도 놓치지 않는 장점이 있습니다.

JS
function throttle(callback, limit) {
  let waiting = false; // 대기 중인지 여부

  return function (...args) {
    if (!waiting) {
      callback.apply(this, args); // 즉시 실행
      waiting = true;

      setTimeout(() => {
        waiting = false; // limit 후 다시 실행 가능
      }, limit);
    }
  };
}

두 방식의 차이를 정리하면 이렇습니다.

timestamp 방식setTimeout 방식
첫 호출즉시 실행즉시 실행
마지막 이벤트놓칠 수 있음놓칠 수 있음
정확도Date.now() 기반타이머 기반
구현 복잡도단순단순

두 방식 모두 기본 형태에서는 trailing 호출을 보장하지 않습니다. lodash 같은 라이브러리는 leading + trailing을 모두 처리하는 정교한 구현을 제공합니다.

디바운스 vs 스로틀 비교

디바운스(Debounce)스로틀(Throttle)
** 실행 시점**이벤트가 멈춘 후일정 간격마다
** 동작 방식**타이머 리셋실행 후 잠금
** 실행 보장**마지막 1회간격당 최대 1회
** 검색 자동완성**적합부적합
** 무한 스크롤**부적합적합
** 윈도우 리사이즈**적합 (최종 크기만 필요)적합 (중간 피드백 필요)
** 버튼 중복 클릭 방지**적합 (leading)적합

공부하다 보니 핵심 기준은 이것이었습니다.

  • "결과만 중요하다" → 디바운스 (타이핑 끝난 후 검색)
  • "중간 과정도 중요하다" → 스로틀 (스크롤 중에도 위치 확인)

Leading과 Trailing 옵션

기본 디바운스는 trailing(마지막에 실행)이고, 기본 스로틀은 leading(처음에 실행)입니다. 하지만 상황에 따라 반대로 설정해야 할 때가 있습니다.

Leading 디바운스

첫 번째 호출을 즉시 실행하고, 이후 연속 호출은 무시합니다. 버튼 중복 클릭 방지에 유용합니다.

JS
function debounce(callback, delay, options = {}) {
  let timerId = null;
  const leading = options.leading || false;

  return function (...args) {
    const isFirstCall = timerId === null;

    clearTimeout(timerId);

    // leading: 첫 번째 호출이면 즉시 실행
    if (leading && isFirstCall) {
      callback.apply(this, args);
    }

    timerId = setTimeout(() => {
      // trailing: leading이 아닐 때만 마지막에 실행
      if (!leading) {
        callback.apply(this, args);
      }
      timerId = null; // 리셋 — 다음 호출이 다시 "첫 번째"가 됨
    }, delay);
  };
}
JS
// 결제 버튼 — 첫 클릭만 실행, 연타 무시
const handlePayment = debounce(
  () => submitPayment(),
  1000,
  { leading: true }
);

payButton.addEventListener('click', handlePayment);

Trailing 스로틀

기본 스로틀에 trailing 실행을 추가하면, 마지막 이벤트를 놓치지 않습니다.

JS
function throttle(callback, limit, options = {}) {
  let lastCall = 0;
  let trailingTimer = null;
  const trailing = options.trailing !== false; // 기본 true

  return function (...args) {
    const now = Date.now();
    const remaining = limit - (now - lastCall);

    // 간격이 지났으면 즉시 실행 (leading)
    if (remaining <= 0) {
      clearTimeout(trailingTimer);
      lastCall = now;
      callback.apply(this, args);
    } else if (trailing && !trailingTimer) {
      // 간격 내에서 마지막 호출을 예약 (trailing)
      trailingTimer = setTimeout(() => {
        lastCall = Date.now();
        trailingTimer = null;
        callback.apply(this, args);
      }, remaining);
    }
  };
}

requestAnimationFrame을 이용한 스로틀

스크롤이나 리사이즈에서 UI를 업데이트한다면, setTimeout 대신 requestAnimationFrame(rAF)을 사용하는 것이 더 자연스럽습니다.

rAF는 브라우저의 ** 화면 갱신 주기 **(보통 60fps = 약 16.7ms 간격)에 맞춰 콜백을 실행합니다. 시간 간격을 직접 지정하는 것보다 렌더링과 동기화되어 더 부드럽습니다.

JS
function throttleByRAF(callback) {
  let ticking = false; // 프레임 요청 중인지 여부

  return function (...args) {
    if (!ticking) {
      ticking = true;

      requestAnimationFrame(() => {
        callback.apply(this, args);
        ticking = false; // 다음 프레임 요청 가능
      });
    }
  };
}
JS
// 스크롤 시 요소 위치를 부드럽게 업데이트
const handleScroll = throttleByRAF(() => {
  const scrollY = window.scrollY;
  // 패럴랙스 효과나 스크롤 기반 애니메이션
  parallaxElement.style.transform = `translateY(${scrollY * 0.5}px)`;
});

window.addEventListener('scroll', handleScroll);

rAF 스로틀은 간격을 직접 지정할 수 없다는 제약이 있습니다. "200ms마다"처럼 구체적인 간격이 필요하면 timestamp 방식의 스로틀을, 화면 갱신과 동기화가 중요하면 rAF를 사용하면 됩니다.

lodash와 React에서의 사용

직접 구현해 보면 원리를 이해하는 데 도움이 되지만, 실무에서는 검증된 라이브러리를 쓰는 것이 안전합니다.

lodash

lodash의 debouncethrottle은 leading, trailing, maxWait 등 다양한 옵션을 지원합니다.

JS
import { debounce, throttle } from 'lodash';

// 디바운스 — 300ms 후 실행
const handleSearch = debounce((query) => {
  fetchSuggestions(query);
}, 300);

// 스로틀 — 200ms 간격으로 실행
const handleScroll = throttle(() => {
  checkScrollPosition();
}, 200);

// 컴포넌트 언마운트 시 정리
handleSearch.cancel();
handleScroll.cancel();

lodash 전체를 import하면 번들 크기가 커집니다. lodash/debounce처럼 개별 함수만 가져오거나, lodash-es를 사용해서 트리셰이킹을 활용하는 것이 좋습니다.

React에서 사용할 때 주의점

React 컴포넌트에서 디바운스/스로틀을 사용할 때는 리렌더링마다 새 함수가 생성되지 않도록 주의해야 합니다.

JSX
import { useMemo, useEffect } from 'react';
import { debounce } from 'lodash';

function SearchInput() {
  // useMemo로 디바운스 함수가 리렌더링마다 재생성되지 않도록 방지
  const handleSearch = useMemo(
    () => debounce((query) => {
      fetchSuggestions(query);
    }, 300),
    [] // 의존성 없음 — 한 번만 생성
  );

  // 컴포넌트 언마운트 시 타이머 정리
  useEffect(() => {
    return () => handleSearch.cancel();
  }, [handleSearch]);

  return (
    <input
      type="text"
      onChange={(e) => handleSearch(e.target.value)}
      placeholder="검색어를 입력하세요"
    />
  );
}

useCallback으로 감싸는 것도 가능하지만, debounce 자체가 새 함수를 반환하므로 useMemo가 의도가 더 명확합니다.

정리

  • ** 디바운스 **: 이벤트가 멈춘 후 한 번 실행. 검색 자동완성, 리사이즈 완료 감지에 적합
  • ** 스로틀 **: 일정 간격으로 한 번 실행. 무한 스크롤, 드래그 위치 추적에 적합
  • **rAF 스로틀 **: 브라우저 렌더링 주기에 맞춘 스로틀. 애니메이션, 패럴랙스에 적합
  • 직접 구현의 핵심은 clearTimeout + setTimeout(디바운스), Date.now() 비교 또는 플래그(스로틀)
  • 실무에서는 lodash나 프레임워크의 유틸을 사용하되, 원리를 알아야 옵션을 올바르게 선택할 수 있습니다
댓글 로딩 중...