응급실 알리미의 구조 설계 초기 때 Router의 loader를 사용하여 미리 데이터를 받아와 컴포넌트를 렌더링 하려고 하였습니다. 하지만 loader함수는 컴포넌트가 아니기 때문에 데이터를 가져오기 위해 useQuery훅이나 recoil의 상태 관리 훅을 사용할 수 없었습니다. 해결책을 찾지 못하고 차선책으로 API를 사용하는 공통 컴포넌트에서 React Query로 데이터를 가져와 rocoil로 관리하기로 하였습니다. 프로젝트 완성 후 리팩토링 과정에서 React Query와 Router loader를 연결할 수 있는 방법을 찾았고 그 과정을 정리한 글입니다
현재 상황
제일 최상위인 app 컴포넌트에서 모든 페이지에 사용하는 공통 API를 호출하여 Recoil 전역 변수에 업데이트합니다. 또 selector로 사용할 데이터를 가공하여 컴포넌트에서 구독하여 데이터를 사용하고 있습니다.
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)
그래서 어떻게 적용하나요?
- 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
'개발언어 > React' 카테고리의 다른 글
[🚨응급실 알리미] React-query에서 error 처리 (4) | 2023.10.08 |
---|---|
[🚨응급실 알리미] Recoil과 React-query를 선택한 이유 (2) | 2023.09.06 |
Virtual DOM이 뭔가요? (0) | 2023.07.21 |
useState를 바닐라JS로 구현하기 (0) | 2023.07.14 |
[공식문서 톺아보기] batching / setState는 비동기인가요? (2) | 2023.07.14 |
댓글