사용자 입력에 따라 실시간으로 API를 호출하거나 스크롤 이벤트에 맞춰 UI를 업데이트할 때 브라우저가 느려지거나 서버에 엄청난 요청이 몰리는 현상을 경험해봤을 것이다.
다만 연동된 이벤트가 초당 수십 번 이상 실행되는 정확한 원인과 이를 제어하는 최적화 기법을 모른 채 방치하는 경우가 많다.
이번에는 연속적으로 발생하는 이벤트를 효율적으로 제어하는 Debounce와 Throttle이 정확히 무엇인지, 왜 필요한지, 그리고 실무에서 어떻게 구현하는지 완벽하게 정리해서 소개하겠다.
키보드 입력이나 마우스 스크롤, 브라우저 창 리사이즈 같은 이벤트는 사용자가 동작을 수행하는 동안 매우 짧은 간격으로 수십 수백 번 연속 발생한다. 최적화 없이 매 순간 중동작을 처리하면 렌더링 프레임이 떨어져 화면이 끊기거나 API 과호출로 서버 비용이 급증한다.
이를 해결하기 위해 사용하는 기법이 바로 Debounce(디바운스)와 Throttle(쓰로틀)이다. 두 개념 모두 이벤트 실행 횟수를 제한한다는 목적은 같지만 동작 방식에서 명확한 차이가 있다.
| 구분 | 동작 방식 | 주요 사용 사례 |
|---|---|---|
| Debounce | 연속된 이벤트 중 마지막 이벤트가 발생하고 일정 시간이 지난 후에 딱 한 번만 실행 | 검색어 자동완성, 결제 버튼 중복 클릭 방지, 창 리사이즈 멈춤 감지 |
| Throttle | 연속된 이벤트 발생 중 지정한 주기(시간 간격)마다 최대 한 번씩만 실행 | 무한 스크롤, 스크롤 위치에 따른 스티키 헤더 전환, 마우스 이동 좌표 추적 |
Debounce는 타이머를 활용한다. 새로운 이벤트가 발생할 때마다 기존에 설정된 타이머를 취소(clearTimeout)하고 새 타이머를 등록(setTimeout)한다. 이로 인해 사용자가 입력을 멈출 때까지 실제 콜백 함수 실행이 뒤로 밀리게 된다.
Throttle은 시간 기록이나 타이머 스케줄링을 활용한다. 지정한 시간이 지나지 않았다면 추가로 들어오는 이벤트를 무시하고, 지정한 시간이 지난 시점에 들어온 요청만 실행하여 일정 주기를 유지한다.
실제 프로젝트에서 흔히 발생하는 문제 상황과 이를 Debounce, Throttle로 해결하는 방법을 살펴보자.
사용자가 '개발자'라는 단어를 입력할 때 디바운스가 없으면 'ㄱ', '개', '개ㅂ', '개발' 등 글자 하나마다 서버 API를 요청한다.
✗ 잘못된 코드 (이벤트 발생 시마다 매번 API 호출)
const searchInput = document.querySelector('#search');
// 사용자가 키를 누를 때마다 백엔드 API 요청 실행
searchInput.addEventListener('input', (e) => {
fetchSearchResults(e.target.value);
});✓ 올바른 코드 (Debounce 커스텀 함수 구현 및 적용)
const searchInput = document.querySelector('#search');
function debounce(func, delay = 300) {
let timerId;
return function (...args) {
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
const handleSearch = debounce((keyword) => {
console.log('최종 입력 완료 후 API 요청:', keyword);
fetchSearchResults(keyword);
}, 300);
searchInput.addEventListener('input', (e) => {
handleSearch(e.target.value);
});결과: 사용자가 타자를 치는 동안에는 API 요청이 보류되고, 입력을 마치고 300ms 동안 추가 입력이 없을 때 비로소 1번의 API 요청이 전송된다.
페이지 스크롤에 따라 상단 진행바(Progress Bar)나 네비게이션 스타일을 변경할 때 쓰로틀이 필요하다.
✗ 잘못된 코드 (스크롤 1픽셀마다 복잡한 DOM 연산 수행)
window.addEventListener('scroll', () => {
// 초당 수십 번 실행되어 브라우저 렌더링 병목 발생
updateProgressBar();
});✓ 올바른 코드 (Throttle 커스텀 함수 구현 및 적용)
function throttle(func, limit = 200) {
let inThrottle;
return function (...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}
const handleScroll = throttle(() => {
console.log('200ms 주기로 스크롤 위치 업데이트');
updateProgressBar();
}, 200);
window.addEventListener('scroll', handleScroll);결과: 사용자가 계속 스크롤을 내리더라도 연산 작업이 200ms마다 균일하게 실행되므로 프레임 드랍 없이 부드러운 스크롤을 유발할 수 있다.
Debounce와 Throttle을 구현할 때 자주 범하는 대표적인 실수를 확인하고 대처해야 한다.
✗ 잘못된 것: React/Vue 등 프레임워크 컴포넌트 내부에서 리렌더링마다 디바운스 함수를 새로 생성하는 경우
// React 컴포넌트 내부
function SearchBox() {
// 컴포넌트가 리렌더링될 때마다 새로운 debounce 인스턴스가 생성되어 타이머 리셋이 안 됨
const handleInput = debounce((val) => fetchApi(val), 300);
return <input onChange={(e) => handleInput(e.target.value)} />;
}✓ 올바른 것: React에서는 useCallback이나 useRef를 활용해 함수 참조를 유지해야 함
function SearchBox() {
const handleInput = useCallback(
debounce((val) => fetchApi(val), 300),
[]
);
return <input onChange={(e) => handleInput(e.target.value)} />;
}추가로 Lodash 라이브러리의 `_.debounce`나 `_.throttle`을 사용하는 경우 컴포넌트가 언마운트될 때 `.cancel()` 메서드로 대기 중인 타이머를 해제해 주어야 메모리 누수를 막을 수 있다.
Debounce와 Throttle은 프론트엔드 성능 최적화에서 필수적인 이벤트 제어 기법이다. 작은 이벤트 제어 습관이 모여서 웹 애플리케이션의 렌더링 성능과 서버 비용 절감이라는 큰 효과를 만든다는 점을 잊지 말자. 이 글의 실전 예제 코드를 참고해 작성 중인 검색창이나 스크롤 이벤트에 디바운스와 쓰로틀을 적용하면, 사용자 경험 향상과 서버 과부하 방지라는 두 가지 토끼를 모두 잡을 수 있을 것이다.