[리액트 쿼리 시리즈] 1. 쿼리 생성 및 데이터 관리

들어가며
이 글은 React Query에 대한 강의를 들으며 내가 기억해두고 싶은 것들 위주로 정리해둔 글이다. 정보 공유의 목적보다는 개인 복습 목적이 더 강하기 때문에 다소 글이 엉성하고 생략이 많을 수 있음에 주의해달라.
쿼리 생성 및 로딩/에러 상태
앱 전역에는 싱글톤 형태의 queryClient
인스턴스가 존재하며, 이 인스턴스는 key-value로 구성되어있는 쿼리 데이터와 캐시를 관리한다. 여기서 말하는 쿼리 데이터란, 서버에서 온 데이터를 의미한다. 웹서비스에서는 axios
, fetch
같은 데이터 fetching 함수를 useEffect
안에 넣고 그 응답값을 상태(state)로 관리하는게 일반적이겠지만, 이 경우 "클라이언트가 들고 있는 이 데이터가 최신화된 서버 데이터인가?"
에 대한 추가적인 대비가 필요하다.
리액트 쿼리는 SWR(Stale While Revalidating) 전략을 통해 이를 손쉽게 관리할 수 있도록 해준다.
- 윈도우가 포커싱되거나
- 컴포넌트가 리마운트되거나
- 리액트 쿼리에서 제공하는
refetch()
함수를 호출하거나 - 기존 쿼리를 무효화(invalidate)시키거나
위 타이밍에 자동으로 새 데이터를 서버에서 가져오며, 이 동안에는 같은 쿼리키에 저장(캐싱)되어있던 이전 데이터를 화면에 표시한다.
컴포넌트에서 queryClient
를 사용하려면 마치 리액트 Provider처럼 상위에서 <QueryClientProvider/>
로 감싸야 한다. 다음은 공식 문서에서 발췌한 예시이다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
...
</QueryClientProvider>
)
}
컴포넌트에서는 아래같은 형태의 코드로 쿼리 클라이언트에 접근할 수 있게 된다. 다시 한번 말하지만, 쿼리 클라이언트는 상위에 싱글톤으로만 존재하기 때문에 컴포넌트 A와 컴포넌트 B에서 동일한 queryKey를 가진 데이터를 사용한다면 둘은 동일한 데이터를 바라보고 있는 것이다.
function ComponentA() {
const { isPending, error, data } = useQuery({
queryKey: ['repoData'],
queryFn: () =>
fetch('https://api.github.com/repos/TanStack/query').then((res) =>
res.json(),
),
})
if (isPending) return 'Loading...'
if (error) return 'An error has occurred: ' + error.message
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>
<strong>✨ {data.stargazers_count}</strong>
<strong>🍴 {data.forks_count}</strong>
</div>
)
}
useQuery
의 쿼리 함수(queryFn)는 기본적으로 3회 호출된다. 다시 말해, 첫번째 호출에서 에러가 발생하더라도 그대로 동작을 멈추는게 아니라 3회 호출까지는 항상 시도하고, 그럼에도 불구하고 여전히 실패한다면 에러값을 돌려주는 것이다.
또한, useQuery는 브라우저 환경에서 React 상태 관리와 네트워킹을 비동기로 처리하기 때문에 서버 환경에서는 동작하지 않고 클라이언트 사이드 환경(CSR)에서만 동작한다는 점을 기억해야 한다. 이는 서버 컴포넌트에서 useEffect나 useState같은 훅을 쓰지 못하는 것과 일맥 상통한다.
불필요한 중복 호출을 피하기 위해, 서버 컴포넌트에서는 queryClient.fetchQuery
등으로 초기 데이터를 호출하고 이를 dehydrate 하여 클라이언트 컴포넌트에 내려주는 방식을 쓴다. 클라이언트 컴포넌트에서는 <HydrationBoundary/>
를 통해 이를 다시 hydrate해야 데이터를 사용할 수 있다. hydrate된 데이터는 클라이언트에 존재하는 싱글톤 쿼리 클라이언트에 병합된다. 만약 이미 같은 키를 가진 데이터가 클라이언트 쿼리 클라이언트에 존재한다면, 리액트 쿼리 내부에서 타임스탬프 값에 따라 병합을 수행한다.
import { HydrationBoundary } from '@tanstack/react-query'
function App() {
return (
<HydrationBoundary state={dehydratedState}>
...
</HydrationBoundary>
)
}
Stale time, GC time
stale time이란 말그대로 데이터의 유통기한
이라고 생각하면 된다. 예를 들어 이 값이 1분이라고 한다면, 1분동안은 리액트 쿼리가 해당 데이터를 "신선한" 데이터로 인식하기 때문에 데이터 자동 fetching을 수행하지 않는다.
기본값은 0이다. 이 부분에 대해 의구심을 표할 수 있겠지만, 이에 대한 리액트 쿼리 개발자 Tanner Linsley의 코멘트를 보면 납득이 갈 것이다

그렇다. stale time이 0보다 클 경우 사람들은 "데이터가 왜 업데이트되지 않지?"
라는 질문을 훨씬 많이 던질 것이다. 이를 선제적으로 예방하기 위해 stale time의 기본값을 0로 설정한 것이다. 물론, 이 값은 useQuery
옵션을 통해 언제든 바꿀 수 있다.
반면 gc time은 얼만큼의 시간이 지나야 가비지 컬렉션(garbage collenction)이 수행되는지를 뜻하며, v4 버전까지는 cacheTime이라고도 불렸다. 이 데이터가 캐시에 일정 시간 남아있다가, 정말 더이상 필요 없어지면 캐시에서 제거되고 가비지 컬렉션 단계에 돌입하는 것이다.
기본값은 5분이다. 이 5분이라는 타이머가 도는 시점은, 이 쿼리 데이터를 사용하는, 활성화된 컴포넌트 인스턴스가 단 한 곳도 없을 때부터 시작된다. 예를 들어 useQuery({ queryKey: ['posts'], queryFn: fetchPosts })
를 사용하는 Component A가 화면에 표시되고 있었는데, 사용자가 다른 화면으로 넘어가면서 Component A가 언마운트되었다고 하자. 어디서도 useQuery로 queryKey: ['posts']
를 사용 중인 활성화된 리액트 컴포넌트 인스턴스가 없다면 이로부터 5분이 지난 시점에 이 키의 데이터는 캐시에서 제거된다. 더 자세한 예시는 공식 문서에서 확인 가능하다. 참고로 쿼리 데이터를 참조하는 활성화된 인스턴스가 몇 개인지, 해당 쿼리가 비활성화되어있는지에 대한 여부는 리액트 쿼리 devtool 좌측에서 색상과 숫자로도 확인할 수 있다.
배열로써의 쿼리키, 객체로써의 쿼리키
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
위 코드처럼 쿼리키가 배열일 경우, 각 요소의 값이 다르면 쿼리키도 다른 데이터로 인식된다. 즉, 위의 두 useQuery
문은 2개의 데이터를 각각 나타낸다.
반면, 쿼리키가 객체일 경우엔 순서에 영향을 받지 않는다.
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })
위 3개의 useQuery
문은 동일한 데이터를 바라보게 된다. 왜 배열일 땐 순서에 영향을 받고, 객체일 땐 받지 않는 것일까?
그것은 TanStack 소스코드를 보면 그 이유를 알 수 있다.
/**
* Default query & mutation keys hash function.
* Hashes the value into a stable hash.
*/
export function hashKey(queryKey: QueryKey | MutationKey): string {
return JSON.stringify(queryKey, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key];
return result;
}, {} as any)
: val
);
}
리액트 쿼리 내부에서 isPlainObject
라는 유틸함수를 이용해 객체 여부를 판단하고, 객체면 똑같은 순서로 정렬하기 때문에 어떤 순서로 객체 요소를 정렬해도 같은 결과값이 나오게 되는 것이다. 반면, 객체가 아니라면 그대로 그 값을 반환하기 때문에 순서가 다르면 다른 값으로 해시된다.
따라서 쿼리키를 선언할 때는, 객체나 배열 사용시 이런 차이점을 염두에 두어야 할 것이다.