
디바운스와 스로틀 적용
검색창에 글자 하나 칠 때마다 API 콜이 날아가고, 스크롤할 때마다 이벤트가 폭주하는 문제를 디바운스와 스로틀로 해결한 이야기.

검색창에 글자 하나 칠 때마다 API 콜이 날아가고, 스크롤할 때마다 이벤트가 폭주하는 문제를 디바운스와 스로틀로 해결한 이야기.
버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

검색 자동완성 기능을 처음 만들 때였다. 사용자가 입력창에 글자를 타이핑할 때마다 실시간으로 검색 결과를 보여주고 싶었다. 간단하게 onChange 이벤트에 API 호출을 걸었다.
결과는 재앙이었다.
"리액트"를 검색하려고 "ㄹ", "리", "리ㅇ", "리액"... 한 글자씩 칠 때마다 API 콜이 날아갔다. 5글자 입력하는 동안 5번이 아니라 15번 넘게 요청이 발생했다. 한글의 조합형 특성 때문에 더 심했다. 개발자 도구를 열어보니 Network 탭이 빨간색으로 도배되어 있었다.
"이거 실서비스에 올리면 서버 비용 폭탄 맞겠는데?"
그때 선배 개발자가 툭 던진 한마디. "디바운스 써봤어?" 처음 들어본 단어였다. 검색해보니 쓰로틀(throttle)이란 것도 있었다. 둘 다 "이벤트 제어"와 관련된 기법인데, 언뜻 보면 비슷해 보였다. 근데 언제 뭘 써야 하는 거지?
한참을 삽질하다가 와닿은 비유가 있었다.
디바운스(Debounce)는 엘리베이터다. 누가 버튼을 누르면 엘리베이터는 몇 초 기다린다. 그 사이에 또 다른 사람이 오면? 대기 시간을 리셋하고 다시 기다린다. 아무도 안 오고 완전히 조용해지면 그제서야 문을 닫고 출발한다.
쓰로틀(Throttle)은 지하철이다. 사람이 얼마나 많든, 아무리 급하든 상관없이 정해진 시간 간격(예: 5분)마다 출발한다. 첫 번째 사람이 타고, 그 다음 5분 동안은 아무리 많은 사람이 와도 기다려야 한다.
이 비유를 코드로 이해하니 완전히 달라 보였다.
// 디바운스: 마지막 이벤트 후 n초 대기
// "사용자가 타이핑을 멈출 때까지 기다렸다가 한 번만 실행"
function debounce(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId); // 기존 타이머 취소
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// 쓰로틀: n초마다 최대 1번만 실행
// "아무리 많이 발생해도 일정 시간 간격으로만 실행"
function throttle(func, delay) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func.apply(this, args);
}
};
}
검색창 문제는 디바운스로 해결됐다. 사용자가 타이핑을 완전히 멈출 때까지 기다렸다가 그때 한 번만 API를 호출하면 되는 거였다. 0.3초 정도 delay를 주니까 15번이 1번으로 줄었다.
처음엔 lodash의 _.debounce를 썼다가, React에서는 좀 다르게 접근해야 한다는 걸 깨달았다. 컴포넌트가 리렌더될 때마다 새로운 debounced 함수가 생성되면 의미가 없기 때문이다.
import { useState, useCallback, useRef, useEffect } from 'react';
function useDebounce(callback, delay) {
const timeoutRef = useRef(null);
const callbackRef = useRef(callback);
// callback이 변경되면 최신 참조 유지
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedCallback = useCallback((...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
// 컴포넌트 언마운트 시 타이머 정리
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedCallback;
}
// 사용 예시
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const searchAPI = async (searchTerm) => {
if (!searchTerm.trim()) return;
try {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
}
};
const debouncedSearch = useDebounce(searchAPI, 300);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value); // 300ms 대기 후 실행
};
return (
<div>
<input
type="text"
value={query}
onChange={handleChange}
placeholder="검색어를 입력하세요..."
/>
<ul>
{results.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
핵심은 useRef로 타이머 ID를 관리하고, useCallback으로 함수를 메모이제이션하는 것이었다. 그리고 컴포넌트가 언마운트될 때 clearTimeout으로 정리하지 않으면 메모리 누수가 발생할 수 있다는 것도 배웠다.
다음 프로젝트는 무한 스크롤 피드였다. 스크롤 이벤트에 리스너를 걸었는데, 이번엔 디바운스가 답이 아니었다.
스크롤을 쭉 내리면 사용자가 "멈출 때까지" 기다리는 게 디바운스다. 근데 빠르게 스크롤하면? 사용자가 제일 아래까지 내렸는데도 디바운스 delay 때문에 다음 컨텐츠가 안 불러와지는 거다. UX 망한다.
여기선 쓰로틀이 맞았다. "스크롤 중에도 일정 간격마다는 체크해라"는 개념.
import { useEffect, useRef, useCallback } from 'react';
function useThrottle(callback, delay) {
const lastRan = useRef(Date.now());
const timeoutRef = useRef(null);
const throttledCallback = useCallback((...args) => {
const now = Date.now();
const timeSinceLastRan = now - lastRan.current;
if (timeSinceLastRan >= delay) {
callback(...args);
lastRan.current = now;
} else {
// 마지막 호출이 실행되도록 보장 (선택적)
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
callback(...args);
lastRan.current = Date.now();
}, delay - timeSinceLastRan);
}
}, [callback, delay]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return throttledCallback;
}
// 무한 스크롤 예시
function InfiniteScrollFeed() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const loadMore = async () => {
if (loading) return;
setLoading(true);
const response = await fetch(`/api/posts?page=${page}`);
const newItems = await response.json();
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
setLoading(false);
};
const handleScroll = () => {
const scrollTop = window.scrollY;
const windowHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
if (scrollTop + windowHeight >= docHeight - 200) {
loadMore();
}
};
const throttledScroll = useThrottle(handleScroll, 200);
useEffect(() => {
window.addEventListener('scroll', throttledScroll);
return () => window.removeEventListener('scroll', throttledScroll);
}, [throttledScroll]);
return (
<div>
{items.map(item => (
<div key={item.id}>{item.content}</div>
))}
{loading && <div>Loading...</div>}
</div>
);
}
200ms마다 한 번씩만 스크롤 위치를 체크한다. 스크롤 이벤트가 초당 수백 번 발생해도 실제로는 초당 5번만 실행되는 거다.
처음 만든 useDebounce 훅은 이상하게 동작했다. callback 함수가 오래된 state를 참조하는 거였다. 클로저가 생성된 시점의 값을 계속 기억하고 있었기 때문이다.
해결책은 useRef로 최신 callback을 참조하는 것. callbackRef.current에 항상 최신 함수를 저장해두면 stale closure 문제가 사라진다.
컴포넌트가 언마운트되는데 setTimeout이 남아있으면? 메모리 누수다. 심지어 언마운트된 컴포넌트의 state를 업데이트하려고 하면서 에러도 뱉는다.
반드시 useEffect의 cleanup 함수에서 clearTimeout 또는 이벤트 리스너 제거를 해야 한다.
lodash의 debounce/throttle은 leading, trailing 옵션이 있다.
버튼 더블클릭 방지 같은 경우엔 leading을 쓰는 게 맞다. 첫 클릭은 즉시 처리하고, 그 후 일정 시간 동안 추가 클릭을 무시하는 식.
이제 명확해졌다.
디바운스를 쓸 때:둘 다 성능 최적화 기법이지만, 타이밍 전략이 완전히 다르다. 디바운스는 "마지막 한 번만", 쓰로틀은 "주기적으로 한 번씩". 이 차이를 이해하고 나니 어떤 상황에서 뭘 써야 할지 자연스럽게 판단이 됐다.
그리고 무엇보다, 직접 구현해보면서 클로저, useRef, useCallback, cleanup 같은 React의 핵심 개념들이 왜 필요한지 체감했다. 라이브러리 갖다 쓰는 것도 좋지만, 한 번쯤은 직접 만들어보는 게 진짜 이해의 지름길이었다.