웹 프론트엔드에서 input, scroll, resize와 같은 이벤트는 짧은 시간 동안 수십, 수백 번 연속해서 발생한다.
이를 최적화 없이 매번 핸들러나 API 호출로 연결하면 아래 사진처럼 브라우저 성능 저하와 서버 트래픽 과부하를 초래한다. 이러한 빈번한 이벤트 호출을 제어하는 대표적인 두 가지 기법이 디바운싱(Debouncing)과 쓰로틀링(Throttling)이다.

연이어 호출되는 이벤트들을 하나의 그룹으로 묶어, 마지막 이벤트가 발생한 후 일정 시간 동안 추가 입력이 없을 때 딱 한 번만 실행하는 방식이다.
resize) 후 레이아웃 재계산이벤트 발생: | | | |||| | (입력 멈춤) --------> [실행!]
타이머 리셋: R R R RRRR R (지정 시간 경과)
디바운싱을 적용하지 않고 검색창 onChange마다 API를 호출하면, 사용자가 '리액트'를 칠 때 ㄹ, 리, 리ㅇ, 리애, 리액, 리액ㅌ, 리액트처럼 글자 조합마다 불필요한 네트워크 요청이 쏟아진다. 디바운싱을 적용하면 타이핑이 완전히 멈췄을 때 한 번만 요청을 보낼 수 있다.
연이어 발생하는 이벤트에 대해 일정한 시간 간격(delay)을 두고 최대 한 번만 실행되도록 제한하는 방식이다.
scroll), 드래그 앤 드롭(drag), 마우스 움직임 추적(mousemove)이벤트 발생: |||||||||||||||||||||||||||||||||||||||||||||||||
실행 주기: [실행] ---- 300ms ---- [실행] ---- 300ms ---- [실행]
무한 스크롤 구현 시 스크롤을 한 번 휠 조작하는 것만으로도 수십 개의 스크롤 이벤트가 트리거된다. 이때 쓰로틀링을 걸어두면 0.3초마다 한 번씩만 스크롤 위치를 계산하므로 브라우저 버벅임을 방지하고 안정적으로 다음 목록 데이터를 호출할 수 있다.
| 구분 | 디바운싱 (Debouncing) | 쓰로틀링 (Throttling) |
|---|---|---|
| 실행 타이밍 | 연속된 이벤트가 완전히 끝난 후 일정 시간 뒤 1회 | 연속된 이벤트가 진행되는 도중에도 일정 주기마다 지속 실행 |
| 목적 | 마지막 최종 결과물 하나만 필요할 때 | 실행 빈도를 줄이되 중간 진행 상태를 계속 반영해야 할 때 |
| 적합한 작업 | 검색어 자동완성, 폼 유효성 검증 | 무한 스크롤 위치 감지, 애니메이션 스크롤 인터랙션 |

유틸리티 라이브러리인 Lodash의 _.debounce 함수를 활용하면 복잡한 타이머 로직 없이 간단하게 구현할 수 있다.
yarn add lodash
yarn add --dev @types/lodash # TypeScript 사용 시
_.debounce(callback, waitTime) 구조로, 첫 번째 인자에는 지연 후 실행할 콜백을, 두 번째 인자에는 대기 시간(ms 단위)을 넘긴다.
import _ from "lodash";
export default function SearchInput() {
// 500ms 동안 추가 입력이 없을 때 최종 value를 출력
const handleDebounceSearch = _.debounce((value) => {
console.log("서버로 검색 요청 전송:", value);
}, 500);
const onChangeInput = (event) => {
// 이벤트 객체 대신 실제 문자열 값을 전달하는 것이 안전하다.
handleDebounceSearch(event.target.value);
};
return <input type="text" onChange={onChangeInput} placeholder="검색어를 입력하세요" />;
}
위 컴포넌트 내부에서 단순히 const handleDebounceSearch = _.debounce(...) 형태로 선언하면, 컴포넌트가 리렌더링될 때마다 새로운 디바운스 함수 인스턴스가 계속 생성된다.
그 결과 이전 타이머가 정상적으로 초기화되지 않고 매 입력마다 타이머가 따로 돌아 디바운싱이 무력화될 수 있다.
이를 방지하려면 useCallback이나 useMemo로 디바운스 함수 인스턴스를 메모이제이션해 두어야 리렌더링 사이에서도 단일 타이머가 유지된다.
import { useCallback } from "react";
import _ from "lodash";
export default function SearchInput() {
// useCallback을 활용해 디바운스 함수의 참조값을 유지
const handleDebounceSearch = useCallback(
_.debounce((value) => {
console.log("최적화된 검색 요청:", value);
}, 500),
[]
);
const onChangeInput = (event) => {
handleDebounceSearch(event.target.value);
};
return <input type="text" onChange={onChangeInput} />;
}