HomeFeedAbout

useDebounce와 useQuery로 검색 요청 최적화하기

김성현 1일 전
Web PerformanceuseDebounceusequery

오늘은 코드를 구현해보면서 프론트엔드에서 검색 성능을 최적화할 수 있는 방법에 대해 공유해보려고 합니다.

🚨 문제 상황

예를 들어, 유저가 스타벅스를 검색한다고 해볼게요.

<input/>의 onChange에 이벤트를 걸어놓고 API 요청을 하게 되면
'ㅅ', '스', '스ㅌ', '스타'......'스타벅스' 처럼 유저가 한글자씩 입력할 때마다 네트워크 요청을 하게 돼요.

만약 '스타' 에 대한 응답을 '스타벅스' 에 대한 응답보다 먼저 받게 된다면?
유저는 잘못된 데이터를 볼 수 있습니다.

🔎 정확한 데이터를 보여줄 수 있을까?

요약
1.useQuery의 signal 이용하기 (AbortController)
2.useDebounce 훅으로 마지막 요청만 전달하기

1. useQuery의 signal 이용하기

이런 오류를 방지하기 위한 방법으로 AbortController를 이용할 수 있습니다. AbortController는 이전에 보낸 요청을 추적해 취소할 수 있게 해줍니다. 대부분의 브라우저가 지원하지만, 아직 미지원 브라우저도 있어서 experimental입니다.

자세한 내용은 MDN 문서를 참조해주세요!
https://developer.mozilla.org/ko/docs/Web/API/AbortController

Tanstack Query의 useQuery는 abort 기능을 내부적으로 제공합니다.
useQuery에서 signal을 넘기지 않는다면, 유저가 화면에 나갔다 들어왔을 때 unmount 이전의 요청에 의해 응답 받은 데이터가 메모리 상에서 가비지 콜렉션되지 않았다면 바로 데이터를 볼 수 있지만, signal을 옵션으로 설정하면 아예 이전의 요청을 끊습니다.

Query cancellation

export function useSearch(query: string) {
  return useQuery({
    queryKey: ['search', query],
    queryFn: async ({ signal }) => {
      const response = await fetch(`/api/search?q=${query}`, { signal }); // 이렇게 넘겨주면
      return response.json();
    },
  });
}

이렇게 fetch나 axios의 옵션에 signal을 넘겨주면 queryKey에 설정해놓은 query가 바뀌는 순간 abort가 실행돼서 이전 요청이 abort됩니다. 결국 마지막에 요청한 쿼리의 응답만 안전하게 받을 수 있죠.

2. useDebounce 훅으로 마지막 요청만 전달하기

debounce를 활용하면 중간 중간의 요청까지도 없앨 수 있습니다. 유저의 마지막 입력이 끝난 후 300ms 정도를 기다린 뒤, 입력이 없다면 그제서야 요청을 보냅니다. setTimeout을 이용해 구현할 수 있어요.

import { useEffect, useState } from 'react';

export function useDebounce<T>(value: T, delay: number = 300) {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    if (delay === 0) {
      setDebouncedValue(value); // delay가 0이라면 바로 return
      return;
    }

    const timeoutId = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timeoutId);
    };
  }, [value, delay]);

  return debouncedValue;
}

이때, useEffect의 return 부에서는 setTimeout을 초기화해줍니다.
useEffect의 return은 언마운트 시 뿐만 아니라, 의존성 배열의 원소 값이 변할 때도 항상 실행됩니다.
따라서 유저의 입력이 들어오면 이전에 예약했던 타이머가 취소되고 새로운 타이머가 할당됩니다.

제가 처음 C-Lab 스타트업에 입사했을 때 사수분이 항상 강조하시던 것 중 하나가 removeEventHandler와 addEventHandler를 같이 써야 한다는 것이었는데, 기억이 나네요 ㅋㅋ

✏️ 코드로 구현해보기

API

실제로는 필터 로직 대신 백엔드의 API를 호출하지만, 목 데이터를 세팅한 뒤 구현해보았습니다. 함수명은 임의로 지었습니다.
저는 useDebounce와 useQuery를 조합하는 방식을 선택했습니다.

export const ABCApi = {
  search: async (query: string): Promise<Product[]> => {
    if (!query.trim()) { // 입력 텍스트가 없다면 전체 데이터를 보여줌
      return Promise.resolve(products);
    }

    const filtered = products.filter((item) =>
      item.title.toLowerCase().includes(query.toLowerCase()),
    );
    return Promise.resolve(filtered);
  },
};

HOOKS

저는 2가지 검색 Filter를 구현해야 했기 때문에 queryKey의 계층을 분리하기 위해 useABCSearch, useDEFSearch 같이 2개의 훅으로 분리했어요. 유저가 다시 검색하는 경우를 대비해 staleTime은 3분으로 설정해줬고, gcTime은 기본값인 5분을 적용했습니다.

import { keepPreviousData, useQuery } from '@tanstack/react-query';

export function useABCSearch(query: string) {
  return useQuery({
    queryKey: ABCkeys.search(query),
    queryFn: () => ABCApi.search(query),
    placeholderData: keepPreviousData, // 데이터 교체 시 공백을 이전 데이터로 채워줌
	staleTime: 1000 * 60 * 3,
  });
}

debounce에 관한 토론들
https://github.com/TanStack/query/issues/293#issuecomment-1942398332

훅 사용 예제

검색 창 Input은 useState로 즉시 업데이트하고, 각 도메인 컴포넌트에는 debouncedSearchQuery를 넘겨줍니다.

'use client';

export default function Page() {
  const [searchQuery, setSearchQuery] = useState('');
  const debouncedSearchQuery = useDebounce(searchQuery, 300);

  return (
    <main className={main}>
      <VStack> // SwiftUI 스타일 Flexbox를 만들어 사용 중임니다,,ㅎ
        <Searchbar
          placeholder="브랜드, 카테고리, 키워드 검색하기"
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
        />
        // ...

        {showABC && <ABCList query={debouncedSearchQuery} />}
      </VStack>
      // ...
    </main>
  );
}

이제 도메인 컴포넌트에서 해당 훅을 호출해 결과를 렌더링합니다.

export function ABCList({ query }: { query: string }) {
  const { data: products } = useABCSearch(query);
  
  // ...
  return (
    <div>UI</div>
  );
}

📝 마치며

debounce에 대해 고민해보면서 useQuery의 캐싱, staleTime은 얼마나 주면 좋을까? 등 이것저것 생각해볼 수 있어서 좋은 경험이었습니다.

긴 글 읽어주셔서 감사합니다!