throttle vs debounce vs requestAnimationFrame: 언제 무엇을 써야 할까

Frontend

프론트엔드에서 성능 이야기를 하다 보면 세 가지가 자주 같이 등장합니다.

  • throttle
  • debounce
  • requestAnimationFrame

이 셋은 종종 비슷한 도구처럼 묶여서 언급되지만, 실제로는 해결하는 문제가 조금씩 다릅니다. 그래서 이름만 외워두면 실무에서 오히려 더 헷갈립니다.

예를 들어 이런 질문이 자주 나옵니다.

  • 스크롤 이벤트는 throttle 해야 하나, requestAnimationFrame을 써야 하나?
  • 검색창 API 호출은 debounce가 맞나?
  • drag 중간 업데이트는 왜 debounce가 아니라 requestAnimationFrame이 더 자연스러울까?

결국 중요한 것은 "셋 중 뭐가 더 좋은가"가 아니라, 이 이벤트 흐름에서 무엇을 줄이고 싶은가입니다.

한눈에 보면

짧게 정리하면 이렇습니다.

  • throttle: 너무 자주 들어오는 이벤트를 일정 주기로 제한하고 싶을 때
  • debounce: 연속 입력이 멈춘 뒤 한 번만 실행하고 싶을 때
  • requestAnimationFrame: 브라우저가 화면을 그리는 타이밍에 맞춰 시각 업데이트를 하고 싶을 때

즉:

  • 호출 횟수 제한이 목적이면 throttle
  • 마지막 한 번만 실행하는 것이 목적이면 debounce
  • 렌더링 타이밍에 맞추는 것이 목적이면 requestAnimationFrame

이렇게 먼저 생각하면 대부분의 선택이 빠르게 정리됩니다.

먼저 각각 무엇인지

throttle

이벤트가 아무리 자주 발생해도 일정 시간마다 한 번만 실행하게 만드는 방식입니다.

function throttle<T extends (...args: any[]) => void>(callback: T, delay: number) {
  let lastCall = 0;
 
  return (...args: Parameters<T>) => {
    const now = Date.now();
 
    if (now - lastCall >= delay) {
      lastCall = now;
      callback(...args);
    }
  };
}

예를 들어 scroll이 100번 들어와도 200ms에 한 번만 실행되게 만들 수 있습니다.

debounce

이벤트가 계속 들어오는 동안은 기다리다가, 입력이 멈춘 뒤 마지막 한 번만 실행하게 만드는 방식입니다.

function debounce<T extends (...args: any[]) => void>(callback: T, delay: number) {
  let timeoutId: number | undefined;
 
  return (...args: Parameters<T>) => {
    window.clearTimeout(timeoutId);
 
    timeoutId = window.setTimeout(() => {
      callback(...args);
    }, delay);
  };
}

즉, 연속 입력 중간값은 버리고 마지막 의도만 반영하고 싶을 때 잘 맞습니다.

requestAnimationFrame

이건 호출 횟수를 시간 기준으로 줄이는 도구라기보다, 브라우저가 다음 화면을 그리기 직전 타이밍에 맞춰 실행하게 해주는 API입니다.

requestAnimationFrame(() => {
  updateUI();
});

핵심은 "몇 ms 뒤"가 아니라, 다음 페인트 직전이라는 점입니다.

셋의 차이를 한 문장씩 줄이면

  • throttle: 너무 많이 오니까 주기적으로만 받자
  • debounce: 계속 오니까 멈춘 뒤 마지막 것만 받자
  • requestAnimationFrame: 브라우저가 그릴 타이밍에 맞춰 반영하자

이 차이만 기억해도 실무 선택이 많이 쉬워집니다.

검색창 autocomplete는 왜 debounce가 잘 맞을까?

가장 흔한 예제입니다.

사용자가 react query를 입력한다고 가정해보겠습니다.

  • r
  • re
  • rea
  • react

매 타이핑마다 바로 API를 호출하면:

  • 요청이 너무 많아지고
  • 응답 순서 꼬임이 생길 수 있고
  • 사용자 의도보다 중간 상태가 너무 많이 반영됩니다

이럴 때는 보통 마지막 입력이 잠깐 멈춘 뒤에만 호출하면 됩니다.

const debouncedFetch = debounce((keyword: string) => {
  fetchSuggestions(keyword);
}, 300);
 
input.addEventListener('input', (event) => {
  const value = (event.target as HTMLInputElement).value;
  debouncedFetch(value);
});

이 경우 중요한 것은 중간값보다 마지막 의도입니다. 그래서 debounce가 잘 맞습니다.

resize는 throttle이 잘 맞을까?

브라우저 resize 이벤트도 꽤 자주 발생합니다. 그런데 resize 중간 상태를 완전히 무시할 수는 없고, 너무 자주 계산하는 것도 부담입니다.

이럴 때는 보통 throttle이 잘 맞습니다.

const throttledResize = throttle(() => {
  updateLayout();
}, 200);
 
window.addEventListener('resize', throttledResize);

이 패턴의 장점은:

  • 사용자가 창 크기를 계속 바꿔도
  • 레이아웃 계산은 너무 자주 하지 않고
  • 그래도 완전히 마지막에만 반응하는 것은 아니라는 점입니다

즉, resize는 "중간 상태도 어느 정도 중요하지만 너무 자주 계산하긴 싫다"는 문제에 가까워서 throttle이 자연스럽습니다.

scroll에서는 왜 헷갈릴까?

스크롤은 이 셋이 가장 많이 헷갈리는 영역입니다. 이유는 스크롤에서 하고 싶은 일이 두 종류로 갈리기 때문입니다.

1. 스크롤 위치를 UI에 반영하고 싶다

예를 들어:

  • sticky header 그림자
  • progress bar
  • parallax
  • scroll-based animation

이건 시각적인 업데이트입니다. 이 경우는 requestAnimationFrame이 더 잘 맞습니다.

let ticking = false;
 
window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      updateScrollUI(window.scrollY);
      ticking = false;
    });
 
    ticking = true;
  }
});

여기서 목적은 호출 횟수를 초 단위로 줄이는 것이 아니라, DOM 업데이트를 브라우저 페인트 타이밍에 정렬하는 것입니다.

2. 스크롤이 어느 정도 진행됐을 때 분석 이벤트를 보내고 싶다

예를 들어:

  • 사용자가 페이지의 50%를 읽었는지 기록
  • 무거운 계산을 일정 주기로만 수행

이건 시각적 반영보다 과도한 호출을 줄이는 것이 목적입니다. 이런 경우는 throttle이 더 잘 맞습니다.

즉, scroll이라고 해서 무조건 requestAnimationFrame이 아니라:

  • 시각 업데이트면 requestAnimationFrame
  • 호출 빈도 제한이면 throttle

으로 보는 편이 정확합니다.

drag나 mousemove는 왜 requestAnimationFrame이 자주 쓰일까?

드래그 UI, 슬라이더, 커스텀 커서 같은 인터랙션은 pointer 이벤트가 매우 자주 발생합니다. 이때 매 이벤트마다 DOM을 바로 바꾸면 부자연스럽고 부담이 될 수 있습니다.

예를 들어:

let latestX = 0;
let latestY = 0;
let ticking = false;
 
window.addEventListener('pointermove', (event) => {
  latestX = event.clientX;
  latestY = event.clientY;
 
  if (!ticking) {
    requestAnimationFrame(() => {
      updateCursor(latestX, latestY);
      ticking = false;
    });
 
    ticking = true;
  }
});

이 패턴은 이벤트를 다 처리하는 것이 아니라, 가장 최신 위치만 다음 프레임에 반영하는 식입니다.

즉, 드래그/포인터 기반 인터랙션은 debounce와는 잘 맞지 않고, throttle보다도 requestAnimationFrame이 더 자연스러운 경우가 많습니다. 이유는 최종 목적이 "부드러운 시각 반영"이기 때문입니다.

그러면 requestAnimationFrame은 throttle의 대체재일까?

완전히 그렇지는 않습니다.

둘 다 "너무 자주 들어오는 이벤트를 바로 처리하지 않는다"는 공통점은 있지만 기준이 다릅니다.

  • throttle: 시간 간격 기준
  • requestAnimationFrame: 다음 페인트 기준

예를 들어 200ms에 한 번만 서버 로그를 보내고 싶다면 requestAnimationFrame은 맞지 않습니다. 반대로 scroll progress bar를 부드럽게 움직이고 싶다면 200ms throttle은 너무 거칠 수 있습니다.

즉, requestAnimationFrame은 성능 최적화 도구이기도 하지만, 더 정확히는 렌더링 타이밍 최적화 도구입니다.

debounce를 쓰면 안 좋은 경우도 있다

검색창처럼 마지막 의도만 중요할 때는 좋지만, 아래 상황에서는 debounce가 어색할 수 있습니다.

  • 스크롤 위치를 즉시 반영해야 하는 UI
  • 드래그 중간 상태가 중요한 인터랙션
  • 사용자가 움직이는 동안 계속 피드백이 보여야 하는 경우

이때 debounce를 쓰면 "멈춘 뒤에만 반응"하기 때문에 UI가 뒤늦게 따라오는 느낌이 강해질 수 있습니다.

즉, debounce는 강력하지만 중간 프레임을 버려도 되는 문제에 써야 합니다.

실무에서 자주 보는 조합

실제로는 하나만 쓰지 않고 섞는 경우도 많습니다.

검색창

  • API 호출: debounce
  • 드롭다운 위치 계산/애니메이션: 필요하면 requestAnimationFrame

스크롤 기반 UI

  • progress bar, sticky header: requestAnimationFrame
  • analytics 이벤트: throttle

resize

  • 무거운 layout 계산: throttle
  • 최종 저장/로깅: debounce

즉, 이벤트 하나에도 "무엇을 하느냐"에 따라 도구가 달라질 수 있습니다.

React에서는 어떻게 가져가면 좋을까?

React에서도 원칙은 같습니다.

  • API 호출처럼 "마지막 값만 중요"하면 debounce
  • 이벤트 폭주를 줄이려면 throttle
  • 시각적 DOM 반영을 프레임에 맞추고 싶으면 requestAnimationFrame

예를 들어 검색 input은 이렇게 볼 수 있습니다.

const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);

반면 스크롤 progress bar처럼 프레임 기반 반영이 중요하면:

useEffect(() => {
  let ticking = false;
 
  const onScroll = () => {
    if (!ticking) {
      requestAnimationFrame(() => {
        updateProgress(window.scrollY);
        ticking = false;
      });
 
      ticking = true;
    }
  };
 
  window.addEventListener('scroll', onScroll);
 
  return () => {
    window.removeEventListener('scroll', onScroll);
  };
}, []);

즉, React에서도 핵심은 같고, 여기에 cleanup과 리렌더 비용을 한 번 더 같이 봐야 합니다.

빠르게 고르는 기준

실무에서 빨리 고르려면 이렇게 생각하면 편합니다.

  1. 마지막 한 번만 실행하면 되는가?
    debounce

  2. 중간 상태도 중요하지만 너무 자주 실행되면 안 되는가?
    throttle

  3. 브라우저가 화면을 그리는 타이밍에 맞춰 시각적으로 반영해야 하는가?
    requestAnimationFrame

이 세 질문으로 대부분 정리가 됩니다.

자주 하는 실수

정리하면 아래 실수가 정말 자주 나옵니다.

  • autocomplete에 throttle을 걸어서 중간값 요청이 너무 많이 나간다
  • scroll UI를 debounce로 처리해서 반응이 굼떠진다
  • 서버 호출 제한 문제를 requestAnimationFrame으로 풀려고 한다
  • 시각적 업데이트 문제를 무거운 throttle로 처리해서 끊겨 보이게 만든다
  • 셋을 전부 "성능 최적화 함수"로만 보고 목적 차이를 구분하지 않는다

이 문제들을 줄이려면, "이벤트가 많다"는 사실보다 무엇을 늦추고 싶은가, 무엇을 묶고 싶은가, 무엇을 렌더링 타이밍에 맞추고 싶은가를 먼저 봐야 합니다.

정리하면

throttle, debounce, requestAnimationFrame은 비슷해 보이지만 실제로는 질문이 다릅니다.

  • throttle: 너무 자주 오니까 주기적으로 제한하자
  • debounce: 계속 오니까 멈춘 뒤 마지막 것만 실행하자
  • requestAnimationFrame: 브라우저가 그릴 타이밍에 맞춰 시각적으로 반영하자

결국 중요한 것은 API 이름이 아니라 이벤트를 줄이고 싶은지, 마지막 의도만 반영하고 싶은지, 화면 그리기 타이밍에 맞추고 싶은지를 먼저 구분하는 것입니다. 그 기준만 서면 셋 중 무엇을 써야 할지도 생각보다 빠르게 결정됩니다.

같이 보면 좋은 글