React에서 디바운스(debounce)를 구현할 때 흔히 발생하는 문제 중 하나가 stale closure와 타이머 관리 문제입니다.
이번 글에서는 useDebounce 훅의 내부 구조와, 이를 안전하게 사용하는 방법, 그리고 useCallback과 callbackRef 패턴의 차이를 실제 예제와 함께 정리합니다.
우리가 흔히 작성하는 useDebounce 훅은 다음과 같습니다.
export function useDebounce(callback: () => void, delay: number) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
return useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(callback, delay);
}, [callback, delay]);
}
callback이 dependency에 포함되어 있어, 렌더마다 새로운 함수 참조가 들어오면 debounce 함수도 새로 생성됩니다.
이때 이전 렌더에서 생성된 타이머는 여전히 브라우저에 살아있어 stale closure 문제가 발생할 수 있습니다.
sequenceDiagram
autonumber
participant React1 as 렌더 1 (callback A)
participant useDebounce1 as useDebounce 클로저 #1
participant Timer1 as 타이머 #1
participant React2 as 렌더 2 (callback B)
participant useDebounce2 as useDebounce 클로저 #2
participant Timer2 as 타이머 #2
React1->>useDebounce1: call useDebounce(callback A, 300)
useDebounce1->>Timer1: setTimeout(callback A, 300)
React2->>useDebounce2: 렌더 2 발생 (callback B)
useDebounce2->>Timer2: setTimeout(callback B, 300)
Timer1-->>useDebounce1: callback A 실행 (stale)
Timer2-->>useDebounce2: callback B 실행 (최신)
타이머는 브라우저 레벨에서 살아있지만, React 클로저 기준으로는 이미 끊어진 상태입니다.
렌더마다 새 함수가 생성되지 않도록 callback을 메모이제이션합니다.
const handleSearch = useCallback(async () => {
if (!isSearchMode) return setStocks(initialStocks);
setLoading(true);
try {
const results = await searchStocks(searchTerm.trim());
setStocks(results);
} catch {
setStocks([]);
} finally {
setLoading(false);
}
}, [isSearchMode, searchTerm, initialStocks]);
const debouncedSearch = useDebounce(handleSearch, 300);
handleSearch 참조가 stable → useDebounce 내부도 stable
디바운스 타이머가 정상적으로 동작합니다.
useCallback을 쓰지 않고, 항상 최신 callback을 실행하게 만들 수도 있습니다.
export function useDebounce(callback: () => void, delay: number) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedFn = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
callbackRef.current();
}, delay);
}, [delay]);
return debouncedFn;
}
callbackRef는 렌더가 바뀌어도 동일한 객체를 참조합니다.
setTimeout 안에서 항상 최신 callback을 호출할 수 있어 stale closure 문제 해결
useCallback으로 감싸지 않아도 안정적
항목 | 문제 | 해결 방법 |
|---|---|---|
매 렌더마다 callback 재생성 | debounce 타이머가 새로 생성 → 이전 타이머는 stale |
|
타이머 안에서 stale state | 옛날 클로저의 state 접근 |
|
메모리 누수? | ❌ 타이머는 브라우저가 GC 후 자동 해제 | ✅ 문제는 stale closure로 인한 중복 실행, 효율 문제 |
API 호출 debounce: 검색 입력, 자동 완성 등
Throttle/Resize 이벤트: 동일 패턴 적용 가능
callbackRef + useCallback 혼합:
외부에 callback 전달 → useCallback
내부 타이머/비동기 → callbackRef
React에서 debounce를 구현할 때 stale closure 문제를 이해하는 것이 핵심입니다.
단순히 useDebounce를 사용한다고 안전하지 않습니다.
useCallback 또는 callbackRef 패턴을 통해 타이머와 callback 참조를 안정화해야 합니다.
이 패턴을 이해하면 React의 렌더링과 클로저, 비동기 타이머가 얽힌 문제를 깔끔하게 해결할 수 있습니다.