검색창에 "React"를 타이핑할 때마다 API를 호출한다면? 5글자 입력에 5번의 불필요한 요청이 발생한다. 스크롤할 때마다 무한 스크롤 체크를 한다면? 초당 수백 번의 함수 실행으로 브라우저가 멈춘다.
이 문서는 다음 질문에 답한다:
이 문서를 읽고 나면:
정의: 연속된 이벤트 중 마지막 이벤트만 처리한다.
동작 방식:
비유: 엘리베이터 문
사람이 타려고 하면 문이 닫히려다가 다시 열림
더 이상 사람이 안 오면 (일정 시간 후) 문이 닫힘
코드 예시:
사용자 타이핑: R → Re → Rea → Reac → React
타이머: 시작 → 리셋 → 리셋 → 리셋 → 리셋
(500ms 대기)
→ API 호출 "React" ✅
결과: 1번의 API 호출
정의: 일정 시간 간격으로 주기적으로 실행한다.
동작 방식:
비유: 지하철 문
일정 시간(예: 1분)마다 문을 열고 닫음
중간에 아무리 버튼을 눌러도 시간이 되기 전엔 열리지 않음
코드 예시:
사용자 스크롤: 계속 스크롤 중...
0ms: 실행 ✅
100ms: 무시
200ms: 실행 ✅
300ms: 무시
400ms: 실행 ✅
결과: 200ms마다 실행
| 특징 | 디바운싱 | 쓰로틀링 |
|---|---|---|
| 실행 시점 | 이벤트 멈춘 후 | 이벤트 진행 중에도 |
| 실행 횟수 | 마지막 1번 | 주기적으로 여러 번 |
| 대기 방식 | 계속 리셋 | 고정 간격 |
| 용도 | 최종 상태 확인 | 진행 상태 추적 |
1. 검색 자동완성
// 타이핑이 멈출 때까지 기다림
검색창 입력 → 타이핑 중... → 멈춤 → API 호출
이유:
2. 폼 유효성 검사
// 입력이 끝날 때까지 기다림
이메일 입력 → 타이핑 중... → 멈춤 → 유효성 검사
이유:
3. 윈도우 리사이즈
// 리사이즈가 끝날 때까지 기다림
창 크기 조정 중... → 멈춤 → 레이아웃 재계산
이유:
1. 무한 스크롤
// 스크롤하는 동안 주기적으로 체크
스크롤 중... → 200ms마다 하단 도달 체크 → 데이터 로드
이유:
2. 마우스 이동 추적
// 마우스 이동 중 주기적으로 위치 저장
마우스 이동 중... → 100ms마다 좌표 기록
이유:
3. 버튼 연타 방지
// 일정 시간마다 한 번만 실행
버튼 클릭 → 실행 → 1초 동안 무시 → 다시 실행 가능
이유:
디바운싱을 선택:
쓰로틀링을 선택:
function debounce(func, delay) {
let timer;
return function(...args) {
// 이전 타이머 취소
clearTimeout(timer);
// 새 타이머 시작
timer = setTimeout(() => {
func(...args);
}, delay);
};
}
// 사용 예시
const search = debounce((query) => {
console.log('API 호출:', query);
}, 500);
search('R'); // 타이머 시작
search('Re'); // 이전 타이머 취소, 새 타이머 시작
search('Rea'); // 이전 타이머 취소, 새 타이머 시작
search('React'); // 이전 타이머 취소, 새 타이머 시작
// 500ms 후 → "API 호출: React" (한 번만!)
동작 흐름:
1. 첫 호출: timer 시작 (500ms)
2. 두 번째 호출: clearTimeout으로 이전 timer 취소 → 새 timer 시작
3. 세 번째 호출: clearTimeout으로 이전 timer 취소 → 새 timer 시작
4. 더 이상 호출 없음
5. 500ms 후: 함수 실행
function throttle(func, delay) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
// 마지막 호출 이후 delay 시간이 지났는지 확인
if (now - lastCall >= delay) {
lastCall = now;
func(...args);
}
};
}
// 사용 예시
const onScroll = throttle(() => {
console.log('스크롤 위치 체크');
}, 200);
window.addEventListener('scroll', onScroll);
// 0ms → 실행 ✅
// 100ms → 무시 (200ms 안됨)
// 200ms → 실행 ✅
// 300ms → 무시 (200ms 안됨)
// 400ms → 실행 ✅
동작 흐름:
1. 첫 호출 (0ms): lastCall = 0, now = 0
→ 조건 충족 (0 - 0 >= 200? No, 하지만 첫 호출이라 실행)
→ lastCall = 0
2. 두 번째 호출 (100ms): now = 100
→ 조건 불충족 (100 - 0 < 200)
→ 무시
3. 세 번째 호출 (200ms): now = 200
→ 조건 충족 (200 - 0 >= 200)
→ 실행, lastCall = 200
기본 쓰로틀링은 마지막 호출을 놓칠 수 있다:
0ms: 스크롤 → 실행 ✅
200ms: 스크롤 → 실행 ✅
400ms: 스크롤 → 실행 ✅
500ms: 스크롤 멈춤 → 마지막 위치가 실행 안됨 ❌
해결책:
function throttle(func, delay) {
let lastCall = 0;
let timer;
return function(...args) {
const now = Date.now();
// 즉시 실행 가능
if (now - lastCall >= delay) {
lastCall = now;
func(...args);
} else {
// 실행 못하는 경우 → 마지막을 위해 타이머 예약
clearTimeout(timer);
timer = setTimeout(() => {
lastCall = Date.now();
func(...args);
}, delay - (now - lastCall));
}
};
}
개선 효과:
0ms: 스크롤 → 즉시 실행 ✅
100ms: 스크롤 → 타이머 예약 (100ms 후)
200ms: 타이머 실행 ✅
350ms: 스크롤 → 타이머 재예약 (50ms 후)
400ms: 타이머 실행 ✅
500ms: 스크롤 멈춤
550ms: 타이머 실행 ✅ (마지막 보장!)
// ❌ 잘못된 구현
function SearchInput() {
const handleSearch = debounce((value) => {
console.log('API 호출:', value);
}, 500);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
문제:
debounce 함수 재생성사용자 'R' 입력
→ 렌더링 발생
→ debounce 함수 새로 생성 (timer 초기화)
사용자 'Re' 입력
→ 렌더링 발생
→ debounce 함수 또 새로 생성 (이전 timer 사라짐!)
// 결과: 디바운싱 안됨
// ✅ 올바른 구현
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedSearch = useCallback(
debounce((value) => {
console.log('API 호출:', value);
}, 500),
[] // 한 번만 생성
);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return <input value={query} onChange={handleChange} />;
}
동작:
debounce 함수는 컴포넌트 생명주기 동안 한 번만 생성// ❌ 오래된 값 사용
function SearchInput() {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState({ category: 'all' });
const debouncedSearch = useCallback(
debounce((value) => {
console.log('API 호출:', value, filters); // ← filters는 오래된 값!
}, 500),
[] // filters가 dependency에 없음
);
}
문제:
초기 렌더링: filters = { category: 'all' }
→ debouncedSearch 생성 (filters = 'all' 캡처)
사용자가 filters 변경: filters = { category: 'books' }
→ debouncedSearch는 그대로 (여전히 'all' 사용)
API 호출: 'all'로 검색됨 (잘못된 값!)
해결책: useRef 사용
// ✅ 최신 값 사용
function SearchInput() {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState({ category: 'all' });
// 최신 값을 ref에 저장
const filtersRef = useRef(filters);
filtersRef.current = filters; // 렌더링마다 업데이트
const debouncedSearch = useCallback(
debounce((value) => {
console.log('API 호출:', value, filtersRef.current); // 최신 값
}, 500),
[] // 한 번만 생성
);
}
동작:
1. debounce 함수는 한 번만 생성 (타이머 유지)
2. filtersRef.current는 렌더링마다 업데이트
3. API 호출 시 항상 최신 filters 사용
function useDebounce(callback, delay) {
const callbackRef = useRef(callback);
callbackRef.current = callback; // 항상 최신 callback
return useCallback(
debounce((...args) => {
callbackRef.current(...args); // 최신 callback 실행
}, delay),
[delay] // delay가 바뀌면 새로 생성
);
}
// 사용
function SearchInput() {
const [filters, setFilters] = useState({ category: 'all' });
const debouncedSearch = useDebounce((value) => {
console.log('API 호출:', value, filters); // filters는 최신값
}, 500);
return <input onChange={(e) => debouncedSearch(e.target.value)} />;
}
function useThrottle(callback, delay) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
return useCallback(
throttle((...args) => {
callbackRef.current(...args);
}, delay),
[delay]
);
}
// 사용: 무한 스크롤
function InfiniteScroll() {
const handleScroll = useThrottle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 100) {
console.log('하단 도달, 데이터 로드');
}
}, 200);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
}
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const searchAPI = async (searchQuery) => {
if (!searchQuery) {
setResults([]);
return;
}
setIsSearching(true);
try {
const response = await fetch(`/api/search?q=${searchQuery}`);
const data = await response.json();
setResults(data.results);
} catch (error) {
console.error('검색 실패:', error);
} finally {
setIsSearching(false);
}
};
const debouncedSearch = useDebounce(searchAPI, 500);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return (
<div>
<input
value={query}
onChange={handleChange}
placeholder="검색..."
/>
{isSearching && <LoadingSpinner />}
<ul>
{results.map(result => (
<li key={result.id}>{result.name}</li>
))}
</ul>
</div>
);
}
function InfiniteScrollList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const response = await fetch(`/api/items?page=${page}`);
const data = await response.json();
setItems(prev => [...prev, ...data.items]);
setPage(prev => prev + 1);
setHasMore(data.hasMore);
} catch (error) {
console.error('로드 실패:', error);
} finally {
setIsLoading(false);
}
};
const checkScrollPosition = useThrottle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
// 하단에서 100px 이내
if (scrollTop + clientHeight >= scrollHeight - 100) {
loadMore();
}
}, 200);
useEffect(() => {
window.addEventListener('scroll', checkScrollPosition);
return () => window.removeEventListener('scroll', checkScrollPosition);
}, [checkScrollPosition]);
return (
<div>
{items.map(item => (
<div key={item.id}>{item.name}</div>
))}
{isLoading && <LoadingSpinner />}
</div>
);
}
function ProductSearch() {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('all');
const [sortBy, setSortBy] = useState('popular');
const [results, setResults] = useState([]);
// 검색 함수
const search = useCallback(async (q, cat, sort) => {
const response = await fetch(
`/api/products?q=${q}&category=${cat}&sort=${sort}`
);
const data = await response.json();
setResults(data.results);
}, []);
// query가 바뀔 때만 debounce
const debouncedSearch = useDebounce((value) => {
search(value, category, sortBy);
}, 500);
// category나 sortBy가 바뀌면 즉시 검색
useEffect(() => {
if (query) {
search(query, category, sortBy);
}
}, [category, sortBy]);
const handleQueryChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return (
<div>
<input value={query} onChange={handleQueryChange} />
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">전체</option>
<option value="books">도서</option>
<option value="electronics">전자제품</option>
</select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="popular">인기순</option>
<option value="price">가격순</option>
<option value="recent">최신순</option>
</select>
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
상황:
사용자 'React' 타이핑
→ API 호출 시작 (3초 걸림)
사용자 'Vue'로 변경
→ API 호출 시작 (3초 걸림)
3초 후: 'React' 응답 도착 (오래된 응답)
3초 후: 'Vue' 응답 도착 (최신 응답)
// 만약 'React' 응답이 더 늦게 오면?
// 화면에 잘못된 검색 결과!
해결책: AbortController
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const abortControllerRef = useRef(null);
const searchAPI = async (searchQuery) => {
// 이전 요청 취소
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 새 요청
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const response = await fetch(`/api/search?q=${searchQuery}`, {
signal: controller.signal
});
const data = await response.json();
setResults(data.results);
} catch (error) {
if (error.name === 'AbortError') {
console.log('이전 요청 취소됨');
} else {
console.error('검색 실패:', error);
}
}
};
const debouncedSearch = useDebounce(searchAPI, 500);
}
상황:
사용자가 검색 입력
→ debounce 타이머 시작 (500ms)
→ 사용자가 페이지 이동 (컴포넌트 언마운트)
→ 500ms 후 타이머 실행
→ 언마운트된 컴포넌트 상태 업데이트 시도
→ 메모리 누수 경고
해결책: 클린업
function useDebounce(callback, delay) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
const timeoutRef = useRef(null);
// 클린업 함수
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback((...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
}
// ❌ 나쁜 UX
function SearchInput() {
const [isSearching, setIsSearching] = useState(false);
return (
<input
disabled={isSearching} // 검색 중 입력 불가
onChange={handleSearch}
/>
);
}
문제:
해결책: 로딩 인디케이터
// ✅ 좋은 UX
function SearchInput() {
const [isSearching, setIsSearching] = useState(false);
return (
<div>
<input
// disabled 하지 않음!
onChange={handleSearch}
/>
{isSearching && <LoadingSpinner />}
</div>
);
}
// 문제 상황
사용자 'React' 타이핑
→ 500ms 대기 중...
→ 사용자가 전체 삭제 (빈 문자열)
→ 500ms 후 'React'로 API 호출 (잘못된 요청)
해결책: 유효성 검사
const debouncedSearch = useDebounce((value) => {
if (!value || value.trim().length === 0) {
setResults([]);
return; // 빈 값이면 무시
}
fetch(`/api/search?q=${value}`).then(/* ... */);
}, 500);
Before (디바운싱 없음):
사용자 'React' 타이핑 (5글자)
→ API 호출 5번
→ 서버 부하 증가
→ 불필요한 네트워크 비용
After (디바운싱 적용):
사용자 'React' 타이핑 (5글자)
→ API 호출 1번
→ 80% 요청 감소
Before (쓰로틀링 없음):
스크롤 이벤트 (1초 동안)
→ 함수 호출 ~60회 (60fps)
→ 브라우저 버벅임
After (200ms 쓰로틀링):
스크롤 이벤트 (1초 동안)
→ 함수 호출 5회
→ 92% 실행 감소
특징:
사용:
구현:
function debounce(func, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => func(...args), delay);
};
}
특징:
사용:
구현:
function throttle(func, delay) {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func(...args);
}
};
}
핵심:
1. useCallback으로 함수 메모이제이션
2. useRef로 최신 값 참조
3. 클린업 함수로 메모리 누수 방지
4. AbortController로 Race Condition 해결
커스텀 Hook:
function useDebounce(callback, delay) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
return useCallback(
debounce((...args) => callbackRef.current(...args), delay),
[delay]
);
}
useCallback으로 함수 메모이제이션했는가?useRef로 최신 값 참조하는가?직접 구현하는 대신 검증된 라이브러리를 사용하는 것도 좋은 선택입니다.
특징:
설치:
npm install es-toolkit
# or
yarn add es-toolkit
기본 사용:
import { debounce } from 'es-toolkit/function';
const debouncedLog = debounce(() => {
console.log('실행됨');
}, 1000);
// 1초 안에 다시 호출되지 않으면 실행
debouncedLog();
// 대기 중인 실행을 취소
debouncedLog.cancel();
// 대기 중인 함수를 즉시 실행
debouncedLog.flush();
검색 예제:
import { debounce } from 'es-toolkit/function';
const searchInput = document.getElementById('search');
const searchResults = debounce(async (query) => {
const results = await fetchSearchResults(query);
displayResults(results);
}, 300);
searchInput.addEventListener('input', (e) => {
searchResults(e.target.value);
});
AbortSignal로 취소:
import { debounce } from 'es-toolkit/function';
const controller = new AbortController();
const debouncedFunc = debounce(
() => {
console.log('실행됨');
},
1000,
{ signal: controller.signal }
);
debouncedFunc();
// 취소
controller.abort();
leading/trailing 옵션:
import { debounce } from 'es-toolkit/function';
// leading: 첫 호출 시 즉시 실행
const leadingDebounce = debounce(
() => console.log('즉시 실행'),
1000,
{ edges: ['leading'] }
);
// trailing: 마지막 호출 후 실행 (기본값)
const trailingDebounce = debounce(
() => console.log('나중에 실행'),
1000,
{ edges: ['trailing'] }
);
// 양쪽 모두
const bothDebounce = debounce(
() => console.log('처음과 마지막에 실행'),
1000,
{ edges: ['leading', 'trailing'] }
);
기본 사용:
import { throttle } from 'es-toolkit/function';
// 1초마다 최대 한 번 실행
const throttledLog = throttle(() => {
console.log('실행됨');
}, 1000);
throttledLog(); // 즉시 실행
throttledLog(); // 무시됨
throttledLog(); // 무시됨
// 1초 후 마지막 호출이 trailing으로 실행됨
스크롤 최적화:
import { throttle } from 'es-toolkit/function';
const handleScroll = throttle(() => {
console.log('스크롤 위치:', window.scrollY);
}, 100); // 100ms마다 최대 한 번
window.addEventListener('scroll', handleScroll);
API 호출 최적화:
import { throttle } from 'es-toolkit/function';
const searchThrottled = throttle(async (query) => {
const results = await fetch(`/api/search?q=${query}`);
console.log('검색 결과:', await results.json());
}, 300);
// 입력할 때마다 호출해도 300ms마다만 실제 검색 실행
searchThrottled('hello');
searchThrottled('hello w');
searchThrottled('hello world');
leading/trailing 옵션:
import { throttle } from 'es-toolkit/function';
// leading만 (시작 시에만 실행)
const leadingOnly = throttle(
() => console.log('Leading only'),
1000,
{ edges: ['leading'] }
);
// trailing만 (끝날 때만 실행)
const trailingOnly = throttle(
() => console.log('Trailing only'),
1000,
{ edges: ['trailing'] }
);
leadingOnly(); // 즉시 실행
leadingOnly(); // 무시됨
leadingOnly(); // 무시됨
trailingOnly(); // 즉시 실행되지 않음
trailingOnly(); // 무시됨
trailingOnly(); // 1초 후 실행됨
수동 제어:
import { throttle } from 'es-toolkit/function';
const throttledFunc = throttle(() => console.log('실행됨'), 1000);
throttledFunc(); // 즉시 실행
throttledFunc(); // 대기 중
// 대기 중인 실행을 즉시 처리
throttledFunc.flush();
// 대기 중인 실행을 취소
throttledFunc.cancel();
검색 컴포넌트:
import { useState } from 'react';
import { debounce } from 'es-toolkit/function';
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// debounce는 한 번만 생성되도록 useMemo 사용
const debouncedSearch = useMemo(
() => debounce(async (searchQuery) => {
if (!searchQuery) {
setResults([]);
return;
}
const response = await fetch(`/api/search?q=${searchQuery}`);
const data = await response.json();
setResults(data.results);
}, 500),
[]
);
const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
// 클린업
useEffect(() => {
return () => {
debouncedSearch.cancel();
};
}, []);
return (
<div>
<input value={query} onChange={handleChange} />
<ul>
{results.map(result => (
<li key={result.id}>{result.name}</li>
))}
</ul>
</div>
);
}
무한 스크롤:
import { useEffect, useMemo } from 'react';
import { throttle } from 'es-toolkit/function';
function InfiniteScroll({ onLoadMore }) {
const throttledScroll = useMemo(
() => throttle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 100) {
onLoadMore();
}
}, 200),
[onLoadMore]
);
useEffect(() => {
window.addEventListener('scroll', throttledScroll);
return () => {
window.removeEventListener('scroll', throttledScroll);
throttledScroll.cancel();
};
}, [throttledScroll]);
return <div>스크롤 콘텐츠</div>;
}
es-toolkit 장점:
직접 구현 장점:
추천: