Frontend

[Frontend] 디바운싱과 쓰로틀링

teddy bear 2026. 7. 15. 16:57
반응형

디바운싱과 쓰로틀링, 같은 문제를 다르게 푸는 두 가지 방법

검색창에 글자를 입력할 때마다 API를 호출한다면 어떻게 될까요. scroll 이벤트가 발생할 때마다 레이아웃을 다시 계산한다면요. 브라우저는 이런 이벤트를 초당 수십, 수백 번씩 발생시킬 수 있습니다. 이 모든 이벤트에 성실하게 반응하는 코드는 사용자 경험을 해치고, 서버에는 불필요한 부하를 줍니다.

디바운싱과 쓰로틀링은 이 문제를 푸는 대표적인 두 가지 기법입니다. 둘 다 "이벤트 실행 빈도를 줄인다"는 목적은 같지만, 접근 방식이 완전히 다릅니다. 이 차이를 정확히 설명하지 못하면 실무 경험이 있어도 면접에서 애매하게 답하게 되는 경우가 많습니다.


1. 문제 상황: 왜 필요한가

먼저 이벤트가 얼마나 자주 발생하는지 감을 잡아보겠습니다.

window.addEventListener("scroll", () => {
  console.log("scroll event");
});

스크롤을 살짝만 움직여도 콘솔에는 수십 줄이 순식간에 찍힙니다. resize, mousemove, input도 마찬가지입니다. 이 이벤트마다 무거운 연산(API 호출, DOM 재계산, 상태 업데이트)을 실행하면 브라우저가 버벅이거나, 서버에 요청이 폭주합니다.

이걸 막는 두 가지 전략이 바로 디바운싱과 쓰로틀링입니다.


2. 디바운싱: "마지막 이벤트만 실행한다"

디바운싱은 이벤트가 연속으로 발생하는 동안은 실행을 계속 미루다가, 일정 시간 동안 이벤트가 더 이상 발생하지 않을 때 딱 한 번 실행하는 방식입니다.

function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

동작을 그림으로 표현하면 이렇습니다.

이벤트 발생:  ●  ●  ● ●  ●───────●
실행 시점:                        ▶ (마지막 이벤트로부터 delay 후 1회)

타이머가 계속 리셋되기 때문에, 이벤트가 멈추지 않는 한 fn은 절대 실행되지 않습니다. 오직 "이벤트가 잠잠해진 순간"에만 실행됩니다.

대표 사용처: 검색 자동완성

const searchInput = document.querySelector("#search");

const handleSearch = debounce((e) => {
  fetchSearchResults(e.target.value);
}, 300);

searchInput.addEventListener("input", handleSearch);

사용자가 "리액트"라는 단어를 입력한다고 해봅시다. ㄹ, 리, 리ㅇ, 리액... 한 글자씩 입력할 때마다 API를 호출하면 불필요한 요청이 6~7번 발생합니다. 디바운싱을 적용하면 사용자가 타이핑을 멈춘 뒤 300ms가 지났을 때 딱 한 번만 요청이 나갑니다. 입력이 끝난 결과값만 중요한 상황에 적합합니다.


3. 쓰로틀링: "일정 간격으로는 반드시 실행한다"

쓰로틀링은 접근이 다릅니다. 이벤트가 아무리 자주 발생해도, 정해진 시간 간격마다 최소 한 번은 실행되도록 보장합니다.

function throttle(fn, interval) {
  let lastTime = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

그림으로 보면 차이가 명확합니다.

이벤트 발생:  ●  ●  ●  ●  ●  ●  ●  ●  ●
실행 시점:    ▶     ▶     ▶     ▶      (일정 간격마다 실행)

디바운싱은 이벤트가 멈춰야 실행되지만, 쓰로틀링은 이벤트가 계속 발생해도 일정 간격마다 꾸준히 실행됩니다.

대표 사용처: 스크롤 이벤트

const handleScroll = throttle(() => {
  updateScrollProgressBar();
}, 200);

window.addEventListener("scroll", handleScroll);

스크롤 진행률 바처럼 사용자가 스크롤하는 동안에도 계속 화면에 반영되어야 하는 UI라면 디바운싱은 적합하지 않습니다. 디바운싱을 쓰면 스크롤이 멈출 때까지 진행률 바가 전혀 움직이지 않는, 부자연스러운 경험이 됩니다. 쓰로틀링은 스크롤 도중에도 200ms마다 한 번씩은 반드시 갱신되므로 자연스럽게 따라옵니다.


4. 언제 뭘 써야 하는가

가장 많이 헷갈려하는 지점이라 표로 정리해봤습니다.

상황 적합한 기법 이유

검색어 자동완성 디바운싱 입력이 끝난 최종 값만 중요
폼 유효성 검사 (입력 완료 후) 디바운싱 타이핑 도중 매번 검증할 필요 없음
버튼 중복 클릭 방지 디바운싱 마지막(혹은 첫) 클릭만 유효하게
무한 스크롤 (스크롤 위치 감지) 쓰로틀링 스크롤 도중에도 주기적 갱신 필요
스크롤 진행률 바 쓰로틀링 실시간성 있는 시각적 피드백 필요
드래그 앤 드롭 위치 갱신 쓰로틀링 드래그 도중 계속 반응해야 함
윈도우 리사이즈 대응 레이아웃 계산 둘 다 가능 최종 크기만 필요하면 디바운싱, 실시간 반응이 필요하면 쓰로틀링

한 문장으로 요약하면 이렇습니다.

"결과만 중요하면 디바운싱, 과정도 중요하면 쓰로틀링."


5. 실무에서 마주치는 함정들

this 바인딩 놓치기

앞서 만든 debounce, throttle 함수에서 fn.apply(this, args)를 쓴 이유가 있습니다. 화살표 함수로 감싸지 않고 그냥 fn(...args)로 호출하면, 클래스 메서드나 객체 메서드로 쓰일 때 this가 유실될 수 있습니다.

class SearchBox {
  constructor() {
    this.query = "";
    this.handleInput = debounce(this.handleInput.bind(this), 300);
  }
  handleInput(e) {
    this.query = e.target.value; // this가 SearchBox 인스턴스를 가리켜야 함
  }
}

bind(this)를 빼먹으면 handleInput 내부의 this가 undefined가 되거나 예상치 못한 컨텍스트를 가리키게 됩니다.

클린업을 잊는 경우 (React)

React 컴포넌트에서 debounce나 throttle로 감싼 함수를 매 렌더링마다 새로 만들면, 이전 타이머가 정리되지 않은 채로 계속 쌓입니다.

function SearchBox() {
  const [query, setQuery] = useState("");

  // 매 렌더링마다 새로운 debounce 함수가 생성되는 문제
  const debouncedSearch = debounce((value) => {
    fetchResults(value);
  }, 300);

  return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}

이렇게 쓰면 리렌더링될 때마다 새로운 타이머 인스턴스가 생기기 때문에, 이전 인풋에 걸려있던 디바운스가 무효화됩니다. useMemo나 useRef로 함수를 한 번만 생성하도록 고정해야 합니다.

function SearchBox() {
  const [query, setQuery] = useState("");

  const debouncedSearch = useMemo(
    () => debounce((value) => fetchResults(value), 300),
    []
  );

  useEffect(() => {
    return () => {
      // 컴포넌트 언마운트 시 타이머 정리 (debounce 함수에 cancel 메서드를 추가해두는 것이 일반적)
      debouncedSearch.cancel?.();
    };
  }, [debouncedSearch]);

  return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}

라이브러리를 직접 구현할 필요는 없다

여기서는 원리를 설명하기 위해 직접 구현했지만, 실무에서는 lodash.debounce, lodash.throttle처럼 검증된 라이브러리를 쓰는 것이 일반적입니다. 엣지 케이스(leading/trailing 옵션, cancel, flush 메서드 등)까지 다 챙기려면 생각보다 신경 쓸 부분이 많기 때문입니다. 다만 면접에서는 "왜 그렇게 동작하는지"를 직접 구현할 수 있는지가 자주 확인됩니다.


6. 면접에서 자주 나오는 질문

  • 디바운싱과 쓰로틀링의 차이를 코드로 구현하면서 설명해달라
  • 두 기법을 각각 언제 사용하는 것이 적합한지 예시를 들어달라
  • leading과 trailing 옵션이 무엇이고 왜 필요한지 아는가
  • React에서 디바운스 함수를 useCallback 없이 매 렌더링마다 생성하면 어떤 문제가 생기는가

leading/trailing 옵션은 간단히 언급하자면, 디바운싱을 이벤트가 시작되는 시점에 한 번 실행할지(leading), 끝나는 시점에 실행할지(trailing), 혹은 둘 다 실행할지를 결정하는 옵션입니다. lodash.debounce는 기본적으로 trailing만 활성화되어 있지만, 상황에 따라 leading을 켜서 "첫 클릭은 즉시 반응하고, 이후 연타는 무시"하는 패턴을 만들 수도 있습니다.


마무리하며

디바운싱과 쓰로틀링은 개념 자체는 단순하지만, "왜 이 상황엔 이걸 써야 하는가"를 설명할 수 있는지가 실제 실무 경험을 가늠하는 지표가 됩니다. 단순히 API 사용법을 아는 것과, 이벤트 발생 패턴을 보고 어떤 전략이 사용자 경험에 더 적합한지 판단할 수 있는 것은 다른 수준의 이해입니다.

지금까지 프로토타입 체인, Object.create()와 프로토타입 오염, 그리고 디바운싱/쓰로틀링까지 세 편을 통해 자바스크립트의 동작 원리를 다뤄봤습니다. 다음 편에서는 실제 트러블슈팅 경험을 곁들여, 이 개념들이 실무에서 어떻게 버그를 예방하거나 성능을 개선했는지 사례 중심으로 풀어보겠습니다.