디바운싱과 쓰로틀링

Woody·2025년 11월 23일
0

개요

검색창에 "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개의 댓글