throttle vs debounce vs requestAnimationFrame: 언제 무엇을 써야 할까
프론트엔드에서 성능 이야기를 하다 보면 세 가지가 자주 같이 등장합니다.
throttledebouncerequestAnimationFrame
이 셋은 종종 비슷한 도구처럼 묶여서 언급되지만, 실제로는 해결하는 문제가 조금씩 다릅니다. 그래서 이름만 외워두면 실무에서 오히려 더 헷갈립니다.
예를 들어 이런 질문이 자주 나옵니다.
- 스크롤 이벤트는
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를 입력한다고 가정해보겠습니다.
rrereareact
매 타이핑마다 바로 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과 리렌더 비용을 한 번 더 같이 봐야 합니다.
빠르게 고르는 기준
실무에서 빨리 고르려면 이렇게 생각하면 편합니다.
-
마지막 한 번만 실행하면 되는가?
debounce -
중간 상태도 중요하지만 너무 자주 실행되면 안 되는가?
throttle -
브라우저가 화면을 그리는 타이밍에 맞춰 시각적으로 반영해야 하는가?
requestAnimationFrame
이 세 질문으로 대부분 정리가 됩니다.
자주 하는 실수
정리하면 아래 실수가 정말 자주 나옵니다.
- autocomplete에
throttle을 걸어서 중간값 요청이 너무 많이 나간다 - scroll UI를
debounce로 처리해서 반응이 굼떠진다 - 서버 호출 제한 문제를
requestAnimationFrame으로 풀려고 한다 - 시각적 업데이트 문제를 무거운
throttle로 처리해서 끊겨 보이게 만든다 - 셋을 전부 "성능 최적화 함수"로만 보고 목적 차이를 구분하지 않는다
이 문제들을 줄이려면, "이벤트가 많다"는 사실보다 무엇을 늦추고 싶은가, 무엇을 묶고 싶은가, 무엇을 렌더링 타이밍에 맞추고 싶은가를 먼저 봐야 합니다.
정리하면
throttle, debounce, requestAnimationFrame은 비슷해 보이지만 실제로는 질문이 다릅니다.
throttle: 너무 자주 오니까 주기적으로 제한하자debounce: 계속 오니까 멈춘 뒤 마지막 것만 실행하자requestAnimationFrame: 브라우저가 그릴 타이밍에 맞춰 시각적으로 반영하자
결국 중요한 것은 API 이름이 아니라 이벤트를 줄이고 싶은지, 마지막 의도만 반영하고 싶은지, 화면 그리기 타이밍에 맞추고 싶은지를 먼저 구분하는 것입니다. 그 기준만 서면 셋 중 무엇을 써야 할지도 생각보다 빠르게 결정됩니다.
