React Query에서 서버 상태를 따로 관리하는 이유
React Query에서 서버 상태를 따로 관리하는 이유
서버 상태는 클라이언트가 완전히 소유하지 않는다. 언제든 다른 사용자나 서버 작업으로 바뀔 수 있고, 요청 중·실패·오래됨·재검증 상태를 함께 가진다. TanStack Query는 query key로 서버 데이터의 정체성을 표현하고, freshness와 cache 수명, refetch, invalidation을 관리한다. 가져온 데이터를 다시 local state나 전역 store에 복사하지 않고 query cache를 source of truth로 두는 것이 핵심이다.
목차
- #서버 데이터는 일반 전역 상태와 성격이 다르다
- #직접 구현하면 어떤 상태가 필요한가
- #query key는 서버 상태의 주소다
- #staleTime과 gcTime은 서로 다른 시간이다
- #로딩과 background refetch를 구분한다
- #query 결과를 local state에 복사하면 생기는 문제
- #mutation 이후에는 관련 query를 무효화한다
- #조건부 query와 request waterfall
- #오류와 재시도 정책을 업무에 맞춘다
- #SSR에서는 요청별 QueryClient를 만든다
- #서버 상태와 클라이언트 상태의 경계
- #Devtools와 운영 지표로 확인하기
- #정리
- #관련 노트와 참고 자료
서버 데이터는 일반 전역 상태와 성격이 다르다
modal이 열렸는지, 현재 선택한 tab이 무엇인지는 브라우저가 소유하는 client state다. 애플리케이션 코드가 값을 바꾸지 않으면 저절로 변하지 않는다.
반면 /api/products에서 가져온 상품 목록은 다르다.
- 관리자 화면에서 상품명이 바뀔 수 있다.
- 재고가 다른 사용자의 주문으로 줄어들 수 있다.
- 권한에 따라 응답이 달라질 수 있다.
- 네트워크가 끊겨 요청이 실패할 수 있다.
- 현재 cache가 최신인지 확신할 수 없다.
- 같은 데이터를 여러 화면이 동시에 요청할 수 있다.
서버 응답 객체만 저장한다고 문제를 다룬 것이 아니다. 데이터와 함께 다음 메타 상태가 필요하다.
type RemoteData<T> = {
data: T | null;
status: "idle" | "pending" | "success" | "error";
error: Error | null;
fetchedAt: number | null;
fetching: boolean;
invalidated: boolean;
};
페이지마다 이를 직접 구현하면 cache key, 요청 중복, retry, focus refetch, garbage collection까지 애플리케이션 코드가 다시 만들게 된다.
client state는 애플리케이션이 원본을 소유하지만 server state는 서버 원본의 특정 시점 snapshot을 빌려 온 것이다.
직접 구현하면 어떤 상태가 필요한가
Context와 Effect로 상품 목록을 가져오는 코드를 보자.
function ProductProvider({ children }: PropsWithChildren) {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let ignore = false;
setLoading(true);
fetchProducts()
.then((result) => {
if (!ignore) {
setProducts(result);
setError(null);
}
})
.catch((cause) => {
if (!ignore) {
setError(toError(cause));
}
})
.finally(() => {
if (!ignore) {
setLoading(false);
}
});
return () => {
ignore = true;
};
}, []);
return (
<ProductContext.Provider value={{ products, loading, error }}>
{children}
</ProductContext.Provider>
);
}
첫 요청은 할 수 있지만 곧 질문이 늘어난다.
- 같은 Provider가 두 곳에 mount되면 요청을 합칠 것인가?
- 다른 화면으로 갔다 돌아오면 cache를 얼마나 유지할 것인가?
- 브라우저 focus가 돌아왔을 때 refetch할 것인가?
- 필터별 목록을 어떤 key로 구분할 것인가?
- 수정 mutation 후 어느 목록을 새로 가져올 것인가?
- 이전 데이터가 있는데 background refetch 중이면 어떤 UI를 보여 줄 것인가?
- 실패한 요청을 몇 번, 어떤 간격으로 재시도할 것인가?
TanStack Query는 이 문제를 query cache와 observer model로 다룬다.
function ProductList({ filters }: { filters: ProductFilters }) {
const query = useQuery({
queryKey: ["products", filters],
queryFn: ({ signal }) => fetchProducts(filters, signal),
staleTime: 30_000,
});
if (query.isPending) return <ProductListSkeleton />;
if (query.isError) return <ProductLoadError error={query.error} />;
return <ProductTable products={query.data} />;
}
이 코드의 장점은 줄 수가 적다는 것보다 서버 상태의 생명주기가 명시된다는 점이다.
query key는 서버 상태의 주소다
query key는 cache entry를 식별하고 query function의 dependency 역할을 한다. 결과를 바꾸는 모든 입력을 포함해야 한다.
const productQuery = useQuery({
queryKey: [
"products",
{
categoryId,
sort,
page,
pageSize,
},
],
queryFn: ({ signal }) =>
fetchProducts(
{ categoryId, sort, page, pageSize },
signal,
),
});
sort를 key에서 빼면 가격순 요청과 최신순 요청이 같은 cache entry를 사용한다.
// 잘못된 예: queryFn 결과를 바꾸는 sort가 key에 없다.
useQuery({
queryKey: ["products", categoryId],
queryFn: () => fetchProducts({ categoryId, sort }),
});
key가 바뀌면 별도 cache entry가 되고 설정에 따라 새로운 query를 실행한다. useEffect dependency와 비슷하게 query function이 읽는 변경 가능한 입력을 key에 넣는다.
query key factory로 일관성 만들기
문자열 배열을 여러 파일에 직접 쓰면 invalidation 범위와 오타를 관리하기 어렵다.
const productKeys = {
all: ["products"] as const,
lists: () => [...productKeys.all, "list"] as const,
list: (filters: ProductFilters) =>
[...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, "detail"] as const,
detail: (productId: string) =>
[...productKeys.details(), productId] as const,
};
useQuery({
queryKey: productKeys.detail(productId),
queryFn: ({ signal }) => fetchProduct(productId, signal),
});
계층적 key는 전체 상품, 목록만, 특정 상세만 invalidation하기 쉽게 한다.
응답이 workspace, tenant, locale, 권한 범위에 따라 달라진다면 key나 QueryClient 경계에서 구분해야 한다. 로그아웃 시 이전 사용자의 민감한 cache를 제거하는 정책도 필요하다.
staleTime과 gcTime은 서로 다른 시간이다
두 옵션을 혼동하기 쉽다.
staleTime
데이터를 fresh로 간주하는 시간이다. fresh한 동안에는 일반적인 mount, focus, reconnect 계기의 자동 refetch를 줄일 수 있다.
useQuery({
queryKey: productKeys.detail(productId),
queryFn: fetchProduct,
staleTime: 60_000,
});
1분 동안 서버 데이터가 절대 바뀌지 않는다는 보장이 아니라, UI가 1분 이내 snapshot을 충분히 최신으로 받아들인다는 제품 정책이다.
gcTime
활성 observer가 없어 inactive가 된 query를 cache에 얼마나 유지한 뒤 garbage collection할지 정한다.
useQuery({
queryKey: productKeys.detail(productId),
queryFn: fetchProduct,
staleTime: 60_000,
gcTime: 10 * 60_000,
});
사용자가 상세 화면을 벗어나도 10분 동안 cache entry를 보관할 수 있다. 다시 돌아왔을 때 cached data를 즉시 보여 주고, stale하다면 background refetch할 수 있다.
stateDiagram-v2
[*] --> Fresh: fetch 성공
Fresh --> Stale: staleTime 경과
Stale --> Fresh: refetch 성공
Fresh --> Inactive: observer 없음
Stale --> Inactive: observer 없음
Inactive --> Fresh: 다시 mount + refetch
Inactive --> Collected: gcTime 경과| 옵션 | 답하는 질문 |
|---|---|
staleTime |
이 snapshot을 언제까지 최신으로 믿을 것인가 |
gcTime |
아무도 구독하지 않는 cache를 언제까지 보관할 것인가 |
기본값은 TanStack Query 버전에 따라 확인해야 한다. 현재 공식 문서 기준 일반 query는 기본적으로 즉시 stale로 간주되고, inactive query는 기본 5분 후 수집된다. 요구에 맞춰 명시한다.
데이터 특성별 예
| 데이터 | staleTime 예시 | 이유 |
|---|---|---|
| 국가 코드 목록 | 길게 또는 수동 invalidation | 거의 변하지 않음 |
| 상품 상세 | 30초~수분 | 약간 오래된 정보 허용 가능 |
| 재고 | 짧게 + mutation 후 invalidation | 변화가 잦고 중요 |
| 실시간 채팅 | query + push 갱신 또는 별도 stream | polling만으로 부족 |
숫자는 정답이 아니라 freshness 요구와 서버 부하 사이의 정책이다.
로딩과 background refetch를 구분한다
첫 데이터가 없는 pending 상태와 기존 데이터가 있는 refetch 상태는 사용자 경험이 다르다.
function ProductPage({ productId }: Props) {
const query = useQuery({
queryKey: productKeys.detail(productId),
queryFn: ({ signal }) => fetchProduct(productId, signal),
});
if (query.isPending) {
return <ProductSkeleton />;
}
if (query.isError) {
return <ProductError onRetry={() => query.refetch()} />;
}
return (
<>
{query.isFetching && (
<span aria-live="polite">최신 정보 확인 중</span>
)}
<ProductDetail product={query.data} />
</>
);
}
cache data가 있는데 refetch마다 전체 화면 skeleton으로 바꾸면 내용이 깜빡이고 사용자의 작업 맥락이 사라진다. 기존 데이터를 유지하면서 작은 갱신 표시를 제공한다.
status와 fetchStatus도 목적이 다르다. query가 아직 data가 없는 pending인지와 네트워크 fetch가 실제 진행 중인지 구분한다. enabled: false인 dependent query는 pending이면서 fetchStatus는 idle일 수 있다.
“처음 진입”, “cached data 표시 중”, “background refetch”, “첫 요청 실패”, “refetch 실패”를 같은 loading boolean 하나로 표현하지 않는다.
query 결과를 local state에 복사하면 생기는 문제
query data를 Effect로 local state에 복사하는 코드를 자주 본다.
const productQuery = useQuery({
queryKey: productKeys.detail(productId),
queryFn: fetchProduct,
});
const [product, setProduct] = useState<Product | null>(null);
useEffect(() => {
if (productQuery.data) {
setProduct(productQuery.data);
}
}, [productQuery.data]);
source of truth가 둘이 된다.
- background refetch 후 Effect가 실행되기 전 한 렌더는 오래된 local 값이다.
- 사용자가 local 값을 편집하면 서버 snapshot과 draft 의미가 섞인다.
- query invalidation과 local reset을 모두 관리해야 한다.
- data가
undefined로 돌아갈 때 local 값을 어떻게 할지 애매하다.
표시만 한다면 query data를 직접 사용한다.
const product = productQuery.data;
서버 데이터를 초기값으로 하는 편집 draft가 필요하다면 source가 바뀌는 경계를 명확히 한다.
function ProductEditRoute({ productId }: Props) {
const query = useQuery({
queryKey: productKeys.detail(productId),
queryFn: fetchProduct,
});
if (!query.data) return <ProductSkeleton />;
return (
<ProductEditForm
key={productId}
initialProduct={query.data}
/>
);
}
Form은 mount 시 snapshot을 draft로 복사하고 이후 background refetch가 사용자 입력을 덮지 않게 한다. 동시에 서버 version을 보관해 제출 시 충돌을 감지할 수 있다.
type ProductDraft = {
name: string;
price: number;
basedOnVersion: number;
};
mutation 이후에는 관련 query를 무효화한다
상품 수정 mutation이 성공해도 cache가 자동으로 서버 변경을 추론하지는 못한다.
const queryClient = useQueryClient();
const updateProductMutation = useMutation({
mutationFn: updateProduct,
onSuccess: async (_result, variables) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: productKeys.detail(variables.productId),
}),
queryClient.invalidateQueries({
queryKey: productKeys.lists(),
}),
]);
},
});
상세와 목록이 모두 상품명을 표시하므로 둘 다 stale로 표시하고 활성 query를 refetch한다.
서버 응답이 완전한 최신 Product를 반환한다면 상세 cache를 직접 갱신할 수 있다.
onSuccess: (updatedProduct) => {
queryClient.setQueryData(
productKeys.detail(updatedProduct.id),
updatedProduct,
);
void queryClient.invalidateQueries({
queryKey: productKeys.lists(),
});
}
목록의 filter와 정렬에서 해당 상품 위치가 바뀔 수 있으므로 모든 list cache를 손으로 정확히 수정하는 것보다 invalidation이 안전할 수 있다.
다음 글인 Optimistic Update와 롤백 설계에서는 응답 전에 cache를 바꾸는 경우의 경쟁과 복구를 다룬다.
조건부 query와 request waterfall
이전 query 결과가 있어야 다음 query를 실행할 수 있다면 enabled를 사용할 수 있다.
const userQuery = useQuery({
queryKey: ["user", email],
queryFn: ({ signal }) => fetchUserByEmail(email, signal),
});
const projectsQuery = useQuery({
queryKey: ["projects", userQuery.data?.id],
queryFn: ({ signal }) =>
fetchProjects(userQuery.data!.id, signal),
enabled: userQuery.data?.id !== undefined,
});
동작은 하지만 네트워크가 직렬화된다.
user 요청 █████
project 요청 █████
총 시간 ██████████
가능하면 backend가 email로 프로젝트까지 조회하거나 두 요청에 필요한 정보를 처음부터 제공해 waterfall을 평평하게 만든다.
const projectsQuery = useQuery({
queryKey: ["projects-by-user-email", email],
queryFn: ({ signal }) =>
fetchProjectsByUserEmail(email, signal),
});
컴포넌트가 깊게 mount된 뒤에야 query가 시작되는 nested waterfall도 router prefetch나 서버 렌더링으로 줄일 수 있다. Query library가 요청 상태를 관리해도 잘못된 API 의존성을 자동으로 병렬화하지는 않는다.
오류와 재시도 정책을 업무에 맞춘다
공식 기본값에서는 실패 query가 여러 번 재시도될 수 있다. 일시적 네트워크 오류에는 유용하지만 모든 오류가 재시도 가능한 것은 아니다.
useQuery({
queryKey: productKeys.detail(productId),
queryFn: fetchProduct,
retry: (failureCount, error) => {
if (error instanceof NotFoundError) return false;
if (error instanceof UnauthorizedError) return false;
return failureCount < 2 && isTransient(error);
},
});
- 400 validation 실패: 같은 요청 재시도 불필요
- 401/403: 인증 갱신 또는 권한 안내
- 404: 없는 resource UI
- 429:
Retry-After와 rate limit 정책 - 5xx/네트워크: 제한적 backoff 재시도
재시도가 진행 중인 동안 isPending이 길어질 수 있으므로 사용자에게 무한 loading처럼 보이지 않게 한다.
background refetch가 실패해도 기존 data는 있을 수 있다. 전체 화면 오류로 바꾸기보다 stale data와 갱신 실패 안내를 함께 보여 줄 수 있다.
{query.isRefetchError && (
<InlineAlert>
최신 정보를 불러오지 못했습니다.
<button onClick={() => query.refetch()}>다시 시도</button>
</InlineAlert>
)}
SSR에서는 요청별 QueryClient를 만든다
서버에서 하나의 전역 QueryClient를 모든 요청에 공유하면 사용자별 query cache가 섞일 수 있다.
// 위험한 예: 서버 프로세스 전역 singleton
export const queryClient = new QueryClient();
요청마다 client를 만들고 서버에서 필요한 query를 prefetch한 뒤 dehydrated state만 클라이언트로 전달한다.
export async function createProductsPageState(
filters: ProductFilters,
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
},
},
});
await queryClient.prefetchQuery({
queryKey: productKeys.list(filters),
queryFn: () => fetchProducts(filters),
});
return dehydrate(queryClient);
}
정확한 prefetch API는 사용하는 TanStack Query 메이저 버전과 framework integration을 확인한다.
Client tree는 HydrationBoundary에서 cache를 복원한다.
<HydrationBoundary state={dehydratedState}>
<ProductPage filters={filters} />
</HydrationBoundary>
서버와 클라이언트의 query key와 query function 의미가 일치해야 한다. 서버 fetch 시각을 기준으로 stale 여부가 계산되며, 기본 staleTime이 0이면 hydration 직후 background refetch가 일어날 수 있다.
dehydrated cache에 비밀값, 다른 사용자의 데이터, 클라이언트에 노출하면 안 되는 내부 필드를 넣지 않는다. HTML payload에 들어간 데이터는 브라우저가 읽을 수 있다.
서버 상태와 클라이언트 상태의 경계
TanStack Query가 모든 state를 대체하지는 않는다.
Query cache에 두기 좋은 것:
- API 응답 snapshot
- 페이지별 목록과 상세
- 서버 검색 결과
- mutation과 refetch 상태
React local state에 두기 좋은 것:
- modal open
- input draft
- 선택 중인 tab
- hover와 focus
URL에 두기 좋은 것:
- 공유할 검색어
- 정렬과 pagination
- 뒤로가기로 복원할 filter
전역 client store에 둘 후보:
- 여러 화면의 복잡한 편집 session
- 고빈도 선택 구독이 필요한 canvas/editor
- 서버와 무관한 앱 수준 workflow
서버에서 온 데이터라는 이유로 query cache에 form draft를 직접 수정하면 background refetch와 충돌할 수 있다. 반대로 API data를 Redux에 복사하면 query가 제공하는 freshness와 invalidation을 이중 구현한다.
Devtools와 운영 지표로 확인하기
TanStack Query Devtools에서 다음을 확인할 수 있다.
- query key 구조
- fresh/stale/inactive 상태
- observer 수
- 마지막 update 시각
- error와 fetch 상태
- invalidation 범위
운영에서는 library 내부 이벤트를 과도하게 로그로 남기기보다 사용자 영향 지표를 본다.
- endpoint별 요청 수와 cache hit로 회피된 호출
- refetch 실패율
- query latency p95
- navigation 후 content 표시 시간
- request waterfall
- mutation 성공 후 stale UI 신고
- retry 횟수와 429 비율
query key에 user 입력 전체나 개인정보를 넣으면 Devtools와 로그에 노출될 수 있다. key에는 cache 구분에 필요한 안정적인 식별자만 포함하고 민감한 token을 넣지 않는다.
- 같은 화면에서 동일 query가 불필요하게 여러 key로 나뉘지 않는가?
- 결과를 바꾸는 filter가 key에서 빠지지 않았는가?
- 모든 query가 기본 staleTime 0으로 과도하게 refetch되지 않는가?
- mutation 후 상세과 목록 cache가 함께 갱신되는가?
- 로그아웃 때 사용자별 cache가 정리되는가?
- SSR QueryClient가 요청 사이에 공유되지 않는가?
정리
서버 상태를 따로 관리하는 이유는 데이터가 전역이라서가 아니라 소유권과 시간 개념이 다르기 때문이다. 브라우저가 가진 것은 서버 원본의 snapshot이며, 그 snapshot에는 freshness, fetch, error, invalidation 상태가 따라온다.
- query key에 결과를 바꾸는 모든 입력을 포함한다.
staleTime은 freshness,gcTime은 inactive cache 보관 시간이다.- 첫 loading과 background refetch를 구분한다.
- query data를 local state에 불필요하게 복사하지 않는다.
- mutation 후 관련 key를 갱신하거나 invalidation한다.
- retry와 refetch 정책을 오류 종류와 업무 위험에 맞춘다.
- SSR에서는 요청별 QueryClient와 안전한 hydration 경계를 사용한다.
TanStack Query는 fetch 함수를 대신 호출하는 Hook이 아니라 서버 snapshot의 정체성, freshness, 생명주기를 관리하는 cache 계층이다.