오늘은 코드를 구현해보면서 프론트엔드에서 검색 성능을 최적화할 수 있는 방법에 대해 공유해보려고 합니다.
예를 들어, 유저가 스타벅스를 검색한다고 해볼게요.
<input/>의 onChange에 이벤트를 걸어놓고 API 요청을 하게 되면
'ㅅ', '스', '스ㅌ', '스타'......'스타벅스' 처럼 유저가 한글자씩 입력할 때마다 네트워크 요청을 하게 돼요.
만약 '스타' 에 대한 응답을 '스타벅스' 에 대한 응답보다 먼저 받게 된다면?
유저는 잘못된 데이터를 볼 수 있습니다.
요약
1.useQuery의 signal 이용하기 (AbortController)
2.useDebounce 훅으로 마지막 요청만 전달하기
이런 오류를 방지하기 위한 방법으로 AbortController를 이용할 수 있습니다. AbortController는 이전에 보낸 요청을 추적해 취소할 수 있게 해줍니다. 대부분의 브라우저가 지원하지만, 아직 미지원 브라우저도 있어서 experimental입니다.
자세한 내용은 MDN 문서를 참조해주세요!
https://developer.mozilla.org/ko/docs/Web/API/AbortController
Tanstack Query의 useQuery는 abort 기능을 내부적으로 제공합니다.
useQuery에서 signal을 넘기지 않는다면, 유저가 화면에 나갔다 들어왔을 때 unmount 이전의 요청에 의해 응답 받은 데이터가 메모리 상에서 가비지 콜렉션되지 않았다면 바로 데이터를 볼 수 있지만, signal을 옵션으로 설정하면 아예 이전의 요청을 끊습니다.
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됩니다. 결국 마지막에 요청한 쿼리의 응답만 안전하게 받을 수 있죠.
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를 호출하지만, 목 데이터를 세팅한 뒤 구현해보았습니다. 함수명은 임의로 지었습니다.
저는 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);
},
};
저는 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은 얼마나 주면 좋을까? 등 이것저것 생각해볼 수 있어서 좋은 경험이었습니다.
긴 글 읽어주셔서 감사합니다!