🔥 쿼리 캐시 시드(Seed)하기

1227자
16분

지난주 Promise에 대한 우선 지원에 관한 새로운 RFC가 공개되었습니다. 이로 인해 잘못 사용할 경우 요청 폭포(fetch waterfalls)를 야기할 수 있다는 논의가 시작되었습니다. 그렇다면 요청 폭포란 정확히 무엇일까요?

요청 폭포(Fetch waterfalls or Request waterfalls)

요청 폭포는 하나의 요청을 보내고 그 요청이 완료될 때까지 기다린 후 다른 요청을 보내는 상황을 말합니다.

때로는 첫 번째 요청에 두 번째 요청에 필요한 정보가 포함되어 있어 이를 피할 수 없는 경우가 있습니다. 우리는 이를 의존적 쿼리라고 부릅니다:

lecture image

하지만 많은 경우 필요한 모든 데이터를 병렬로 가져올 수 있습니다. 데이터가 서로 독립적이기 때문입니다:

lecture image

React Query에서는 두 가지 방법으로 이를 구현할 수 있습니다:

// 1. useQuery를 두 번 사용
const issues = useQuery({ queryKey: ['issues'], queryFn: fetchIssues })
const labels = useQuery({ queryKey: ['labels'], queryFn: fetchLabels })
 
// 2. useQueries 훅 사용
const [issues, labels] = useQueries([
  { queryKey: ['issues'], queryFn: fetchIssues },
  { queryKey: ['labels'], queryFn: fetchLabels },
])
 
javascript

두 방법 모두 React Query는 데이터 가져오기를 병렬로 시작합니다. 그렇다면 요청 폭포는 어디서 발생할까요?

Suspense

앞서 언급한 RFC에서 설명한 대로, Suspense는 React에서 Promise를 처리하는 방법입니다. Promise의 주요 특징은 pending, fulfilled, rejected 세 가지 상태를 가질 수 있다는 점입니다.

컴포넌트를 렌더링할 때 우리는 주로 성공 시나리오에 관심이 있습니다. 모든 컴포넌트에서 로딩 및 오류 상태를 처리하는 것은 번거로울 수 있으며, Suspense는 이 문제를 해결하기 위해 만들어졌습니다.

Promise가 pending 상태일 때 React는 컴포넌트 트리를 언마운트하고 Suspense 경계 컴포넌트에 정의된 폴백을 렌더링합니다. 오류가 발생하면 가장 가까운 ErrorBoundary로 오류가 전파됩니다.

이렇게 하면 컴포넌트에서 이러한 상태를 처리하는 것을 분리할 수 있으며, 성공 시나리오에만 집중할 수 있습니다. 마치 캐시에서 값을 읽는 동기 코드처럼 작동합니다. React Query는 v5부터 이를 위한 전용 useSuspenseQuery 훅을 제공합니다:

function Issues() {
  // 👓 캐시에서 데이터 읽기
  const { data } = useSuspenseQuery({
    queryKey: ['issues'],
    queryFn: fetchIssues,
  })
 
  // 🎉 로딩이나 오류 상태를 처리할 필요가 없습니다
 
  return (
    <div>
      { /* TypeScript는 data가 undefined일 수 없다는 것을 알고 있습니다 */ }
      {data.map((issue) => (
        <div>{issue.title}</div>
      ))}
    </div>
  )
}
 
function App() {
  // 🚀 경계가 로딩 및 오류 상태를 처리합니다
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <ErrorBoundary fallback={<div>이런!</div>}>
        <Issues />
      </ErrorBoundary>
    </Suspense>
  )
}
 
javascript

Suspense 폭포

이는 좋아 보이지만, 같은 컴포넌트에서 Suspense가 활성화된 여러 쿼리를 사용할 때 문제가 될 수 있습니다. 다음은 이런 경우 발생하는 상황입니다:

lecture image

  • 컴포넌트가 렌더링되고 첫 번째 쿼리를 읽으려고 시도합니다.
  • 캐시에 아직 데이터가 없음을 확인하고 중단됩니다.
  • 이로 인해 컴포넌트 트리가 언마운트되고 폴백이 렌더링됩니다.
  • 가져오기가 완료되면 컴포넌트 트리가 다시 마운트됩니다.
  • 첫 번째 쿼리는 이제 캐시에서 성공적으로 읽힙니다.
  • 컴포넌트가 두 번째 쿼리를 보고 읽으려고 시도합니다.
  • 두 번째 쿼리는 캐시에 데이터가 없어 다시 중단됩니다.
  • 두 번째 쿼리가 가져와집니다.
  • 마침내 컴포넌트가 성공적으로 렌더링됩니다.

이는 애플리케이션 성능에 상당한 영향을 미칠 것입니다. 필요 이상으로 오랫동안 폴백이 표시될 것이기 때문입니다.

이 문제를 해결하는 가장 좋은 방법은 컴포넌트당 하나의 쿼리만 사용하거나, 컴포넌트가 읽으려고 할 때 이미 캐시에 데이터가 있도록 하는 것입니다.

미리 가져오기(prefetching)

가져오기를 더 일찍 시작할수록 좋습니다. 빨리 시작할수록 빨리 끝나기 때문입니다. 🤓

그렇지 않은 경우에도 prefetchQuery를 사용하여 컴포넌트가 렌더링되기 전에 가져오기를 시작할 수 있습니다:

const issuesQuery = { queryKey: ['issues'], queryFn: fetchIssues }
 
// ⬇️ 컴포넌트 렌더링 전에 가져오기 시작
queryClient.prefetchQuery(issuesQuery)
 
function Issues() {
  const issues = useSuspenseQuery(issuesQuery)
}
 
javascript

prefetchQuery 호출은 JavaScript 번들이 평가되는 즉시 실행됩니다. 경로 기반 코드 분할을 사용하는 경우 특히 잘 작동합니다. 이는 사용자가 해당 페이지로 이동할 때 특정 페이지의 코드가 지연 로드되고 평가되기 때문입니다.

이는 여전히 컴포넌트가 렌더링되기 전에 시작된다는 것을 의미합니다. 예제의 두 쿼리 모두에 대해 이렇게 하면 Suspense를 사용하더라도 병렬 쿼리를 다시 얻을 수 있습니다

lecture image

보시다시피 두 쿼리가 모두 가져오기를 완료할 때까지 쿼리는 여전히 중단되지만, 병렬로 트리거했기 때문에 대기 시간이 크게 줄어들었습니다.

참고: useQueries는 현재 suspense를 지원하지 않지만 향후 지원할 수 있습니다. 지원을 추가한다면 목표는 이러한 폭포를 피하기 위해 모든 가져오기를 병렬로 트리거하는 것입니다.

업데이트
v5에서는 모든 가져오기를 병렬로 트리거하는 전용 useSuspenseQueries 훅이 있습니다. 🎉

use RFC

아직 RFC에 대해 충분히 알지 못해 적절히 설명하기 어렵습니다. 캐시 API가 어떻게 작동할지에 대한 중요한 부분이 아직 빠져있습니다. 개발자가 명시적으로 초기에 캐시를 시드하지 않으면 기본 동작이 폭포로 이어질 수 있다는 점이 조금 문제가 될 수 있습니다. 그래도 React Query의 내부를 이해하고 유지보수하기 쉽게 만들 가능성이 크기 때문에 여전히 흥미롭습니다. 사용자 영역에서 많이 사용될지는 지켜봐야 할 것 같습니다.

목록에서 상세 정보 시드하기

캐시를 읽을 때 데이터로 채우는 또 다른 좋은 방법은 캐시의 다른 부분에서 시드하는 것입니다. 항목의 상세 보기를 렌더링할 때 종종 항목 목록을 보여주는 목록 보기에 있었다면 해당 항목에 대한 데이터를 이미 사용할 수 있습니다.

목록 캐시에서 상세 캐시를 채우는 두 가지 일반적인 접근 방식이 있습니다:

당겨오기 접근 방식(Pull approach)

이 방식은 문서에도 설명되어 있습니다: 상세 보기를 렌더링하려고 할 때 렌더링하려는 항목에 대한 목록 캐시를 조회합니다. 있다면 상세 쿼리의 초기 데이터로 사용합니다.

const useTodo = (id: number) => {
  const queryClient = useQueryClient()
  return useQuery({
    queryKey: ['todos', 'detail', id],
    queryFn: () => fetchTodo(id),
    initialData: () => {
      // ⬇️ 목록 캐시에서 항목 찾기
      return queryClient
        .getQueryData(['todos', 'list'])
        ?.find((todo) => todo.id === id)
    },
  })
}
 
javascript

initialData 함수가 undefined를 반환하면 쿼리는 정상적으로 진행되어 서버에서 데이터를 가져옵니다. 그리고 무언가를 찾으면 그것을 직접 캐시에 넣습니다.

staleTime을 설정한 경우 추가적인 백그라운드 재요청은 발생하지 않습니다. initialData는 '신선한' 것으로 간주되기 때문입니다. 목록을 20분 전에 마지막으로 가져온 경우라면 이는 원하는 동작이 아닐 수 있습니다.

문서에 나와 있듯이, 상세 쿼리에 initialDataUpdatedAt을 추가로 지정할 수 있습니다. 이는 initialData로 전달하는 데이터가 원래 언제 가져왔는지 React Query에 알려주어 오래됨을 올바르게 판단할 수 있게 합니다. 편리하게도 React Query는 목록을 마지막으로 가져온 시간도 알고 있으므로 이를 전달할 수 있습니다:

const useTodo = (id: number) => {
  const queryClient = useQueryClient()
  return useQuery({
    queryKey: ['todos', 'detail', id],
    queryFn: () => fetchTodo(id),
    initialData: () => {
      return queryClient
        .getQueryData(['todos', 'list'])
        ?.find((todo) => todo.id === id)
    },
    initialDataUpdatedAt: () =>
      // ⬇️ 목록의 마지막 가져오기 시간 가져오기
      queryClient.getQueryState(['todos', 'list'])?.dataUpdatedAt,
  })
}
 
javascript

🟢 "적시에" 캐시를 시드합니다

🔴 오래됨을 고려하려면 추가 작업이 필요합니다

밀어넣기 접근 방식(Push approach)

또는 목록 쿼리를 가져올 때마다 상세 캐시를 만들 수 있습니다. 이 방식의 장점은 목록을 가져온 시점부터 자동으로 오래됨을 측정한다는 것입니다. 목록을 가져온 시점이 바로 상세 항목을 만드는 시점이기 때문입니다.

하지만 쿼리가 가져와질 때 연결할 수 있는 좋은 콜백이 없습니다. 캐시 자체의 전역 onSuccess 콜백이 작동할 수 있지만 모든 쿼리에 대해 실행되므로 올바른 쿼리 키로 범위를 좁혀야 합니다.

제가 찾은 밀어넣기 접근 방식을 실행하는 가장 좋은 방법은 데이터를 가져온 후 queryFn 직접 실행하는 것입니다:

const useTodos = () => {
  const queryClient = useQueryClient()
  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: async () => {
      const todos = await fetchTodos()
      todos.forEach((todo) => {
        // ⬇️ 각 항목에 대한 상세 캐시 생성
        queryClient.setQueryData(['todos', 'detail', todo.id], todo)
      })
      return todos
    },
  })
}
 
javascript

이렇게 하면 목록의 각 항목에 대한 상세 항목을 즉시 생성합니다. 현재 이 쿼리에 관심이 있는 사람이 없으므로 이들은 '비활성' 상태로 간주되어 gcTime이 경과한 후 가비지 컬렉션될 수 있습니다(기본값: 15분).

따라서 밀어넣기 접근 방식을 사용하면 사용자가 실제로 상세 보기로 이동할 때 여기서 만든 상세 항목을 더 이상 사용할 수 없을 수 있습니다. 또한 목록이 길다면 절대 필요하지 않을 수 있는 너무 많은 항목을 만들 수 있습니다.

🟢 staleTime을 자동으로 존중합니다

🟡 좋은 콜백이 없습니다

🟡 불필요한 캐시 항목을 만들 수 있습니다

🔴 밀어넣은 데이터가 너무 일찍 가비지 컬렉션될 수 있습니다


두 접근 방식 모두 상세 쿼리의 구조가 목록 쿼리의 구조와 정확히 같거나(또는 적어도 할당 가능한 경우)에만 잘 작동한다는 점을 명심하세요. 상세 보기에 목록에 없는 필수 필드가 있다면 initialData를 통한 시드는 좋은 방법이 아닙니다. 이럴 때 placeholderData가 유용하며, #9: React Query의 Placeholder 및 Initial Data에서 둘을 비교했습니다.