Tanstack Query 캐시 활용하기 (리액트 쿼리 QueryClient)
회사 프로젝트에서 리액트 쿼리(tanstack query)를 사용하고 있습니다. useMutation을 custom hook으로 감싸면서 좀 더 활용할 수 있는 방법을 찾아보다가 QueryClient를 사용해 쿼리 캐시를 활용할 수 있는 방법을 생각해보았습니다.
QueryClient
- Tanstack Query에서 제공하는 클라이언트 캐시에 접근할 수 있는 객체
- Query cache, mutation cache, logger 등과 연결되어있다.
용어
- staleTime: 데이터가
fresh
상태로 유지되는 시간.fresh
상태일 땐 쿼리 인스턴스가 새로 마운트되어도 fetch가 일어나지 않는다. - cacheTime:
inactive
상태인 캐시 데이터가 메모리에 남아있는 시간. 쿼리 인스턴스가 unmount 되면 데이터는inactive
상태로 변경되며, 캐시는cacheTime
만큼 유지된다.cacheTime
이 지나기 전에 쿼리 인스턴스가 다시 마운트 되면, 데이터를 fetch하는 동안 캐시 데이터를 보여준다. - fetching, fresh, stale, inactive, delete
주요 메서드
fetchQuery({ queryKey, queryFn })
: fetch하고 쿼리를 캐시한다. (비동기) 쿼리가 존재하는데 데이터가 유효하지 않거나 staleTime을 넘어섰을 경우 캐시 데이터를 반환한다. 그렇지 않은 경우엔 최신 데이터를 fetch한다.prefetchQuery({ queryKey, queryFn })
: 데이터가 필요하기 전에 쿼리를 미리 fetch한다.prefetchInfiniteQuery
도 있다.getQueryData(queryKey)
: 해당 쿼리의, 캐시되어있던 데이터를 가져온다. (동기)setQueryData(queryKey, updater)
: 동기적으로 캐시를 업데이트한다. cacheTime(기본값 5분) 이내에 쿼리를 사용하지 않을 경우 가비지컬렉트된다.invalidateQueries({queryKey...})
: 캐시된 쿼리를 무효화하고 다시 fetch한다.
기본 refetch 조건
query가 refetch 되는 기본 조건은 아래와 같습니다. 공통화해두면 좋을 것 같습니다.
refetchOnMount
: 쿼리 인스턴스가 마운트될 때refetchOnReconnect
: 네트워크가 다시 연결될 때refetchOnWindowFocus
: 윈도우 창이 다시 포커스될 때 (개발자도구 클릭 후 돌아오는 경우 등)refetchInterval
: 쿼리의 refetch Interval이 지나 refetch가 필요할 때
방법1: 리소스별 queryKey
쿼리 키는 캐시의 키다. 쿼리 키를 REST API처럼 리소스에 따라 나누면 한 캐시를 대상으로 CRUD 작업을 할 수 있다.
방법2: keepPreviousData
결과: 기존 데이터에서 새 데이터로 부드럽게 전환된다.
type Filter = '전체' | '쿠팡' | '아임웹';
const useProductsQuery = (filter: Filter) =>
useQuery({
queryKey: ['products', filter],
queryFn: () => fetchProducts(filter)
});
위 코드는 filter 값에 따라 상품 목록을 불러오는 코드다. queryKey는 useEffect의 의존성 배열과 같은 존재다. 그렇기에 filter 값이 바뀔 때마다 query Key가 변하고, query key가 변했으므로 쿼리가 refetch된다.
또 query key는 캐시의 키이므로, cache entry가 새로 생기고, 처음으로 필터를 전환할 경우 (로딩 스피너가 표시되는) 하드로딩 상태가 된다. 이때 keepPreviousData를 true로 설정하거나 initialData를 설정해두면 새 쿼리가 새 데이터를 요청하는 동안 가장 최근의 데이터를 활용할 수 있다.
export const useProductsQuery = (filter: Filter) =>
useQuery({
queryKey: ['products', filter],
queryFn: () => fetchProducts(filter),
initialData: () => {
// '전체' 상품의 데이터를 활용해 우선적인 데이터를 보여줄 수 있다.
const allProducts = queryClient.getQueryData<Products>(['products', '전체'])
const filteredData =
allProducts?.filter((product) => product.type === filter) ?? []
return filteredData.length > 0 ? filteredData : undefined
},
})
이후 새 데이터가 도착하면 기존의 데이터가 부드럽게 새 데이터로 스왑된다. 비슷하게 Tab에서도 각 탭의 query key를 다르게 해 사용할 수 있다. 다만 initialData를 쓸 경우 각 페이지에서 개발자가 추가해줘야하는 코드가 늘어날 수 있고, 만약 기존 데이터 중 filter에 걸리는 데이터가 없다면 보여줄 데이터가 없을 수도 있다. 이런 경우엔 전체 데이터를 보여줘야할 것이다.
방법3: Smart refetching
결과: mutation이 성공할 때마다 매번 refetch 되는 것이 아니라, 데이터가 필요할 때 fresh하게 불러오도록 한다.
상품 목록에서 한 가지 상품을 골라 삭제를 할 수 있다. 상품 삭제 모달에서 confirm을 하면 상품 목록이 리렌더되며 해당 상품이 제외된 상품 목록이 표시되어야한다.
이렇게 CRUD 후 바로 적용된 데이터를 보여줘야 할 때, mutation success callback에서 관련된 쿼리의 캐시를 무효화시킨다. 데이터를 invalid시킴으로써 React query가 해당 쿼리를 refetch를 하도록 만드는 것이다.
// 상품을 삭제한다.
const deleteProduct = (productId) => {
return axios.delete('/product', productId);
};
const { mutate: deleteProduct } = useMutation(deleteProduct, {
onSuccess: () => {
// 방법1. queryKey가 'products'로 시작하는 모든 쿼리 무효화
queryClient.invalidateQueries('products');
// 방법2. productId에 따라 해당 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['products', productId] }
}
});
onSuccess
안에서 invalidateQueries
로 쿼리를 무효화시켜 refetch하도록 하는 것과 refetch()
를 직접 호출하는 것에는 차이가 있다.
직접 refetch를 할 경우 요청을 새로 보내서 데이터를 새로 가져오게 된다.
해당 쿼리를 보고 있는 observer가 없어도 무조건 다시 가져온다.
반면 invalidateQueries를 사용하여 데이터를 무효화하는 경우 데이터는 stale
상태로 변했다가
이후 query의 refetch 조건에 부합할 때에 fetch하게 된다. (useQuery 나 관련 훅을 통해 쿼리가 렌더링되고 있다면 백그라운드에서도 refetch한다.)
refetch를 하면 query key(예를 들면 무한스크롤 컴포넌트의 page)를 기준으로 inactive한 모든 페이지의 데이터를 불러오지만
쿼리 무효화를 하면 우선 query를 stale
하게만 만들기 때문에 active
한 페이지의 데이터만 fetch한다. 이러한 이유로
쿼리 무효화를 하는 게 미세하게 나은 성능을 가질 수 있다.
setQueryData
를 통해 optimistic update를 하는 방법도 있지만,
변화를 우선적으로 적용한 뒤 성공 여부에 따라 값을 다시 바꿔야하는 optimistic update보다는 무효화를 하는게
각 쿼리에서 개발자가 추가해야할 코드의 양은 적어지는 것 같다.