입력, 스크롤, 리사이즈처럼 과도하게 발생하는 이벤트는 그대로 처리하면 렌더링과 네트워크 호출이 폭증해 성능이 급격히 떨어집니다. 디바운스는 마지막 입력 후 일정 시간 동안 추가 입력이 없을 때만 동작을 실행해 이런 문제를 해결합니다. 본 글에서는 실무에서 바로 쓸 수 있는 두 가지 커스텀 훅으로 디바운스를 구현하고 적용하는 방법을 설명합니다.
1. 디바운스가 필요한 순간
검색 자동완성 입력, 윈도우 리사이즈, 스크롤 위치 연동, 폼 유효성 검증처럼 사용자가 빠르게 연속 입력하는 상황에서 디바운스를 적용하면 불필요한 연산과 API 호출을 줄일 수 있습니다. 반대로 즉각적인 피드백이 중요한 버튼 클릭 등에는 적용하지 않습니다.
2. 두 가지 훅 설계: 값 vs 콜백
useDebouncedValue는 변하는 값을 지연시켜 반환합니다. UI는 즉시 반응하고, 지연된 값으로만 사이드 이펙트를 실행할 때 적합합니다. useDebouncedCallback은 콜백 실행 자체를 지연합니다. 이벤트 리스너나 비싼 계산을 직접 묶을 때 유용합니다.
3. useDebouncedValue 구현
import { useEffect, useState } from 'react';
// 값 변화가 멈춘 뒤 delay(ms) 후에만 debounced 값을 갱신합니다.
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
4. 검색 입력에 적용 예제 (레이스 컨디션 방지 포함)
import React, { useEffect, useRef, useState } from 'react';
import { useDebouncedValue } from './useDebouncedValue';
export default function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);
const [results, setResults] = useState([]);
const abortRef = useRef(null);
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([]);
return;
}
if (abortRef.current) abortRef.current.abort();
const ac = new AbortController();
abortRef.current = ac;
const url = '/api/search?q=' + encodeURIComponent(debouncedQuery);
fetch(url, { signal: ac.signal })
.then((res) => res.json())
.then((data) => setResults(data.items || []))
.catch((err) => {
if (err.name !== 'AbortError') console.error(err);
});
return () => ac.abort();
}, [debouncedQuery]);
return (
<div>
<input
type='text'
value={query}
placeholder='검색어를 입력하세요'
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
중요한 점은 입력값 상태는 즉시 업데이트해 사용자 타이핑이 즉각 반영되도록 하고, 네트워크 호출은 디바운스된 값으로만 수행한다는 것입니다. AbortController로 이전 요청을 취소해 레이스 컨디션도 막습니다.
5. useDebouncedCallback 구현 (leading/trailing, cancel/flush)
import { useCallback, useEffect, useRef } from 'react';
// 이벤트 핸들러 같은 콜백 실행 자체를 디바운스합니다.
// options.leading: true면 첫 호출 즉시 1회 실행합니다.
// options.trailing: true면 마지막 호출을 delay 후 실행합니다.
export function useDebouncedCallback(fn, delay = 300, options = { leading: false, trailing: true }) {
const { leading = false, trailing = true } = options || {};
const fnRef = useRef(fn);
const timerRef = useRef(null);
const lastArgsRef = useRef();
const leadingInvokedRef = useRef(false);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
const clearTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const invokeTrailing = () => {
leadingInvokedRef.current = false;
if (trailing && lastArgsRef.current) {
fnRef.current(...lastArgsRef.current);
lastArgsRef.current = undefined;
}
};
const debounced = useCallback((...args) => {
lastArgsRef.current = args;
if (leading && !leadingInvokedRef.current) {
fnRef.current(...args);
leadingInvokedRef.current = true;
}
clearTimer();
if (trailing) {
timerRef.current = setTimeout(invokeTrailing, delay);
} else if (leading) {
// trailing이 꺼져 있어도 delay 후 다음 leading을 허용합니다.
timerRef.current = setTimeout(() => {
leadingInvokedRef.current = false;
}, delay);
}
}, [delay, leading, trailing]);
const cancel = useCallback(() => {
clearTimer();
lastArgsRef.current = undefined;
leadingInvokedRef.current = false;
}, []);
const flush = useCallback(() => {
clearTimer();
if (trailing && lastArgsRef.current) {
fnRef.current(...lastArgsRef.current);
lastArgsRef.current = undefined;
leadingInvokedRef.current = false;
}
}, [trailing]);
useEffect(() => cancel, [cancel]); // 언마운트 시 자동 취소
// 함수에 메서드 부착 (JS에서는 가능). TS라면 반환 객체 형태를 권장.
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
6. 윈도우 리사이즈 이벤트에 적용
import React, { useEffect, useState } from 'react';
import { useDebouncedCallback } from './useDebouncedCallback';
const getSize = () => ({
w: typeof window !== 'undefined' ? window.innerWidth : 0,
h: typeof window !== 'undefined' ? window.innerHeight : 0,
});
export default function ResizePanel() {
const [size, setSize] = useState(getSize);
const onResize = useDebouncedCallback(() => {
setSize(getSize());
}, 200, { leading: false, trailing: true });
useEffect(() => {
if (typeof window === 'undefined') return;
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [onResize]);
return (
<p>현재 크기: {size.w} x {size.h}</p>
);
}
SSR 환경을 고려해 window 존재 여부를 체크합니다. 디바운스된 핸들러 참조가 변하면 이벤트 리스너도 함께 갱신되므로, 훅 내부에서 콜백 참조를 useRef로 안정화해 불필요한 바인딩을 피했습니다.
7. 실무 적용 팁과 주의사항
입력값 자체는 즉시 반영하고 사이드 이펙트만 디바운스합니다. 사용자는 타이핑 지연 없이 반응하는 UI를 기대합니다.
지연 시간은 200~400ms가 보편적입니다. 자동완성은 250~350ms, 리사이즈는 100~200ms 정도를 먼저 시도하고 서비스 특성에 맞게 조정합니다.
leading은 첫 입력에 즉시 반응해야 할 때만 켭니다. 대부분의 검색은 trailing만 켜도 충분합니다. 둘 다 true면 첫 입력과 마지막 입력 모두 처리됩니다.
delay 값을 동적으로 바꿀 경우, 의존성에 포함되어 타이머가 재설정됩니다. 사용자가 입력 중일 때 갑자기 delay가 줄거나 늘면 체감에 영향을 줄 수 있으니 주의합니다.
이미 lodash를 사용 중이면 _.debounce를 감싼 훅을 만들어도 됩니다. 다만 React 훅 내부에서 콜백 최신 참조를 useRef로 유지해 stale 클로저를 방지해야 합니다.
React 18에서 디바운스된 콜백이 무거운 상태 업데이트를 유발한다면 startTransition으로 낮은 우선순위로 전환하는 것도 고려합니다.
8. 마무리
디바운스는 적은 코드로 체감 성능을 크게 개선하는 실무 효자 패턴입니다. 값 기반 훅과 콜백 기반 훅을 상황에 맞게 선택하고, 레이스 컨디션과 SSR 같은 환경 이슈만 피하면 안정적인 사용자 경험을 제공할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 웹 브라우저 멀티 스레드(Web Workers) 활용하기 (0) | 2026.07.07 |
|---|---|
| React 앱에서 시각적 차이를 비교하는 Visual Regression 테스트 (0) | 2026.07.07 |
| React 앱에서 화면 녹화(Screen Recording) 기능 구현 (0) | 2026.07.03 |
| React에서 비동기 컴포넌트 로딩 순서 제어하기 (0) | 2026.07.02 |
| React 앱에서 브라우저 Payment Request API 통합하기 (1) | 2026.07.02 |