🔥 쿼리 캐시 시드(Seed)하기
강의 목차
지난주 Promise에 대한 우선 지원에 관한 새로운 RFC가 공개되었습니다. 이로 인해 잘못 사용할 경우 요청 폭포(fetch waterfalls)를 야기할 수 있다는 논의가 시작되었습니다. 그렇다면 요청 폭포란 정확히 무엇일까요?
요청 폭포(Fetch waterfalls or Request waterfalls)
요청 폭포는 하나의 요청을 보내고 그 요청이 완료될 때까지 기다린 후 다른 요청을 보내는 상황을 말합니다.
때로는 첫 번째 요청에 두 번째 요청에 필요한 정보가 포함되어 있어 이를 피할 수 없는 경우가 있습니다. 우리는 이를 의존적 쿼리라고 부릅니다:
하지만 많은 경우 필요한 모든 데이터를 병렬로 가져올 수 있습니다. 데이터가 서로 독립적이기 때문입니다:
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가 활성화된 여러 쿼리를 사용할 때 문제가 될 수 있습니다. 다음은 이런 경우 발생하는 상황입니다:
- 컴포넌트가 렌더링되고 첫 번째 쿼리를 읽으려고 시도합니다.
- 캐시에 아직 데이터가 없음을 확인하고 중단됩니다.
- 이로 인해 컴포넌트 트리가 언마운트되고 폴백이 렌더링됩니다.
- 가져오기가 완료되면 컴포넌트 트리가 다시 마운트됩니다.
- 첫 번째 쿼리는 이제 캐시에서 성공적으로 읽힙니다.
- 컴포넌트가 두 번째 쿼리를 보고 읽으려고 시도합니다.
- 두 번째 쿼리는 캐시에 데이터가 없어 다시 중단됩니다.
- 두 번째 쿼리가 가져와집니다.
- 마침내 컴포넌트가 성공적으로 렌더링됩니다.
이는 애플리케이션 성능에 상당한 영향을 미칠 것입니다. 필요 이상으로 오랫동안 폴백이 표시될 것이기 때문입니다.
이 문제를 해결하는 가장 좋은 방법은 컴포넌트당 하나의 쿼리만 사용하거나, 컴포넌트가 읽으려고 할 때 이미 캐시에 데이터가 있도록 하는 것입니다.
미리 가져오기(prefetching)
가져오기를 더 일찍 시작할수록 좋습니다. 빨리 시작할수록 빨리 끝나기 때문입니다. 🤓
- 아키텍처가 서버 사이드 렌더링을 지원한다면 서버에서 가져오는 것을 고려해보세요.
- 로더를 지원하는 라우터가 있다면 거기서 미리 가져오기를 고려해보세요.
그렇지 않은 경우에도 prefetchQuery
를 사용하여 컴포넌트가 렌더링되기 전에 가져오기를 시작할 수 있습니다:
const issuesQuery = { queryKey: ['issues'], queryFn: fetchIssues } // ⬇️ 컴포넌트 렌더링 전에 가져오기 시작 queryClient.prefetchQuery(issuesQuery) function Issues() { const issues = useSuspenseQuery(issuesQuery) }
javascript
prefetchQuery
호출은 JavaScript 번들이 평가되는 즉시 실행됩니다. 경로 기반 코드 분할을 사용하는 경우 특히 잘 작동합니다. 이는 사용자가 해당 페이지로 이동할 때 특정 페이지의 코드가 지연 로드되고 평가되기 때문입니다.
이는 여전히 컴포넌트가 렌더링되기 전에 시작된다는 것을 의미합니다. 예제의 두 쿼리 모두에 대해 이렇게 하면 Suspense를 사용하더라도 병렬 쿼리를 다시 얻을 수 있습니다
보시다시피 두 쿼리가 모두 가져오기를 완료할 때까지 쿼리는 여전히 중단되지만, 병렬로 트리거했기 때문에 대기 시간이 크게 줄어들었습니다.
참고: 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에서 둘을 비교했습니다.