🔥 React Query와 React Router의 만남

1482자
18분

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는 캐시에 있는 데이터를 반환하는 데 효과적입니다. 오래된 데이터라도 말이죠. 이는 페이지를 반복해서 방문할 때 데이터를 즉시 보여줄 수 있게 합니다. getQueryDataundefined를 반환할 때만 (즉, 캐시에 아무것도 없을 때) 실제로 데이터를 가져옵니다.

다른 방법으로는 fetchQuerystaleTime을 설정하는 것이 있습니다:

export const loader =
  (queryClient) =>
  ({ params }) =>
    queryClient.fetchQuery({
      ...contactDetailQuery(params.contactId),
      staleTime: 1000 * 60 * 2,
    })
 
javascript

staleTime을 2분으로 설정하면 fetchQuery에게 데이터가 사용 가능하고 2분보다 오래되지 않았다면 즉시 해결하라고 말하는 것입니다. 그렇지 않으면 데이터를 가져올 것입니다. 컴포넌트에서 오래된 데이터를 보여주지 않아도 된다면 이는 좋은 대안입니다.

staleTimeInfinity로 설정하는 것은 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를 추가했습니다 - 공식 문서의 예제에서 찾아볼 수 있습니다.