디바운싱과 쓰로틀링

Woody·2025년 11월 23일

개요

검색창에 "React"를 타이핑할 때마다 API를 호출한다면? 5글자 입력에 5번의 불필요한 요청이 발생한다. 스크롤할 때마다 무한 스크롤 체크를 한다면? 초당 수백 번의 함수 실행으로 브라우저가 멈춘다.

이 문서는 다음 질문에 답한다:

  • 디바운싱과 쓰로틀링은 무엇이고 어떻게 다른가?
  • 언제 디바운싱을 쓰고, 언제 쓰로틀링을 쓰는가?
  • React에서 어떻게 올바르게 구현하는가?
  • 실전에서 마주치는 문제들은 어떻게 해결하는가?

이 문서를 읽고 나면:

  • 디바운싱과 쓰로틀링의 차이를 명확히 이해할 수 있다
  • 상황에 맞는 최적화 기법을 선택할 수 있다
  • React에서 안전하게 구현할 수 있다
  • Race Condition 같은 실전 문제를 해결할 수 있다
  • es-toolkit 같은 라이브러리를 효과적으로 활용할 수 있다

1. 디바운싱과 쓰로틀링이란?

디바운싱 (Debouncing)

정의: 연속된 이벤트 중 마지막 이벤트만 처리한다.

동작 방식:

  • 이벤트가 발생하면 타이머 시작
  • 설정된 시간(예: 500ms) 내에 또 이벤트 발생 시 타이머 리셋
  • 타이머가 완료되면 함수 실행

비유: 엘리베이터 문

사람이 타려고 하면 문이 닫히려다가 다시 열림
더 이상 사람이 안 오면 (일정 시간 후) 문이 닫힘

코드 예시:

사용자 타이핑: R → Re → Rea → Reac → React

타이머: 시작 → 리셋 → 리셋 → 리셋 → 리셋
        (500ms 대기)API 호출 "React" ✅

결과: 1번의 API 호출

쓰로틀링 (Throttling)

정의: 일정 시간 간격으로 주기적으로 실행한다.

동작 방식:

  • 첫 이벤트 즉시 실행
  • 설정된 시간(예: 200ms) 동안 추가 호출 무시
  • 시간이 지나면 다시 실행 가능

비유: 지하철 문

일정 시간(예: 1분)마다 문을 열고 닫음
중간에 아무리 버튼을 눌러도 시간이 되기 전엔 열리지 않음

코드 예시:

사용자 스크롤: 계속 스크롤 중...

0ms:   실행 ✅
100ms: 무시
200ms: 실행 ✅
300ms: 무시
400ms: 실행 ✅

결과: 200ms마다 실행

핵심 차이

특징디바운싱쓰로틀링
실행 시점이벤트 멈춘 후이벤트 진행 중에도
실행 횟수마지막 1번주기적으로 여러 번
대기 방식계속 리셋고정 간격
용도최종 상태 확인진행 상태 추적

2. 언제 사용하는가?

디바운싱 사용 케이스

1. 검색 자동완성

// 타이핑이 멈출 때까지 기다림
검색창 입력 → 타이핑 중... → 멈춤 → API 호출

이유:

  • 중간 검색어("Re", "Rea")는 불필요
  • 완성된 검색어("React")만 필요

2. 폼 유효성 검사

// 입력이 끝날 때까지 기다림
이메일 입력 → 타이핑 중... → 멈춤 → 유효성 검사

이유:

  • 타이핑 중 매번 검사하면 성가심
  • 입력 완료 후 검사가 자연스러움

3. 윈도우 리사이즈

// 리사이즈가 끝날 때까지 기다림
창 크기 조정 중... → 멈춤 → 레이아웃 재계산

이유:

  • 조정 중 매번 계산하면 버벅임
  • 최종 크기에만 반응하면 됨

쓰로틀링 사용 케이스

1. 무한 스크롤

// 스크롤하는 동안 주기적으로 체크
스크롤 중...200ms마다 하단 도달 체크 → 데이터 로드

이유:

  • 스크롤 중에도 데이터 로드 필요
  • 하단 근처에서 미리 로드해야 자연스러움

2. 마우스 이동 추적

// 마우스 이동 중 주기적으로 위치 저장
마우스 이동 중...100ms마다 좌표 기록

이유:

  • 이동 경로를 추적해야 함
  • 너무 자주 기록하면 성능 저하

3. 버튼 연타 방지

// 일정 시간마다 한 번만 실행
버튼 클릭 → 실행 → 1초 동안 무시 → 다시 실행 가능

이유:

  • 중복 제출 방지
  • 서버 부하 감소

선택 기준

디바운싱을 선택:

  • ✅ 최종 결과만 필요할 때
  • ✅ 사용자 액션이 완료되기를 기다려도 될 때
  • ✅ 예: 검색, 유효성 검사, 자동 저장

쓰로틀링을 선택:

  • ✅ 진행 중 상태를 추적해야 할 때
  • ✅ 일정 간격으로 계속 반응해야 할 때
  • ✅ 예: 스크롤, 리사이즈, 드래그, 마우스 이동

3. 기본 구현

디바운싱 구현

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: 타이머 실행 ✅ (마지막 보장!)

4. React에서 사용하기

문제: 렌더링마다 함수 재생성

// ❌ 잘못된 구현
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 사라짐!)

// 결과: 디바운싱 안됨

해결책 1: useCallback

// ✅ 올바른 구현
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 함수는 컴포넌트 생명주기 동안 한 번만 생성
  • 타이머가 유지됨
  • 디바운싱 정상 작동

문제 2: 최신 상태 참조

// ❌ 오래된 값 사용
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 사용

커스텀 Hook: useDebounce

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)} />;
}

커스텀 Hook: useThrottle

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]);
}

5. 실전 예제

예제 1: 검색 자동완성

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>
  );
}

예제 2: 무한 스크롤

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>
  );
}

예제 3: 검색 + 필터 + 정렬

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>
  );
}

6. 주의사항과 해결책

문제 1: Race Condition

상황:

사용자 '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);
}

문제 2: 컴포넌트 언마운트 시 타이머

상황:

사용자가 검색 입력
→ 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]);
}

문제 3: input disabled 안티패턴

// ❌ 나쁜 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>
  );
}

문제 4: 빈 값 처리

// 문제 상황
사용자 'React' 타이핑
→ 500ms 대기 중...
→ 사용자가 전체 삭제 (빈 문자열)500ms 후 'React'API 호출 (잘못된 요청)

해결책: 유효성 검사

const debouncedSearch = useDebounce((value) => {
  if (!value || value.trim().length === 0) {
    setResults([]);
    return; // 빈 값이면 무시
  }

  fetch(`/api/search?q=${value}`).then(/* ... */);
}, 500);

7. 성능 비교

디바운싱 효과

Before (디바운싱 없음):

사용자 'React' 타이핑 (5글자)
→ API 호출 5번
→ 서버 부하 증가
→ 불필요한 네트워크 비용

After (디바운싱 적용):

사용자 'React' 타이핑 (5글자)
→ API 호출 1번
→ 80% 요청 감소

쓰로틀링 효과

Before (쓰로틀링 없음):

스크롤 이벤트 (1초 동안)
→ 함수 호출 ~60회 (60fps)
→ 브라우저 버벅임

After (200ms 쓰로틀링):

스크롤 이벤트 (1초 동안)
→ 함수 호출 5회
→ 92% 실행 감소

8. 핵심 개념 요약

디바운싱

특징:

  • 마지막 이벤트만 처리
  • 이벤트가 멈출 때까지 대기
  • 타이머를 계속 리셋

사용:

  • 검색 자동완성
  • 폼 유효성 검사
  • 자동 저장
  • 윈도우 리사이즈

구현:

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);
    }
  };
}

React에서 안전하게 사용

핵심:
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]
  );
}

9. 체크리스트

디바운싱 적용 전 확인

  • 최종 결과만 필요한가?
  • 중간 상태는 불필요한가?
  • 사용자가 기다려도 되는가?
  • 예: 검색, 유효성 검사, 자동 저장

쓰로틀링 적용 전 확인

  • 진행 중 상태를 추적해야 하는가?
  • 주기적으로 반응해야 하는가?
  • 즉각적인 피드백이 필요한가?
  • 예: 스크롤, 마우스 이동, 드래그

React 구현 시 확인

  • useCallback으로 함수 메모이제이션했는가?
  • useRef로 최신 값 참조하는가?
  • 클린업 함수를 작성했는가?
  • Race Condition을 고려했는가?
  • 빈 값 처리를 했는가?

10. 실전 라이브러리: es-toolkit

직접 구현하는 대신 검증된 라이브러리를 사용하는 것도 좋은 선택입니다.

es-toolkit이란?

특징:

  • 현대적인 TypeScript 라이브러리
  • Lodash보다 가볍고 빠름
  • Tree-shaking 지원
  • AbortSignal 지원

설치:

npm install es-toolkit
# or
yarn add es-toolkit

debounce 사용법

기본 사용:

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'] }
);

throttle 사용법

기본 사용:

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();

React에서 es-toolkit 사용

검색 컴포넌트:

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 vs 직접 구현

es-toolkit 장점:

  • ✅ 검증된 구현
  • ✅ TypeScript 완벽 지원
  • ✅ AbortSignal 지원
  • ✅ leading/trailing 옵션
  • ✅ flush/cancel 메서드
  • ✅ 가볍고 빠름

직접 구현 장점:

  • ✅ 의존성 없음
  • ✅ 정확히 원하는 동작
  • ✅ 번들 크기 최소화
  • ✅ 학습 목적

추천:

  • 프로덕션: es-toolkit 또는 lodash 사용
  • 학습/간단한 경우: 직접 구현
  • 복잡한 요구사항: 라이브러리 + 커스터마이징

참고 자료

profile
프론트엔드 개발자로 살아가기

0개의 댓글