먼저 읽기: TanStack Query staleTime gcTime 차이: 캐시 시간 기준 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
TanStack Query mutation 성공 후 화면이 그대로일 때 확인할 핵심
서버의 수정 요청이 성공했는데 목록이 그대로라면 무조건 강제 새로고침부터 넣을 문제가 아닙니다. 읽기에 사용한 queryKey와 무효화한 key가 같은지, invalidateQueries가 현재 active 쿼리를 다시 요청했는지, mutation 콜백을 기다렸는지, 쿼리가 enabled: false 또는 staleTime: 'static' 상태인지, 캐시 값과 컴포넌트가 관찰하는 값이 실제로 달라졌는지를 순서대로 확인해야 합니다.
문서 기준: 이 글의 TanStack Query 동작 설명은 React v5 최신 공식 문서를 2026-09-12에 확인해 작성했습니다.
await invalidation 이후에도 후속 조회의 성공 여부를 확인하기
invalidateQueries의 Promise를 기다리면 관련 재조회가 정리될 때까지 mutation pending을 유지할 수 있습니다. 다만 기본 설정은 refetch 오류를 호출자에게 throw하지 않습니다. 따라서 mutation 성공 알림만으로 새 목록 수신까지 성공했다고 단정하지 않습니다. 기존 data가 남아 있는 refetch 오류는 전체 목록을 지우기보다 목록 위에 갱신 실패를 표시하고 재시도를 제공합니다.
await queryClient.invalidateQueries(
{ queryKey: productKeys.lists() },
{ throwOnError: true },
)throwOnError:true를 쓰면 후속 읽기 실패도 호출자에게 전달할 수 있습니다. 그러나 서버 쓰기는 이미 성공했을 수 있으므로 이를 “저장 실패”라고 표시하고 쓰기부터 재시도하면 중복 변경이 생깁니다. 저장 단계와 갱신 단계를 별도 상태로 표현하는 선택이 필요합니다. 읽기 API가 이전 값을 반환할 때는 eventual consistency, HTTP/CDN 캐시, 잘못 연결된 데이터 원본도 조사합니다. QueryClient 캐시 삭제만으로 원본의 지연을 해결할 수는 없습니다.
enabled:false 설명은 해당 query를 구독한 다른 enabled observer가 없다는 기준입니다. 같은 key의 활성 observer가 별도로 있으면 그 observer가 재조회를 유발할 수 있습니다. static도 일반적인 invalidation 기반 재조회를 막지만 hook이 반환하는 refetch의 직접 호출이나 setQueryData까지 막지는 않습니다. dataUpdatedAt은 진단 단서이며 같은 밀리초에 처리될 수 있으므로 값 변화의 유일한 판정 기준으로 삼지 않습니다.
증상: mutation 성공과 화면 갱신은 다른 단계다
대표 증상은 수정 버튼을 누른 뒤 API가 200 또는 204로 끝나고 mutation.isSuccess도 참이 되지만, 화면에는 이전 상품명이나 이전 상태가 남는 경우입니다. 여기서 mutation 성공은 쓰기 요청이 성공했다는 뜻입니다. 그 사실만으로 기존 query 캐시가 자동으로 어떤 항목과 연결되어 있는지 알 수는 없습니다. 관련 캐시를 무효화하거나 mutation 응답으로 직접 갱신하는 단계가 별도로 필요합니다.
문제를 빠르게 좁히려면 아래 네 지점을 한 덩어리로 보지 않는 것이 중요합니다. 쓰기 응답, 후속 조회 요청, query cache, 컴포넌트가 실제로 사용하는 값 중 어디까지 새 값이 도착했는지를 확인합니다.
| 관찰 지점 | 확인할 내용 | 여기서 멈췄을 때 의심할 것 |
|---|---|---|
| mutation 응답 | 서버가 실제 변경된 값을 반환했는가 | API 검증·저장 로직·권한 |
| 후속 query 요청 | mutation 뒤 목록 GET이 실행됐는가 | queryKey·active/inactive·enabled |
| TanStack Query 캐시 | 해당 key의 데이터와 dataUpdatedAt이 바뀌었는가 | 응답 변환·잘못 본 key·직접 캐시 수정 |
| 컴포넌트 출력 | 화면이 사용하는 필드 또는 select 결과가 바뀌었는가 | select·파생 값·변경되지 않은 참조 |
원인: queryKey부터 structural sharing까지
1. 읽은 queryKey와 무효화한 queryKey가 다르다
TanStack Query는 queryKey로 캐시를 식별합니다. query 함수가 카테고리, 사용자 ID, 페이지 같은 변수에 의존하면 그 변수도 key에 포함해야 서로 다른 응답이 독립적으로 캐시됩니다. 공식 Query Keys 문서도 queryFn이 의존하는 변경 변수를 key에 넣도록 설명합니다.
화면이 ['products', { category: 'books' }]를 읽는데 mutation이 ['product']를 무효화하면 서로 다른 캐시입니다. 오타뿐 아니라 단수·복수, key 요소 순서, ID의 문자열·숫자 차이도 실제 key를 비교해서 확인해야 합니다. invalidateQueries({ queryKey: ['products'] })처럼 prefix로 목록 계층 전체를 대상으로 할 수도 있고, exact: true로 정확히 같은 key만 대상으로 할 수도 있습니다. key 계층 설계가 낯설다면 내부 글 TanStack Query queryKey 설계 기준을 함께 보면 기준을 잡기 쉽습니다.
2. 읽기와 무효화가 서로 다른 QueryClient를 사용한다
QueryClient는 QueryCache를 포함합니다. 화면의 useQuery가 가까운 QueryClientProvider의 client A를 읽는데 mutation이 별도로 만든 client B에서 invalidateQueries를 호출하면, key가 같아도 서로 다른 캐시를 다루므로 화면은 갱신되지 않습니다. 중첩 provider, Storybook·테스트 wrapper, 마이크로 프런트엔드 경계에서 특히 확인해야 합니다.
공식 QueryClientProvider 문서는 하나의 client 인스턴스를 provider에 전달하는 구조를 보여 줍니다. 공식 Stable Query Client 규칙도 앱 생명주기 동안 안정적인 인스턴스를 사용하고 렌더마다 새로 만들지 않도록 안내합니다. 비동기 Server Component에서 요청 범위로 만드는 예외와 브라우저 앱의 장기 client를 혼동하지 않습니다.
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient()
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Products category="books" />
</QueryClientProvider>
)
}
3. invalidateQueries가 모든 캐시를 즉시 다시 요청한다고 생각했다
invalidateQueries는 일치한 쿼리를 stale로 표시합니다. 현재 useQuery 등으로 렌더링 중인 active 쿼리는 기본적으로 백그라운드 refetch됩니다. 그러나 inactive 쿼리까지 기본으로 즉시 refetch되는 것은 아닙니다. 이는 공식 Query Invalidation 가이드와 QueryClient API의 동작입니다.
| refetchType | refetch 대상 | 사용 판단 |
|---|---|---|
active |
현재 active인 일치 쿼리 | 기본값이며 현재 화면 갱신에 보통 충분합니다. |
inactive |
inactive인 일치 쿼리만 | 숨겨진 캐시만 미리 갱신해야 할 때 제한적으로 씁니다. |
all |
active와 inactive 모두 | 후속 화면 캐시까지 즉시 최신화해야 하는 요구가 있을 때 씁니다. |
none |
refetch하지 않음 | 일치 캐시를 invalid 상태로만 표시합니다. |
따라서 다른 탭이나 아직 열지 않은 화면의 캐시까지 지금 당장 새로 받아야 한다는 요구가 아니라면 무조건 refetchType: 'all'을 붙일 필요는 없습니다. 먼저 현재 화면의 쿼리가 active인지, key가 맞는지 확인하는 편이 정확합니다.

4. mutation 콜백에서 invalidation 완료를 기다리지 않았다
서버 쓰기가 성공했을 때 관련 query를 갱신하려면 onSuccess가 가장 직접적인 위치입니다. TanStack Query 공식 Invalidations from Mutations 문서는 onSuccess에서 invalidateQueries를 호출하고 그 Promise를 반환하거나 await하는 패턴을 안내합니다. Promise가 끝날 때까지 isPending이 유지되므로 저장 완료 UI와 최신 데이터 수신 시점을 맞추기 쉽습니다.
onSettled는 성공과 오류 뒤에 모두 실행해야 하는 정리 작업이 있을 때 선택할 수 있습니다. 성공한 쓰기 뒤에만 관련 데이터를 무효화하려는 목적이라면 onSuccess에 두면 실패 요청 때문에 불필요한 refetch가 생기는 일을 줄일 수 있습니다.
5. staleTime이 길어서 무효화도 막힌다고 오해했다
staleTime은 데이터가 fresh로 간주되는 시간을 정합니다. fresh인 동안에는 mount, window focus 같은 staleness 기반 refetch가 줄어듭니다. 하지만 일반적인 숫자 staleTime과 Infinity는 수동 invalidateQueries로 무효화할 수 있습니다. 즉 명시적 invalidation 뒤에도 화면이 그대로라면 무조건 staleTime: 0으로 바꾸기 전에 key와 observer 상태를 먼저 확인해야 합니다.
최신 공식 Important Defaults에는 예외도 있습니다. staleTime: 'static'은 수동 invalidation에도 refetch되지 않도록 설계된 값입니다. 앱 실행 중 바뀔 수 있는 상품·주문 데이터에 'static'을 사용했다면 의도와 맞는지 다시 검토해야 합니다. stale과 캐시 보존 시간의 구분은 내부 글 TanStack Query staleTime과 gcTime 차이에서 더 자세히 볼 수 있습니다.
6. 쿼리가 enabled false라서 refetch를 무시한다
enabled: false인 쿼리는 mount 시 자동 fetch와 백그라운드 refetch를 하지 않으며, query client의 invalidateQueries와 refetchQueries 호출이 일반적으로 유발할 refetch도 무시합니다. 이는 공식 Disabling/Pausing Queries 문서에 명시된 동작입니다.
필터 값이 준비되기 전까지만 막으려는 목적이라면 enabled: Boolean(category)처럼 실제 의존 조건과 연결합니다. 계속 비활성화한 채 mutation 뒤 invalidation만 호출하면 캐시는 invalid 상태가 될 수 있어도 해당 observer의 자동 refetch는 일어나지 않습니다. useQuery가 돌려준 refetch로 수동 요청할 수 있지만, skipToken을 사용한 쿼리에는 이 수동 refetch도 적용되지 않습니다.
7. structural sharing과 실제 캐시 변경을 혼동했다
TanStack Query는 JSON 호환 응답에 structural sharing을 적용합니다. 새 응답이 이전 데이터와 같으면 기존 data 참조를 유지하고, 일부만 바뀌면 바뀌지 않은 부분의 참조를 최대한 보존합니다. 이는 화면 갱신 실패를 만드는 캐시 오류가 아니라 불필요한 렌더링을 줄이는 기본 최적화입니다. 자세한 동작은 공식 Render Optimizations 문서에서 확인할 수 있습니다.
예를 들어 컴포넌트가 select: (products) => products.length만 구독한다면 상품 이름이 바뀌어도 길이는 같습니다. 이 컴포넌트가 다시 렌더링되지 않는 것은 선택한 값이 달라지지 않았기 때문입니다. 반대로 mutation 응답을 setQueryData로 넣는다면 이전 캐시 객체를 직접 수정하지 말고 새 객체와 새 배열을 반환해야 합니다. 공식 Updates from Mutation Responses도 직접 캐시 갱신을 불변 방식으로 수행하도록 요구합니다.
재현 코드: 다른 queryKey를 무효화하면 UI는 그대로다
아래 예시는 목록 조회가 ['products', { category }]를 사용하는데, mutation 성공 뒤에는 단수형 ['product']를 무효화합니다. 쓰기 요청은 성공해도 현재 화면이 관찰하는 목록 캐시는 일치하지 않으므로 후속 목록 refetch가 시작되지 않습니다.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
type Product = {
id: number
name: string
}
async function fetchProducts(category: string): Promise<Product[]> {
const response = await fetch(
'/api/products?category=' + encodeURIComponent(category),
)
if (!response.ok) {
throw new Error('상품 목록 조회 실패')
}
return response.json()
}
async function updateProductName(input: {
id: number
name: string
}): Promise<Product> {
const response = await fetch('/api/products/' + input.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: input.name }),
})
if (!response.ok) {
throw new Error('상품 수정 실패')
}
return response.json()
}
export function BrokenProducts({ category }: { category: string }) {
const queryClient = useQueryClient()
const productsQuery = useQuery({
queryKey: ['products', { category }],
queryFn: () => fetchProducts(category),
})
const renameMutation = useMutation({
mutationFn: updateProductName,
onSuccess: () => {
// 잘못된 key: 현재 목록 query와 일치하지 않는다.
return queryClient.invalidateQueries({
queryKey: ['product'],
})
},
})
if (productsQuery.isPending) {
return <p>목록을 불러오는 중입니다.</p>
}
if (productsQuery.isError) {
return <p>목록을 불러오지 못했습니다.</p>
}
const firstProduct = productsQuery.data[0]
return (
<section>
<p>현재 이름: {firstProduct?.name ?? '상품 없음'}</p>
<button
disabled={!firstProduct || renameMutation.isPending}
onClick={() => {
if (firstProduct) {
renameMutation.mutate({
id: firstProduct.id,
name: '변경된 상품명',
})
}
}}
>
이름 변경
</button>
</section>
)
}
수정 코드: key factory를 공유하고 invalidation 완료를 기다린다
수정의 핵심은 조회와 무효화가 같은 key factory를 사용하게 만드는 것입니다. 아래 코드에서 목록 query는 productKeys.list(category)를 사용하고, mutation은 상위 prefix인 productKeys.lists()를 무효화합니다. 현재 화면의 목록은 active이므로 기본값과 같은 refetchType: 'active'로 다시 요청됩니다. await를 사용해 refetch Promise가 끝날 때까지 mutation의 pending 상태도 유지합니다.
const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (category: string) =>
[...productKeys.lists(), { category }] as const,
}
export function Products({ category }: { category: string }) {
const queryClient = useQueryClient()
const productsQuery = useQuery({
queryKey: productKeys.list(category),
queryFn: () => fetchProducts(category),
enabled: category.length > 0,
staleTime: 60_000,
})
const renameMutation = useMutation({
mutationFn: updateProductName,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: productKeys.lists(),
refetchType: 'active',
})
},
})
if (category.length === 0) {
return <p>카테고리를 선택해 주세요.</p>
}
if (productsQuery.isPending) {
return <p>목록을 불러오는 중입니다.</p>
}
if (productsQuery.isError) {
return <p>목록을 불러오지 못했습니다.</p>
}
return (
<section>
<ul>
{productsQuery.data.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
<button
disabled={
productsQuery.data.length === 0 ||
renameMutation.isPending
}
onClick={() => {
const firstProduct = productsQuery.data[0]
if (firstProduct) {
renameMutation.mutate({
id: firstProduct.id,
name: '변경된 상품명',
})
}
}}
>
{renameMutation.isPending ? '저장 및 갱신 중' : '이름 변경'}
</button>
</section>
)
}
mutation 응답에 최신 상품 객체가 이미 들어 있고 네트워크 요청을 한 번 줄이는 것이 더 중요하다면 setQueryData로 detail 캐시를 즉시 갱신할 수도 있습니다. 이 경우에도 목록 정렬, 필터 포함 여부처럼 서버만 확정할 수 있는 결과가 있다면 관련 목록을 invalidation해 서버 결과와 다시 맞추는 편이 안전합니다.

v4·v5 문법을 구분하고 Devtools로 실제 캐시를 확인한다
검색 결과나 오래된 블로그의 호출 형태를 그대로 섞지 않습니다. TanStack Query v5는 query 관련 API에 단일 객체 시그니처를 사용합니다. 공식 v5 마이그레이션 문서의 과거 v4 예시는 버전 차이를 식별하는 용도로만 보고, 현재 v5 코드에는 invalidateQueries({ queryKey, ...filters }, options) 형태를 사용합니다.
// v4 이하의 과거 호출 예시: 현재 v5 코드에 복사하지 않습니다.
queryClient.invalidateQueries(productKeys.lists())
// v5: query filters를 한 객체로 전달합니다.
await queryClient.invalidateQueries({
queryKey: productKeys.lists(),
refetchType: 'active',
})
공식 React Query Devtools 문서에 따라 별도 패키지를 설치하고, 앱이 사용하는 QueryClientProvider 안에 Devtools를 둡니다. custom client를 따로 넘기지 않으면 가장 가까운 context의 client를 사용하므로 화면과 같은 캐시를 관찰하기 쉽습니다. v5 Devtools는 query뿐 아니라 mutation도 관찰할 수 있습니다.
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Products category="books" />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
Devtools에서 먼저 화면이 읽는 정확한 query key, active 여부와 구독 상태, stale·fetching 상태, 마지막 갱신 시각을 확인합니다. mutation 항목이 성공했는데 대상 query가 invalid 상태로도 바뀌지 않으면 key나 QueryClient가 다른지 의심합니다. invalid가 되었지만 fetch가 시작되지 않으면 active/inactive, enabled, refetchType, staleTime: 'static'을 봅니다. 네트워크 응답과 캐시는 새 값인데 화면만 같으면 select와 표시 필드를 조사합니다.
진단 순서: 요청·캐시·observer를 분리해서 확인한다
- mutation 응답을 확인합니다. 성공 코드만 보지 말고 실제 저장된 ID와 변경 필드가 기대한 값인지 확인합니다.
- Devtools에서 화면이 읽는 전체 queryKey와 client 경계를 확인합니다. mutation 콜백의 key와 도메인명, 배열 순서, 변수 타입을 비교하고 같은 provider의 QueryClient인지 봅니다.
- 후속 query 요청이 생겼는지 확인합니다. 요청이 없다면 active/inactive와 구독 상태, refetchType, enabled, static staleTime 순서로 봅니다.
- 후속 응답을 확인합니다. refetch는 실행됐지만 서버가 이전 값을 반환하면 query cache보다 API 응답 경로를 먼저 조사합니다.
- 정확한 key의 캐시를 직접 읽습니다.
getQueryData와getQueryState의dataUpdatedAt으로 다른 캐시를 보고 있지 않은지 확인합니다. - 화면이 관찰하는 값을 확인합니다. 새 캐시는 들어왔는데 UI만 같다면
select결과, 표시 필드, 불변 갱신 여부를 봅니다.
import type { QueryClient } from '@tanstack/react-query'
async function inspectProductListCache(
queryClient: QueryClient,
category: string,
) {
const key = productKeys.list(category)
console.log('무효화 전 데이터', queryClient.getQueryData<Product[]>(key))
console.log(
'무효화 전 갱신 시각',
queryClient.getQueryState(key)?.dataUpdatedAt,
)
await queryClient.invalidateQueries({
queryKey: key,
exact: true,
refetchType: 'active',
})
console.log('무효화 후 데이터', queryClient.getQueryData<Product[]>(key))
console.log(
'무효화 후 갱신 시각',
queryClient.getQueryState(key)?.dataUpdatedAt,
)
}
| 관찰 결과 | 다음 확인 지점 |
|---|---|
| mutation부터 실패 | 서버 저장·검증·권한 |
| mutation 성공, 후속 GET 없음 | queryKey·active 상태·enabled·static |
| 후속 GET 있음, 응답이 이전 값 | API가 읽는 데이터 원본과 응답 캐시 |
| 응답과 query cache는 새 값, UI만 이전 값 | select 결과·표시 필드·직접 캐시 수정 |
재발 방지 체크리스트
- query key factory를 한 곳에 정의하고 조회·무효화·직접 갱신에서 함께 사용합니다.
- queryFn이 사용하는 category, userId, page 같은 변경 변수를 queryKey에도 포함합니다.
- prefix 무효화와
exact: true중 필요한 범위를 의도적으로 선택합니다. - 현재 화면만 갱신하면 기본
active, 숨겨진 캐시도 즉시 필요하면all을 검토합니다. - 성공한 쓰기 뒤 invalidation은
onSuccess에서 Promise를 반환하거나 await합니다. - 긴 staleTime 자체를 원인으로 단정하지 않고 explicit invalidation 여부를 먼저 확인합니다.
staleTime: 'static'이 변경 가능한 서버 데이터에 적용되지 않았는지 확인합니다.enabled: false또는skipToken때문에 자동·수동 refetch가 막히지 않았는지 확인합니다.setQueryData에서는 기존 객체나 배열을 직접 수정하지 않고 새 값을 반환합니다.- structural sharing을 끄기 전에 정확한 key의 캐시 값과
dataUpdatedAt을 먼저 관찰합니다. - 테스트에서 mutation 뒤 최신 값이 화면에 나타날 때까지 기다리고, 단순히 성공 알림만 검사하지 않습니다.
확인한 공식 문서와 관련 글
아래 공식 문서는 모두 TanStack Query React v5 최신 문서이며 2026-09-12에 접근 상태를 확인했습니다.
- Query Keys
- Query Invalidation
- QueryClientProvider
- Stable Query Client
- Migrating to v5
- React Query Devtools
- QueryClient API
- Invalidations from Mutations
- Important Defaults
- Disabling/Pausing Queries
- Render Optimizations
- Updates from Mutation Responses
같이 읽으면 좋은 내부 글
- TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
- TanStack Query staleTime gcTime 차이: 캐시 시간 기준
- TanStack Query와 Zustand 상태 구분 기준
같은 쓰기 성공에서 정상 키와 잘못된 키를 비교하는 진단 실험
잘못된 키 모드에서 이름 변경을 누릅니다. 쓰기는 변경된 상품명인데 목록은 이전 상품명, 읽기 호출은 1인 상태가 실패 재현입니다. 정상 키로 재시작하여 같은 조작을 하면 목록이 변경되고 읽기는 2가 됩니다. 네트워크 강제 새로고침 없이 끊어진 key 연결만 고칩니다.
Node.js 22.12 이상(또는 20.19 이상)에서 압축을 푼 폴더로 이동합니다. 외부 API 키는 필요 없습니다. fixture 값은 새로고침·프로세스 종료 후 보존되지 않습니다.
npm install
npm test
npm run build
npm run devHTML 진입점·CSS·JavaScript 파일을 분리했습니다. 본문의 기존 API 코드가 설명하는 실제 서버와 ZIP의 메모리 fixture는 다른 실행 범위입니다.
App.jsx
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
App.jsx
import React, { useState } from 'react';
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { createStore, keys } from './model.js';
const client = new QueryClient();
export function Diagnosis({ store, broken }) {
const qc = useQueryClient();
const query = useQuery({
queryKey: keys.list('books'),
queryFn: store.read,
staleTime: Infinity,
});
const mutation = useMutation({
mutationFn: store.rename,
onSuccess: () =>
qc.invalidateQueries({ queryKey: broken ? ['product'] : keys.lists }),
});
return (
<section>
<p>캐시/화면: {query.data?.[0].name ?? '대기'}</p>
<button
disabled={mutation.isPending || !query.data}
onClick={() => mutation.mutate()}
>
이름 변경
</button>
{mutation.isSuccess && <p>쓰기 성공: {mutation.data.name}</p>}
{mutation.isError && <p role="alert">쓰기 실패</p>}
<p>읽기 호출: {store.count()}</p>
</section>
);
}
export default function App() {
const [broken, setBroken] = useState(true);
const [store, setStore] = useState(() => createStore());
function reset() {
client.clear();
setStore(createStore());
setBroken(!broken);
}
return (
<QueryClientProvider client={client}>
<main>
<h1>갱신 단절 진단</h1>
<button onClick={reset}>
현재 {broken ? '잘못된 키' : '정상 키'}: 반대로 재시작
</button>
<Diagnosis key={String(broken)} store={store} broken={broken} />
<p>쓰기 성공 문구와 목록 이름을 따로 관찰하세요.</p>
</main>
</QueryClientProvider>
);
}
diagnosis.test.jsx
// @vitest-environment jsdom
import React from 'react';
import { afterEach, expect, test } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider, QueryObserver } from '@tanstack/react-query';
import { Diagnosis } from './App.jsx';
import { createStore, keys } from './model.js';
afterEach(cleanup);
test.each([true, false])(
'write succeeds but only matching key refreshes: broken=%s',
async (broken) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
const store = createStore();
render(
<QueryClientProvider client={client}>
<Diagnosis store={store} broken={broken} />
</QueryClientProvider>,
);
await screen.findByText('캐시/화면: 이전 상품명');
fireEvent.click(screen.getByText('이름 변경'));
await screen.findByText('쓰기 성공: 변경된 상품명');
await screen.findByText(`캐시/화면: ${broken ? '이전' : '변경된'} 상품명`);
expect(store.count()).toBe(broken ? 1 : 2);
client.clear();
},
);
test('same key in another client cannot update the subscribed cache', async () => {
const a = new QueryClient();
const b = new QueryClient();
const store = createStore();
const observer = new QueryObserver(a, {
queryKey: keys.list('books'),
queryFn: store.read,
staleTime: Infinity,
});
const off = observer.subscribe(() => {});
await observer.refetch();
await store.rename();
await b.invalidateQueries({ queryKey: keys.lists });
expect(a.getQueryData(keys.list('books'))[0].name).toBe('이전 상품명');
await a.invalidateQueries({ queryKey: keys.lists });
expect(a.getQueryData(keys.list('books'))[0].name).toBe('변경된 상품명');
off();
a.clear();
b.clear();
});
test('inactive cache is invalidated without default refetch, all opts in', async () => {
const client = new QueryClient();
const store = createStore();
await client.fetchQuery({ queryKey: keys.list('books'), queryFn: store.read });
await store.rename();
await client.invalidateQueries({ queryKey: keys.lists });
expect(store.count()).toBe(1);
await client.invalidateQueries({ queryKey: keys.lists, refetchType: 'all' });
expect(store.count()).toBe(2);
client.clear();
});
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Query 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.jsx"></script>
</body>
</html>
main.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './style.css';
createRoot(document.getElementById('root')).render(<App />);
model.js
export const keys = {
lists: ['products', 'list'],
list: (category) => ['products', 'list', { category }],
};
export function createStore() {
let name = '이전 상품명';
let reads = 0;
return {
read: async () => {
reads++;
return [{ id: 1, name }];
},
rename: async () => {
name = '변경된 상품명';
return { id: 1, name };
},
count: () => reads,
};
}
package.json
{
"name": "query-lab-3062",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "5.102.8",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3"
}
}
style.css
body {
margin: 0;
color: #222;
background: #fff;
font-family: system-ui, sans-serif;
}
main {
max-width: 760px;
margin: 40px auto;
padding: 24px;
}
button,
select {
padding: 10px;
margin: 6px;
border: 1px solid #888;
background: #f5f5f5;
color: #222;
}
button:disabled {
color: #777;
}
li {
padding: 8px;
border-bottom: 1px solid #ddd;
}
pre {
padding: 16px;
background: #eee;
overflow: auto;
}
diagnosis.test.jsx
index.html
main.jsx
model.js
package.json
style.css
검증 결과와 이 실습의 경계
Vitest jsdom 환경 4개 테스트: 잘못된 키와 정상 키의 쓰기 성공/목록 차이, 다른 QueryClient 무효화의 무효과, inactive 기본 유지와 all 재조회. Vite production build도 통과했습니다.
실제 브라우저나 HTTP API를 실행한 결과는 아닙니다.
결론: 강제 렌더링보다 데이터 흐름의 끊긴 지점을 찾는다
TanStack Query에서 서버 데이터 변경 후 UI가 그대로인 문제는 대개 React를 강제로 다시 렌더링해서 해결할 일이 아닙니다. mutation이 성공한 뒤 현재 화면이 읽는 queryKey를 정확히 무효화하고, active observer가 refetch할 수 있는 상태인지 확인하며, 새 응답이 정확한 캐시에 들어왔는지 관찰하면 원인을 단계적으로 좁힐 수 있습니다.
가장 먼저 key를 비교하고, 그다음 invalidateQueries의 active/inactive 범위와 mutation Promise, enabled와 staleTime 예외를 확인하세요. 마지막으로 structural sharing을 오류로 단정하지 말고 실제 캐시 데이터와 컴포넌트가 선택한 값이 달라졌는지를 확인하면 됩니다. 이 순서를 지키면 불필요한 refetchType: 'all', 강제 새로고침, structural sharing 비활성화 없이도 갱신 문제를 재현하고 설명할 수 있습니다.
v5 공식 문서 확인
2026-09-12 공식 문서와 설치된 @tanstack/react-query 5.102.8의 실행 결과를 대조했습니다. 문서의 latest 예시는 이후 바뀔 수 있으므로 ZIP의 고정 버전과 함께 확인하세요.
연결 학습: 낙관적 업데이트와 정규화의 예외 점검
목표·선행 지식: 조회 취소 → 원본 백업 → 즉시 변경 → 오류 복원 → 서버 재확인의 흐름을 추적합니다.
onSettled의 응답 데이터는 실패 시 없을 수 있으므로 대상 ID는 variables에서도 구할 수 있어야 합니다. enabled: false인 상세 쿼리는 invalidateQueries 호출만으로 재조회되지 않습니다. 수동 정규화에서 목록은 ID만 남고 개별 캐시가 사라지는 경우도 처리해야 합니다.
직접 확인할 과제
조회 도중 수정, 수정 실패, 빠른 연속 수정, 비활성 상세 캐시 무효화를 각각 재현하세요. 전체 목록 스냅샷 롤백은 동시에 성공한 다른 수정까지 되돌릴 수 있으므로 직렬화하거나 항목별 복구·경쟁 제어가 필요합니다.
공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드
공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.
이 글이 도움이 되었나요?
TanStack Query 학습 순서
필수 11개 · 전체 12개
읽음 기록 관리
전체 과정 목차 (12개)
- 필수 길잡이 · TanStack Query vs Zustand: 서버 상태와 클라이언트 상태 차이
- 필수 학습 · TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드
- 필수 학습 · TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기
- 필수 학습 · TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기
- 필수 학습 · TanStack Query staleTime gcTime 차이: 캐시 시간 기준
- 필수 학습 · TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지
- 선택 참고 · TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때 현재 글
- 필수 학습 · TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기
- 필수 학습 · TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기
- 필수 선수 · TanStack Query Hydration 오류 해결: QueryClient 설정 기준
- 필수 학습 · TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백
- 필수 학습 · TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.