React Query 고급 활용법 효율적인 데이터 관리 전략

202308월 08



1. 구현 배경, 주요 목표 및 활용된 라이브러리 버전 안내


구현 배경


현재 참여 중인 팀 프로젝트에서 react-query를 사용하고 있습니다. 해당 라이브러리를 공부해 보면서 느낀점은 react-query를 사용해 요청을 보낼 때마다 데이터를 캐싱하는 코드를 작성해주어야 했다는 점입니다.

좀 더 좋은 활용법이 없을까 고민하던 중 github에서 외국 개발자분께서 작성하신 react-query example을 보고 이렇게 사용하면 좀 더 쉽고 통일된 캐싱 과정을 거칠 수 있다는 점을 알게 되어서 구현 하게 되었습니다.

🔗 horprogs의 reactQuery.ts


주요 목표


주요 목표는 react-query의 요청을 보낼 때 한번 저희가 작성한 custom hook을 거치는데 해당 hook에서는 공통적인 로직이 적용됩니다.

예를 들어서 get 요청 시에 querykey에 [url!, params] 이런 형식으로 저장이 됩니다.

useQuery를 사용하는 요청인 usePrefetch custom hook을 사용할 때는 queryClient.prefetchQuery를 실행해 미리 받아오며 useFetch custom hook을 사용할 때는 enabled 속성을 부여해 url이 존재할 때만 요청이 이루어집니다.

useMutation를 사용하는 요청들은 useGenericMutation custom hook을 거치고 onMutate 로직이 실행되는데 낙관적인 방법을 사용해 실제로 요청이 전달되어서 다시 받아오기 전에 미리 화면에서는 받아온 것처럼 보여주는 역할을 도와줍니다.


활용된 라이브러리 버전


이 글에서 활용한 버전

"axios": "^0.21.1",
"react-query": "^3.25.1",

실제로 제 프로젝트에서 활용한 버전

"axios": "^1.4.0",
"@tanstack/react-query": "^4.29.11",

이 글에서는 외국 개발자분께서 작성하신 코드를 기반으로 설명해 드릴 예정이며 전체적으로 예전 버전이 활용되어있습니다.

실제로 제 프로젝트에서는 좀 더 최신버전의 라이브러리들이 적용되었으며 이 글에서는 설명해 드리지 않습니다. 또한 여러 기능이 더 추가되었으니 이 글을 모두 읽으신 다음에 밑의 링크를 보시는 것을 추천해 드립니다.

🔗 JJAN 프로젝트 ReactQueryManager.ts


2. useQuery : usePrefetch, useFetch


usePrefetch


usePrefetch는 내부적으로 queryClient.prefetchQuery를 실행합니다.

queryClient.prefetchQuery는 React Query의 QueryClient 객체에 포함된 메서드로, 주어진 쿼리 키와 쿼리 함수를 이용해 데이터를 미리 가져오는 (프리패치하는) 역할을 합니다.

일반적으로 웹 애플리케이션에서 사용자의 특정 행동을 예상하고 그에 따른 데이터를 미리 불러와서 캐시에 저장하고 싶을 때 prefetchQuery를 사용합니다. 예를 들어, 사용자가 특정 버튼을 클릭하여 다음 페이지로 이동할 것이라 예상되면 그 다음 페이지에 필요한 데이터를 미리 불러와 빠르게 화면을 구성할 수 있게 합니다.

prefetchQuery를 사용하면 사용자 경험이 향상되며, 웹 애플리케이션의 반응성이 더 좋아질 수 있습니다. 이는 사용자가 실제로 해당 데이터를 요청하기 전에 이미 데이터를 미리 불러와 캐시에 저장해두기 때문입니다.


type QueryKeyT = [string, object | undefined];

export const usePrefetch = <T>(url: string | null, params?: object) => {
  const queryClient = useQueryClient();

  return () => {
    if (!url) {
      return;
    }

    queryClient.prefetchQuery<T, Error, T, QueryKeyT>(
      [url!, params],
      ({ queryKey }) => fetcher({ queryKey })
    );
  };
};

const prefetch = usePrefetch("get 요청을 보낼 url");

prefetchQuery에는 useQuery의 enabled이 없기 때문에 url이 없으면 return 하는 코드도 함께 작성되었습니다.

prefetchQuery의 첫 번쨰 매개변수에 queryKey를 [url!, params]를 설정해 해당 요청에 대한 queryKey를 설정합니다. 또한 두 번째 매개변수에 함수를 넘겨주는데 fetcher는 실제로 api 요청이 일어나는 axios 코드입니다. fetcher에 매개변수로 queryKey를 같이 넘겨줍니다.


export const fetcher = <T>({
  queryKey,
  pageParam,
}: QueryFunctionContext<QueryKeyT>): Promise<T> => {
  const [url, params] = queryKey;
  return api
    .get<T>(url, { params: { ...params, pageParam } })
    .then((res) => res.data);
};

queryKey 매개변수를 분리해 실제 요청을 보낼 url과 params 옵션을 같이 줍니다. 여기서 pageParam는 이 글에서는 다루지 않는 useLoadMore을 사용할떄 필요한 값으로써 여기서는 넘어가겠습니다.

또한 api.get은 axios 모듈입니다. 대충 이런 느낌으로 자세한 설명은 생략하겠습니다.

export const api = {
  get: <T>(url: string, params?: object) =>
    axios.get<T>(url, {
      headers: {
        token: Cookies.get('token'),
      },
      ...params,
    }),
    ..
    ..
}

useFetch


useFetch는 useQuery를 사용하는 훅으로 usePrefetch와 비슷하지만 좀더 다릅니다.

export const useFetch = <T>(
  url: string | null,
  params?: object,
  config?: UseQueryOptions<T, Error, T, QueryKeyT>
) => {
  const context = useQuery<T, Error, T, QueryKeyT>(
    [url!, params],
    ({ queryKey }) => fetcher({ queryKey }),
    {
      enabled: !!url,
      ...config,
    }
  );

  return context;
};

const context = useFetch("요청을 보낼 url", undefined, { retry: false });

useQuery의 세 번쨰 매개변수인 config를 설정할 수 있는데 enabled를 설정해 url이 존재할 경우에만 요청을 수행합니다. 또한 config를 받아서 자유자재로 옵션을 설정할수있습니다.

위 코드에서는 axios에 넘겨줄 paramse를 undefined로 react-query의 config로 retry를 false로 설정함으로 요청을 딱 한 번만 수행합니다.


3. useMutation : useGenericMutation


useGenericMutation는 모든 post, delete, patch 요청을 수행하는 useMutation를 updater 함수를 사용해 실제 요청이 이루어지기 전 낙관적으로 데이터를 보여줍니다.


const useGenericMutation = <T, S>(
  func: (data: T | S) => Promise<AxiosResponse<S>>,
  url: string,
  params?: object,
  updater?: ((oldData: T, newData: S) => T) | undefined
) => {
  // 실제로 동작하는 로직 현재는 생략
};

const delete = useGenericMutation(
  (id) => api.delete(`${url}/${id}`),
  url,
  undefined,
  (oldData, id) => oldData.filter((item) => item.id !== id)
);

useGenericMutation는 4개의 props를 받습니다. 그중 2개를 살펴보겠습니다.

func : 실제로 API 요청을 수행하는 코드

updater : 기존의 queryKey에 저장된 데이터를 요청을 보내기 전 미리 수정 해주는 함수 (oldData, id) => oldData.filter((item) => item.id !== id) oldData 배열에서 해당하는 id와 같은 item을 삭제해주는 코드입니다.

이제 useGenericMutation 내부 로직을 좀 더 살펴보겠습니다.

const useGenericMutation = <T, S>(
  func: (data: T | S) => Promise<AxiosResponse<S>>,
  url: string,
  params?: object,
  updater?: ((oldData: T, newData: S) => T) | undefined
) => {
  const queryClient = useQueryClient();

  return useMutation<AxiosResponse, AxiosError, T | S>(func, {
    onMutate: async (data) => {
      await queryClient.cancelQueries([url!, params]);

      const previousData = queryClient.getQueryData([url!, params]);

      queryClient.setQueryData<T>([url!, params], (oldData) => {
        return updater ? updater(oldData!, data as S) : (data as T);
      });

      return previousData;
    },
    onError: (err, _, context) => {
      queryClient.setQueryData([url!, params], context);
    },
    onSettled: () => {
      queryClient.invalidateQueries([url!, params]);
    },
  });
};

요청이 수행되기 전에 onMutate가 실행이 됩니다. onMutate의 실행 순서 👇

1 . queryKey로 진행 중인 쿼리를 취소합니다.

2 . 기존에 저장되어있는 데이터를 previousData에 저장합니다.

3 . 기존에 저장된 데이터를 updater를 사용해서 데이터를 미리 업데이트합니다. 만약 여기서 문제가 발생하면 previousData를 return 합니다.

만약 요청이 실패할 경우 onError가 실행되며 세 번쨰 context가 위에서 return 한 previousData입니다. 즉 기존 데이터로 다시 저장하는 과정을 거칩니다.

모든 요청이 끝났을 경우 onSettled를 실행하며 해당 쿼리에 대한 데이터를 무효화합니다. 무효가 된 쿼리는 다음에 해당 데이터를 사용할 때 최신 데이터를 서버로부터 다시 가져오게 됩니다.


4. 여담


이 글에서는 작성되지 않았지만 useLoadMore custom hook이 존재하며 해당 hook은 페이지 네이션 및 무한 스크롤을 지원해줍니다. 또한 react-query에 대한 여러 example를 공유합니다.

현재 제 프로젝트에는 여러 기능이 추가되었는데 무조건 url, params로 queryKey를 저장하는 방식에서 customKey props를 넘겨주는 해당 변수로 queryKey를 저장합니다. 그리고 @tanstack/eslint-plugin-query를 적용해 좀 더 명확한 코드로 작성되었습니다. useMutation의 config를 넘겨줄 수도 있고요.


참고 문서

https://github.com/horprogs/react-query