[리액트 쿼리 시리즈] 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
라는 유틸함수를 이용해 객체 여부를 판단하고, 객체면 똑같은 순서로 정렬하기 때문에 어떤 순서로 객체 요소를 정렬해도 같은 결과값이 나오게 되는 것이다. 반면, 객체가 아니라면 그대로 그 값을 반환하기 때문에 순서가 다르면 다른 값으로 해시된다.
따라서 쿼리키를 선언할 때는, 객체나 배열 사용시 이런 차이점을 염두에 두어야 할 것이다.
데이터 로딩 상태
사용자가 페이지에 진입했을 때, CSR로 그려지는 페이지라면 아직 사용자에게 보여줄 데이터가 준비되지 않았을 것이다. 따라서 데이터를 가져오는 시간동안 우리는 로딩UI나 적절한 애니메이션, 스켈레톤 UI 등을 화면에 표시해야 한다.
이 때 활용할 수 있는 값이 바로 isLoading
, isFetching
, isPending
등인데, 이 값들은 기본적으로 쿼리 객체 내의 status
와 fetchStatus
필드 값에 의존한다. status: "loading"
이면 isLoading: true
인 식으로 말이다. 리액트쿼리 v4에서 v5로 넘어가면서 일관성있는 표현을 위해 status
값이 조금 바뀌었고, 각각의 의미와 쓰임새가 조금씩 달라졌다. 각 버전 별로 차이를 알아보도록 하자.
v4: isLoading, isFetching
// react-query v4
const { isLoading, isFetching, status, data, isError, error } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodoList,
});
isLoading
은 status === "loading"
에 기반한다. 여기서 loading
은 데이터를 가져오는 중이라는 뜻이 아니라, 캐시된 데이터가 없고 완료된 쿼리가 없을 때를 의미한다. 하지만 보통 useQuery
를 쓰면 (enabled
값이 false
가 아니고서야) queryFn이 실행되면서 데이터를 들고 올테니 보통은 최초 로딩 UI를 나타낼 때 이 값을 쓰게 된다.
만약 데이터를 최초로 가져오는 중임을 알아내려면 어떻게 해야할까? 여기서 fetchStatus
가 나오게 된다.
fetchStatus
는 fetching, paused, idle 중 하나의 값을 가질 수 있는데, fetching일 경우 isFetching: true
가 된다. 따라서 데이터를 최초로 가져오는 중임을 특정해내려면 우리는 isLoading === true && isFetching === true
일 때를 봐야하며, 이와 동일한 의미로 존재하는 isInitialLoading
필드를 사용할 수 있다.
즉 정리하자면 status
는 캐시 데이터의 상태, fetchStatus
는 네트워크 상태를 나타내고, 이를 이용해 isLoading
, isFetching
, isInitialLoading
중 필요한 것을 쓰면 된다.
상황 | isFetching | isLoading | isInitialLoading |
---|---|---|---|
페이지 최초 진입 (데이터 없음) | true | true | true |
데이터 갱신 | true | false | false |
지금까지 useQuery
의 데이터 로딩 상태를 알아보았다. 그럼 useMutation
은 어떨까?
const { data, status, isIdle, isLoading, isSuccess, isError } = useMutation({
mutationFn: fetchTodoList,
});
useMutation
의 로딩 상태도 그 자신이 반환하는 객체의 status
값에 기반하는데, status
는 idle, loading, error, success 중 하나의 값을 가진다.
idle은 mutationFn
이 실행되기 전 최초의 상태를 뜻하며, loading는 mutationFn
이 실행 중임을 나타내고, error와 success는 그 결과다. 이 값은 각각 isIdle
, isLoading
, isError
, isSuccess
라는 boolean형 필드로 나타나므로, 필요에 따라 원하는 값을 쓰면 된다.
v5: isPending, isFetching
v5로 넘어오면서부터는 status
의 loading이 pending으로 바뀌었다. 그에 따라 isLoading
도 isPending
으로 그 네이밍이 바뀌었다. 이는 useQuery
뿐만 아니라 useMutation
에도 동일하게 적용되었다. 즉, v4의 isLoading
와 동일하게, v5에서는 isPending: true
가 캐시된 데이터가 없고 완료된 쿼리가 아직 없음 을 나타내게 되었다.
그럼 isLoading
은 완전히 사라졌을까? 그렇지 않다. isLoading
이라는 네이밍은 이제 isInitialLoading
의 자리를 꿰차게 되었고, 밀려난 isInitialLoading
은 다음 메이저버전(아마 v6?)에 완전히 deprecated되기로 하였다. 즉, isPending == true && isFetching === true
일 때 isLoading
은 true
가 된다.
상황 | isFetching | isPending | isLoading |
---|---|---|---|
페이지 최초 진입 (데이터 없음) | true | true | true |
데이터 갱신 | true | false | false |
조금 헷갈릴 수는 있지만 status
가 캐시 데이터의 상태, fetchStatus
는 네트워크 상태를 나타낸다는 기본 컨셉은 바뀌지 않았다. isPending
, isFetching
같은 필드가 이를 기반으로 값이 결정된다는 걸 상기하면 그렇게 어렵지 않을 것이다.