TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때

2026.05.08·수정 2026.07.20·약 30분

TanStack Query mutation 성공 후 화면이 그대로일 때 확인할 핵심

서버의 수정 요청이 성공했는데 목록이 그대로라면 무조건 강제 새로고침부터 넣을 문제가 아닙니다. 읽기에 사용한 queryKey와 무효화한 key가 같은지, invalidateQueries가 현재 active 쿼리를 다시 요청했는지, mutation 콜백을 기다렸는지, 쿼리가 enabled: false 또는 staleTime: 'static' 상태인지, 캐시 값과 컴포넌트가 관찰하는 값이 실제로 달라졌는지를 순서대로 확인해야 합니다.

문서 기준: 이 글의 TanStack Query 동작 설명은 React v5 최신 공식 문서를 2026-07-19에 확인해 작성했습니다.

증상: 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를 사용한다

QueryClientQueryCache를 포함합니다. 화면의 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가 줄어듭니다. 하지만 일반적인 숫자 staleTimeInfinity는 수동 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의 invalidateQueriesrefetchQueries 호출이 일반적으로 유발할 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를 분리해서 확인한다

  1. mutation 응답을 확인합니다. 성공 코드만 보지 말고 실제 저장된 ID와 변경 필드가 기대한 값인지 확인합니다.
  2. Devtools에서 화면이 읽는 전체 queryKey와 client 경계를 확인합니다. mutation 콜백의 key와 도메인명, 배열 순서, 변수 타입을 비교하고 같은 provider의 QueryClient인지 봅니다.
  3. 후속 query 요청이 생겼는지 확인합니다. 요청이 없다면 active/inactive와 구독 상태, refetchType, enabled, static staleTime 순서로 봅니다.
  4. 후속 응답을 확인합니다. refetch는 실행됐지만 서버가 이전 값을 반환하면 query cache보다 API 응답 경로를 먼저 조사합니다.
  5. 정확한 key의 캐시를 직접 읽습니다. getQueryDatagetQueryStatedataUpdatedAt으로 다른 캐시를 보고 있지 않은지 확인합니다.
  6. 화면이 관찰하는 값을 확인합니다. 새 캐시는 들어왔는데 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-07-19에 접근 상태를 확인했습니다.

같이 읽으면 좋은 내부 글

결론: 강제 렌더링보다 데이터 흐름의 끊긴 지점을 찾는다

TanStack Query에서 서버 데이터 변경 후 UI가 그대로인 문제는 대개 React를 강제로 다시 렌더링해서 해결할 일이 아닙니다. mutation이 성공한 뒤 현재 화면이 읽는 queryKey를 정확히 무효화하고, active observer가 refetch할 수 있는 상태인지 확인하며, 새 응답이 정확한 캐시에 들어왔는지 관찰하면 원인을 단계적으로 좁힐 수 있습니다.

가장 먼저 key를 비교하고, 그다음 invalidateQueries의 active/inactive 범위와 mutation Promise, enabledstaleTime 예외를 확인하세요. 마지막으로 structural sharing을 오류로 단정하지 말고 실제 캐시 데이터와 컴포넌트가 선택한 값이 달라졌는지를 확인하면 됩니다. 이 순서를 지키면 불필요한 refetchType: 'all', 강제 새로고침, structural sharing 비활성화 없이도 갱신 문제를 재현하고 설명할 수 있습니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기