[🚨응급실 알리미]React Query와 Router loader 함께 쓰기

    응급실 알리미의 구조 설계 초기 때 Router의 loader를 사용하여 미리 데이터를 받아와 컴포넌트를 렌더링 하려고 하였습니다. 하지만 loader함수는 컴포넌트가 아니기 때문에 데이터를 가져오기 위해 useQuery훅이나 recoil의 상태 관리 훅을 사용할 수 없었습니다.  해결책을 찾지 못하고 차선책으로 API를 사용하는 공통 컴포넌트에서 React Query로 데이터를 가져와 rocoil로 관리하기로 하였습니다. 프로젝트 완성 후 리팩토링 과정에서 React Query와 Router loader를 연결할 수 있는 방법을 찾았고 그 과정을 정리한 글입니다

    현재 상황

    제일 최상위인 app 컴포넌트에서 모든 페이지에 사용하는 공통 API를 호출하여 Recoil 전역 변수에 업데이트합니다. 또 selector로 사용할 데이터를 가공하여 컴포넌트에서 구독하여 데이터를 사용하고 있습니다.

    응급실 알리미 router 구조

    React Query와 Router는 환상의 짝꿍?!

    They are a match made in heaven

    React Query의 메인테이너인 Tkdodo의 블로그엔 React Query와 Router는 환상의 짝꿍이라고 적혀있습니다.

    (하지만 이것을 프로젝트에 적용시킬땐 환장의 짝꿍처럼...느껴졌습니다 ㅎㅎ)

    그 이유는 무엇일까요?
    일단, react router의 loader는 router의 각 경로마다 정의할 수 있습니다. 이 loader는 경로를 방문할 때 호출됩니다. 이 loader에서 데이터를 미리 가져오니 사용자는 페이지가 로드되는 동안 데이터를 기다릴 필요가 없습니다. 하지만 문제점이 있습니다. 

     

    예를 들어 A병원 디테일 페이지에서 B병원 디테일 페이지로 이동한 다음 다시 A병원 디테일 페이지로 이동할 때 이 문제점이 확 느껴집니다.

     

    로더는 한 번 요청한 데이터를 다시 사용할 수 있도록 저장하는 기능이 없습니다. 따라서 A병원 디테일 페이지에서 B병원 디테일 페이지로 이동한 후 다시 A병원 디테일 페이지로 이동하면, 로더는 A병원의 데이터를 다시 요청하게 됩니다.

     

    하지만 React Query의 가장 큰 특징인 데이터 캐싱을 효율적으로 관리한다는 점이 있죠. Tkdodo는 이 점으로 loader의 약점을 react query로 보완하는 것을 제안했습니다.
    loader에서 React Query cache에 데이터를 호출해 넣어놓고 컴포넌트에선 useQuery를 사용하여 React Query의 장점을 활용하는 방법입니다.

    라우터는 데이터를 일찍 가져오는 역할을 하고(없는 경우) - when(fetch)
    React Query는 데이터를 최신 상태로 캐싱하고 유지하는 역할을 합니다. -what(data)


    그래서 어떻게 적용하나요?

    1. queryClient 넘겨주기

     

    저는 router함수를 따로 파일 분리했기때문에, router함수에 queryClient를 전달해 줍니다. loader는 훅이 아니기 때문에 QueryClient를 사용할 수 없습니다. 따라서 파라미터로 명시적으로 전달해 줍니다.

    // main.jsx
    
    import React from 'react';
    import ReactDOM from 'react-dom/client';
    import { RouterProvider } from 'react-router-dom';
    import toast from 'react-hot-toast';
    import {
      QueryClient,
      QueryClientProvider,
      QueryCache,
    } from '@tanstack/react-query';
    import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
    import { RecoilRoot } from 'recoil';
    import createRouter from './createRouter';
    
    const queryClient = new QueryClient({
      defaultOptions: {
        queries: {
    		// data를 30분 단위로 refetch되기 때문에 자동 refetch 옵션들은 꺼두었습니다.
          refetchOnWindowFocus: false,
          refetchOnMount: false,
          refetchOnReconnect: false,
        },
      },
      queryCache: new QueryCache({
        onError: (error, query) => {
          if (query.meta.errorMessage) toast(() => query.meta.errorMessage);
        },
      }),
    });
    
    const router = createRouter(queryClient);
    
    ReactDOM.createRoot(document.getElementById('root')).render(
      <React.StrictMode>
        <RecoilRoot>
          <QueryClientProvider client={queryClient}>
            <RouterProvider router={router} />
            <ReactQueryDevtools initialIsOpen={true} />
          </QueryClientProvider>
        </RecoilRoot>
      </React.StrictMode>,
    );
    // createRouter.js
    
    import { createBrowserRouter } from 'react-router-dom';
    import { PATH_ROOT, PATH_CHARTVIEW, PATH_HOSPITALDETAIL } from '@constants';
    import App from './App';
    import { MapView, HpDetailPage, NotFoundPage } from '@pages';
    import { appLoader } from './components/app';
    
    function createRouter(queryClient) {
      return createBrowserRouter([
        {
          path: PATH_ROOT,
          element: <App />,
    	  // loader에 queryclient를 넘겨줍니다.
          loader: appLoader(queryClient),
          children: [
            {
              index: true,
              element: <MapView />, // MapView > 지도 + ersBoxes
            },
            {
              path: PATH_HOSPITALDETAIL,
              element: <HpDetailPage />, // HpDetailPage > 지도 + HpDetailBoxes
            },
            {
              path: '*',
              element: <NotFoundPage />,
            },
          ],
        },
      ]);
    }
    
    export default createRouter;

     

     

    2.  query 정의하고 loader에서 react query cache에 data 저장하기

     

    // queries/erListQuery.js
    
    import { getErList } from '@services';
    
    // query정의
    const erListQuery = () => ({
      queryKey: ['erList'],
      queryFn: getErList,
      retry: 3,
      staleTime: 5 * 60 * 1000,
      cacheTime: 5 * 60 * 1000,
    });
    
    export { erListQuery };
    
    
    // appLoader.js
    import { erListQuery } from '../../queries/erListQuery';
    
    const appLoader = (queryClient) => async () => {
      const query = erListQuery();
      return await queryClient.ensureQueryData(query.queryKey, query.queryFn);
    };
    
    export { appLoader };

    querClient.ensureQueryData는 queryKey에 대한 query 데이터가 캐싱되어 있는지 확인하고 없으면 가져오고 캐싱합니다. 이와 같은 방법으로는 queryClient.getQueryData(query.queryKey) ??  (await queryClient.fetchQuery(query)))   있습니다.

     
    3. 데이터를 사용할 컴포넌트에서 useQuery 호출하기

    // app.jsx
    
    function App() {
      // 응급실 전체 목록 정보 가져오기
      const { isFetching: isErListFetching } = useFetchErList(erListQuery);
      // 가용 병상 정보 가져오기
      const { isFetching: isAvailableBedFetching } = useFetchErsRTavailableBed();
    
      // 생략...
    }
    
    export default App;
    hooks/useFetchErList.js
    
    import { useSetRecoilState } from 'recoil';
    import { useQuery } from '@tanstack/react-query';
    import { ersListState } from '@stores';
    import { ErrorMessage } from '@components';
    import { useCallback } from 'react';
    
    // 응급실 전체 목록 정보 가져오기
    function useFetchErList(erListQuery) {
      const setErsListState = useSetRecoilState(ersListState);
      const query = useQuery({
        ...erListQuery,    
        select:useCallback(
          (data) => setErsListState(data),
          []
        ),
        meta: {
          errorMessage: (
            <ErrorMessage
              content="[실패] 응급실 목록 데이터 가져오기"
              refreshButton
            />
          ),
        },
      });
    
      return query;
    }
    
    export default useFetchErList;

    erListQuery변수는 훅이 아니므로 useCallback, select, meta에 컴포넌트를 넣을 수 없었습니다. 저와 같은 고민을 한 질문을 찾았고 제가 cache data와 used data를 분리하지 못하고 생각했다는 것을 깨달았습니다.  cache data에는 initialData를 저장하고 useQuery를 호출해서 select로 데이터를 가공해 사용하거나, meta 등 다양한 옵션들을 사용하여 useQuery의 이점을 활용할 수 있었습니다.

    확인하기

    getErList의 시작점을 보면 apploader에서 시작됐다는 것을 볼 수 있습니다. 그다음 비교하기 위해 getErRTAvailableBed는 loader를 사용하지 않고 app에서 바로 useQuery로 데이터를 가져왔습니다. 다른 경로로 왔다 갔다 해도 data값이 stale 하지 않다면 데이터를 fetch해오지 않습니다. 

      const checkQuery = useQueryClient();
      console.log(checkQuery.getQueryCache())

    또 queryCache를 콘솔에 찍어 확인해 보면 queriesMap에 [' erList ']를 가진 query가 잘 캐싱되어 있는 것을 확인할 수 있습니다.

     

    삽질

    며칠을 삽질을 했는지 모르겠습니다....ㅎ

    - new queryClient() 두 번 호출

    main.jsx에 QueryClient를 정의한 후 queryClientProvider로 전달하고, router에 새롭게 QueryClient를 정의하고 쿼리를 캐싱했습니다. 그 결과 new queryClient()를 두 번 호출하여, 각각의 queryClient는 독립적으로 동작시켰습니다. 서로 다른 데이터를 캐싱하고, 서로 다른 API 호출을 수행합니다. 따라서 저는 두 개의 queryClient에 각각 데이터를 저장했기 때문에 캐싱이 되지 않아 loader에서 API호출했음에도 app컴포넌트에서 API를 한번 더 호출했습니다.

     



    참고자료
    https://tanstack.com/query/v4/docs/react/examples/react/react-router

    https://tkdodo.eu/blog/react-query-meets-react-router#invalidating-in-actions

    댓글