🔥 React Query와 React Router의 만남
강의 목차
Remix가 데이터 가져오기 개념(로더와 액션)을 순수 클라이언트 측 렌더링 애플리케이션으로 가져오면서 게임의 판도를 바꾸고 있습니다. React Router 6.4와 함께 이 개념들이 도입되었죠. React Router의 훌륭한 튜토리얼을 통해 이 개념을 잘 이해할 수 있으며, 작지만 기능이 풍부한 앱을 빠르게 만들 수 있는 방법을 배울 수 있습니다.
React Router가 데이터 가져오기 영역에 진출하면서, 이것이 React Query와 같은 기존의 데이터 가져오기 및 캐싱 라이브러리와 어떻게 경쟁하거나 상호 작용하는지 이해하는 것이 흥미롭습니다. 결론부터 말씀드리면:
이 둘은 천생연분입니다.
데이터를 가져오는 라우터
React Router는 각 라우트에 로더를 정의할 수 있게 해줍니다. 이 로더는 해당 라우트를 방문할 때 호출됩니다. 라우트 컴포넌트 내에서 useLoaderData()
를 사용하여 그 데이터에 접근할 수 있습니다. 데이터 업데이트는 Form
을 제출하는 것만큼 간단하며, 이는 액션 함수를 호출합니다. 액션은 모든 활성 로더를 무효화하므로, 화면에서 자동으로 업데이트된 데이터를 볼 수 있습니다.
이는 쿼리와 뮤테이션과 매우 유사하게 들립니다. 그래서 React Router의 새로운 기능 발표 이후 다음과 같은 질문들이 제기되었습니다:
- 이제 라우트에서 데이터를 가져올 수 있는데 React Query가 필요할까요?
- 이미 React Query를 사용하고 있다면, 새로운 React Router 기능을 활용하고 싶은데 (그리고 어떻게) 할 수 있을까요?
캐시가 아닙니다
두 질문 모두에 대한 답변은 명확히 "네"입니다. Remix 팀의 Ryan Florence가 말했듯이 "React Router는 캐시가 아닙니다":
@ryanflorence
아니요, React Router는 캐시가 아닙니다.
브라우저는 HTTP를 통해 이 기능을 내장하고 있고, React Query 같은 라이브러리들이 이 작업을 완벽히 해냅니다.
React Router는 언제에 관한 것이고, 데이터 캐싱 라이브러리들은 무엇에 관한 것입니다.
"가능한 한 빨리" 데이터를 가져오는 것은 최상의 사용자 경험을 제공하기 위한 중요한 개념입니다. NextJs나 Remix 같은 풀 스택 프레임워크는 이 단계를 서버로 옮깁니다. 그곳이 가장 빠른 진입점이기 때문입니다. 하지만 클라이언트에서 렌더링되는 애플리케이션에서는 이런 편의성이 없습니다.
빠른 데이터 가져오기
우리는 보통 컴포넌트가 마운트될 때 - 즉, 데이터가 처음 필요할 때 - 데이터를 가져옵니다. 하지만 이는 좋지 않습니다. 초기 데이터를 가져오는 동안 사용자에게 로딩 스피너가 보이기 때문입니다. 프리페칭이 도움이 될 수 있지만, 이는 후속 탐색에만 적용되며 라우트로 이동하는 모든 방법에 대해 수동으로 설정해야 합니다.
그러나 라우터는 항상 어떤 페이지를 방문하려고 하는지 알고 있는 첫 번째 컴포넌트입니다. 이제 로더가 있기 때문에 해당 페이지를 렌더링하는 데 필요한 데이터도 알 수 있습니다. 이는 첫 페이지 방문에 좋습니다. 하지만 로더는 모든 페이지 방문마다 호출됩니다. 라우터에는 캐시가 없기 때문에 우리가 무언가를 하지 않는 한 매번 서버에 요청을 보낼 것입니다.
예를 들어 (네, 이는 앞서 언급한 튜토리얼에서 가져온 것입니다. Ryan Florence에게 감사드립니다), 연락처 목록이 있다고 가정해봅시다. 그 중 하나를 클릭하면 연락처 세부 정보를 보여줍니다:
src/routes/contact.jsx
import { useLoaderData } from 'react-router-dom' import { getContact } from '../contacts' // ⬇️ 이는 세부 정보 라우트의 로더입니다 export async function loader({ params }) { return getContact(params.contactId) } export default function Contact() { // ⬇️ 이는 로더에서 데이터를 가져옵니다 const contact = useLoaderData() // jsx를 렌더링합니다 }
javascript
src/main.jsx
import Contact, { loader as contactLoader } from './routes/contact' const router = createBrowserRouter([ { path: '/', element: <Root />, children: [ { path: 'contacts', element: <Contacts />, children: [ { path: 'contacts/:contactId', element: <Contact />, // ⬇️ 이는 세부 정보 라우트의 로더입니다 loader: contactLoader, }, ], }, ], }, ])
javascript
contacts/1
로 이동하면 해당 연락처의 데이터를 컴포넌트가 렌더링되기 전에 가져옵니다. Contact를 보여줄 때쯤이면 useLoaderData
에 데이터가 준비되어 있습니다. 이는 사용자 경험을 개선할 뿐만 아니라, 데이터 가져오기와 렌더링이 함께 위치하는 개발자 경험도 제공합니다! 저는 이를 정말 좋아합니다. 🥰
너무 자주 가져오기
캐시가 없다는 큰 단점은 Contact 2로 갔다가 다시 Contact 1로 돌아올 때 드러납니다. React Query에 익숙하다면 Contact 1의 데이터가 이미 캐시되어 있어 즉시 보여주고 백그라운드에서 리프레시할 수 있다는 것을 알 것입니다. 하지만 로더 접근 방식에서는 이미 가져온 적이 있더라도 그 데이터를 다시 가져와야 합니다 (그리고 가져오기가 끝날 때까지 기다려야 합니다!).
바로 여기서 React Query가 빛을 발합니다.
로더를 사용해 React Query 캐시를 미리 채우되, 컴포넌트에서는 여전히 useQuery
를 사용하여 refetchOnWindowFocus
나 오래된 데이터를 즉시 보여주는 등 React Query의 모든 이점을 누릴 수 있다면 어떨까요? 이는 두 가지 장점을 모두 취하는 방법으로 들립니다. 라우터는 데이터를 빨리 가져오고 (없는 경우), React Query는 데이터를 캐싱하고 최신 상태로 유지하는 역할을 합니다.
예제 Query화하기
이 예제를 그 방향으로 옮겨보겠습니다:
src/routes/contacts.jsx
import { useQuery } from '@tanstack/react-query' import { getContact } from '../contacts' // ⬇️ 쿼리를 정의합니다 const contactDetailQuery = (id) => ({ queryKey: ['contacts', 'detail', id], queryFn: async () => getContact(id), }) // ⬇️ queryClient에 접근해야 합니다 export const loader = (queryClient) => async ({ params }) => { const query = contactDetailQuery(params.contactId) // ⬇️ 데이터를 반환하거나 가져옵니다 return ( queryClient.getQueryData(query.queryKey) ?? (await queryClient.fetchQuery(query)) ) } export default function Contact() { const params = useParams() // ⬇️ 평소처럼 useQuery를 사용합니다 const { data: contact } = useQuery(contactDetailQuery(params.contactId)) // jsx를 렌더링합니다 }
javascript
src/main.jsx
const queryClient = new QueryClient() const router = createBrowserRouter([ { path: '/', element: <Root />, children: [ { path: 'contacts', element: <Contacts />, children: [ { path: 'contacts/:contactId', element: <Contact />, // ⬇️ 라우트에 queryClient를 전달합니다 loader: contactLoader(queryClient), }, ], }, ], }, ])
javascript
여기서 몇 가지 일이 일어나고 있습니다. 하나씩 살펴보겠습니다:
로더는 QueryClient에 접근해야 합니다
로더는 훅이 아니기 때문에 useQueryClient
를 사용할 수 없습니다. QueryClient를 직접 가져오는 것은 권장하지 않으므로, 명시적으로 전달하는 것이 최선의 대안으로 보입니다.
getQueryData ?? fetchQuery
우리는 로더가 데이터를 준비할 때까지 기다렸다가 반환하여 첫 번째 로드에서 좋은 경험을 제공하고 싶습니다. 또한 오류를 errorElement
로 던지고 싶으므로 fetchQuery
가 최선의 선택입니다. prefetchQuery
는 아무것도 반환하지 않고 내부적으로 오류를 잡는다는 점을 참고하세요 (그 외에는 동일합니다).
getQueryData
는 캐시에 있는 데이터를 반환하는 데 효과적입니다. 오래된 데이터라도 말이죠. 이는 페이지를 반복해서 방문할 때 데이터를 즉시 보여줄 수 있게 합니다. getQueryData
가 undefined
를 반환할 때만 (즉, 캐시에 아무것도 없을 때) 실제로 데이터를 가져옵니다.
다른 방법으로는 fetchQuery
에 staleTime
을 설정하는 것이 있습니다:
export const loader = (queryClient) => ({ params }) => queryClient.fetchQuery({ ...contactDetailQuery(params.contactId), staleTime: 1000 * 60 * 2, })
javascript
staleTime
을 2분으로 설정하면 fetchQuery
에게 데이터가 사용 가능하고 2분보다 오래되지 않았다면 즉시 해결하라고 말하는 것입니다. 그렇지 않으면 데이터를 가져올 것입니다. 컴포넌트에서 오래된 데이터를 보여주지 않아도 된다면 이는 좋은 대안입니다.
staleTime
을 Infinity
로 설정하는 것은 getQueryData
접근 방식과 거의 동일합니다. 다만 수동 쿼리 무효화가 staleTime
보다 우선합니다. 그래서 저는 코드가 약간 더 많더라도 getQueryData
접근 방식을 조금 더 선호합니다.
업데이트: v4.18.0부터는 내장된 queryClient.ensureQueryData 메서드를 사용하여 동일한 결과를 얻을 수 있습니다. 이는 문자 그대로 getQueryData ?? fetchQuery
로 구현되어 있지만, 라이브러리에서 기본으로 제공할 만큼 일반적인 사용 사례입니다.
TypeScript 팁
이렇게 하면 컴포넌트에서 useQuery
를 호출할 때 useLoaderData
를 호출하는 것처럼 일부 데이터를 사용할 수 있다는 것이 보장됩니다. 하지만 TypeScript는 이를 알 수 없습니다 - 반환된 데이터의 타입은 Contact | undefined
입니다.
Matt Pocock과 그의 React Query v4에 대한 기여 덕분에 이제 initialData
가 제공되면 유니언에서 undefined
를 제외할 수 있습니다.
initialData
는 어디서 얻을 수 있을까요? 물론 useLoaderData
에서 얻을 수 있습니다! 로더 함수에서 타입을 추론할 수도 있습니다:
export default function Contact() { const initialData = useLoaderData() as Awaited ReturnType<ReturnType<typeof loader>> > const params = useParams() const { data: contact } = useQuery({ ...contactDetailQuery(params.contactId), initialData, }) // jsx를 렌더링합니다 }
javascript
로더가 함수를 반환하는 함수이기 때문에 쓰기가 조금 복잡합니다. 하지만 이를 단일 유틸리티로 숨길 수 있습니다. 또한 현재로서는 타입 단언을 사용하는 것이 useLoaderData
의 반환 타입을 좁히는 유일한 방법인 것 같습니다. 🤷♂️ 하지만 이는 useQuery
결과의 타입을 잘 좁혀줄 것입니다. 🙌
액션에서 무효화하기
퍼즐의 다음 조각은 쿼리 무효화와 관련이 있습니다. 다음은 React Query 없이 액션이 어떻게 보일지를 보여주는 튜토리얼의 예제입니다 (네, 업데이트를 수행하는 데 이것이 전부입니다):
src/routes/edit.jsx
export const action = async ({ request, params }) => { const formData = await request.formData() const updates = Object.fromEntries(formData) await updateContact(params.contactId, updates) return redirect(`/contacts/${params.contactId}`) }
javascript
액션은 로더를 무효화하지만, 우리는 로더가 항상 캐시에서 데이터를 반환하도록 설정했기 때문에 캐시를 어떻게든 무효화하지 않으면 업데이트를 볼 수 없습니다. 실제로 한 줄의 코드만 추가하면 됩니다:
src/routes/edit.jsx
export const action = (queryClient) => async ({ request, params }) => { const formData = await request.formData() const updates = Object.fromEntries(formData) await updateContact(params.contactId, updates) await queryClient.invalidateQueries({ queryKey: ['contacts'] }) return redirect(`/contacts/${params.contactId}`) }
javascript
invalidateQueries의 퍼지 매칭은 액션이 완료되고 세부 정보 보기로 리디렉션할 때까지 목록과 세부 정보 보기가 캐시에 새 데이터를 가지도록 합니다.
await는 레버입니다
하지만 이렇게 하면 액션 함수가 더 오래 걸리고 전환을 차단할 것입니다. 무효화를 트리거한 다음 세부 정보 보기로 리디렉션하여 오래된 데이터를 보여주고, 새 데이터를 사용할 수 있게 되면 백그라운드에서 업데이트하도록 할 수는 없을까요? 물론 가능합니다: 그냥 await
키워드를 생략하면 됩니다:
src/routes/edit.jsx
export const action = (queryClient) => async ({ request, params }) => { const formData = await request.formData() const updates = Object.fromEntries(formData) await updateContact(params.contactId, updates) queryClient.invalidateQueries({ queryKey: ["contacts"] }); return redirect(`/contacts/${params.contactId}`) }
javascript
await는 말 그대로 어느 쪽으로든 당길 수 있는 레버가 됩니다 (이 비유는 Ryan의 훌륭한 강연 When To Fetch에 기반합니다. 아직 보지 않았다면 꼭 보세요):
- 가능한 한 빨리 세부 정보 보기로 전환하는 것이 중요한가요? await를 사용하지 마세요.
- 오래된 데이터를 보여줄 때 발생할 수 있는 레이아웃 이동을 피하거나 모든 새 데이터를 가질 때까지 액션을 대기 상태로 유지하고 싶나요? await를 사용하세요.
여러 무효화가 관련된 경우 이 두 가지 접근 방식을 혼합하여 중요한 리페치는 기다리고 덜 중요한 것들은 백그라운드에서 처리할 수 있습니다.
요약
새로운 React Router 릴리스에 대해 매우 기대됩니다. 모든 애플리케이션이 가능한 한 빨리 데이터 가져오기를 시작할 수 있게 하는 큰 진전입니다. 하지만 이는 캐싱을 대체하는 것이 아닙니다 - 그러니 React Router와 React Query를 결합하여 두 가지의 장점을 모두 얻으세요. 🚀
이 주제를 더 탐구하고 싶다면, 튜토리얼의 앱을 구현하고 React Query를 추가했습니다 - 공식 문서의 예제에서 찾아볼 수 있습니다.