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

··설명 약 10분·작성: 해비·블로그 소개

TanStack Query에서 저장 성공 후 UI가 갱신되지 않으면 어떤 queryKey를 확인하나요?

저장 요청이 성공했는데 목록이 그대로라면 서버 저장과 Query 캐시 갱신을 따로 확인합니다. TanStack Query가 관련 캐시를 모두 추측해 바꾸지는 않으므로 영향을 받은 queryKey를 알려야 합니다.

적용 조건·범위: 현재 query가 inactive라면 바로 네트워크 요청이 발생하지 않을 수 있습니다. 다시 활성화될 때 stale 상태를 기준으로 refetch될 수 있습니다.

본문 근거: tanstack.com/query/latest/docs/framework/react/guides/query-invalidation · tanstack.com/query/latest/docs/framework/react/reference/classes/QueryClient

이 기술만 따로 연습하기

이 개념 직접 실행

버튼을 조작하고 로딩·결과·캐시의 변화를 확인합니다.

예제 크게 보기 (새 탭)

기대 결과: 원본만 변경을 눌러도 화면은 처음 값입니다. 틀린 키 items를 무효화해도 그대로이며, 맞는 item 키를 무효화하면 재조회 후 바뀐 값이 됩니다.

직접 실행: 환경 준비와 전체 파일

실행 환경은 React·TypeScript와 @tanstack/react-query 5.104.0입니다. React 환경이 없다면 React Vite 사용법 Vite 8 프로젝트 생성 실행 빌드를, 라이브러리 설치는 TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드의 설치 항목을 확인합니다. 아래는 별도 Vite 연습 앱 src/App.tsx 전체 내용입니다. src/main.tsx는 유지하며 이전 TaskFlow나 API 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.

import { useState } from 'react';
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
  useQueryClient,
  useMutation
} from '@tanstack/react-query';

const wait = (ms = 400) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const client = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

let original = '처음 값';
function Demo() {
  const queryClient = useQueryClient();
  const [notice, setNotice] = useState('');
  const query = useQuery({
    queryKey: ['item'],
    queryFn: async () => { await wait(); return original; },
    staleTime: Infinity,
  });
  const mutation = useMutation({
    mutationFn: async () => { original = '바뀐 값'; return original; },
    onSuccess: () => setNotice('원본만 바뀌었습니다'),
  });
  return (
    <main>
      <p>캐시: {query.data ?? '읽는 중'}</p>
      <p>{notice}</p>
      <button onClick={() => mutation.mutate()}>원본만 변경</button>
      <button onClick={() => queryClient.invalidateQueries({ queryKey: ['items'] })}>틀린 키 무효화</button>
      <button onClick={() => queryClient.invalidateQueries({ queryKey: ['item'] })}>맞는 키 무효화</button>
    </main>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={client}>
      <Demo />
    </QueryClientProvider>
  );
}
npm run dev
npm run build

자가 확인·변형: staleTime Infinity도 명시적 invalidation을 막는 설정은 아닙니다. items와 item을 같은 캐시 이름으로 보지 않는지 확인하세요.

검증 기록: 위 전체 코드는 타입검사·빌드와 jsdom 동작 검증을 통과했습니다. 실제 브라우저 화면과 누적 Next.js 프로젝트 통합 실행은 별도 확인 대상입니다.

서버가 바뀌어도 캐시는 자동으로 모릅니다

다음 mutation이 성공했다고 가정합니다.

useMutation({
  mutationFn: createTask,
});

POST /api/tasks가 성공해도 기존 목록 cache는 이전 데이터를 가지고 있을 수 있습니다.

서버
→ 새 작업 저장 완료


Query cache
→ 이전 목록 유지

따라서 mutation 이후 동기화 전략이 필요합니다.

가장 먼저 queryKey 확인

목록 query가 다음 키를 사용하고 있다고 가정합니다.

taskKeys.list('all')

그런데 mutation에서 다음 키를 invalidation하면 원하는 query가 포함되지 않을 수 있습니다.

queryClient.invalidateQueries({
  queryKey: ['task'],
});

tasks와 task는 완전히 다른 키입니다.

query key factory를 사용하면 이런 오타를 줄일 수 있습니다.

invalidateQueries

서버를 다시 원본으로 확인하는 가장 일반적인 방법입니다.

const queryClient =
  useQueryClient();


const mutation = useMutation({
  mutationFn: createTask,


  onSuccess: async () => {
    await queryClient
      .invalidateQueries({
        queryKey:
          taskKeys.lists(),
      });
  },
});

관련 query를 stale 상태로 만들고 active query가 서버 데이터를 다시 확인할 수 있게 합니다.

setQueryData

mutation 응답에 최신 데이터가 충분히 들어 있다면 cache를 직접 바꿀 수도 있습니다.

onSuccess: (createdTask) => {
  queryClient.setQueryData<Task[]>(
    taskKeys.list('all'),
    (oldTasks = []) => [
      ...oldTasks,
      createdTask,
    ],
  );
},

이 방식은 즉시 UI를 갱신할 수 있고 추가 GET 요청을 줄일 수 있습니다.

하지만 현재 캐시 구조를 정확히 알고 있어야 합니다.

invalidateQueries와 setQueryData 선택

서버에서 다시 계산되는 필드가 많음
관련 캐시가 여러 개임
정확한 최신 원본이 중요함
→ invalidateQueries 우선


mutation 응답이 최종 객체를 반환함
정확히 수정할 캐시를 알고 있음
추가 요청을 줄이고 싶음
→ setQueryData 검토

둘을 함께 사용할 수도 있습니다.

onSuccess: (createdTask) => {
  queryClient.setQueryData<Task[]>(
    taskKeys.list('all'),
    (old = []) => [
      ...old,
      createdTask,
    ],
  );


  void queryClient.invalidateQueries({
    queryKey: taskKeys.lists(),
  });
},

즉시 반영 후 서버와 재검증하는 방식입니다.

상세 캐시도 확인

작업 완료 mutation이 목록과 상세 양쪽에 영향을 준다면 두 캐시가 따로 존재할 수 있습니다.

['tasks', 'list', ...]
['tasks', 'detail', taskId]

mutation 응답으로 상세를 직접 갱신할 수 있습니다.

queryClient.setQueryData(
  taskKeys.detail(updatedTask.id),
  updatedTask,
);

목록은 invalidation하는 식으로 전략을 나눌 수도 있습니다.

화면이 안 바뀔 때 진단 순서

1. mutation 요청이 실제 성공했는가
2. 서버 응답 데이터가 바뀌었는가
3. 화면이 어떤 queryKey를 구독하는가
4. invalidation key가 그 query를 포함하는가
5. setQueryData의 exact key가 맞는가
6. 캐시 데이터 shape가 맞는가
7. query가 enabled:false 등으로 멈춰 있지 않은가

자주 발생하는 오류

invalidateQueries를 호출했는데 refetch가 없습니다

현재 query가 inactive라면 바로 네트워크 요청이 발생하지 않을 수 있습니다. 다시 활성화될 때 stale 상태를 기준으로 refetch될 수 있습니다.

setQueryData 후 데이터가 깨집니다

기존 cache가 Task[]인데 다른 객체 shape를 넣으면 렌더링 코드와 맞지 않습니다.

// 잘못된 예
setQueryData(key, {
  items: updatedTasks,
});

실제 queryFn 반환 타입과 같은 구조를 유지합니다.

학습 완료 체크리스트

  • mutation 성공과 cache 갱신이 별개임을 이해했습니다.
  • queryKey 불일치를 찾을 수 있습니다.
  • invalidateQueries와 setQueryData를 구분할 수 있습니다.
  • 목록과 상세 cache를 따로 동기화할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 서버 데이터의 조회·변경과 캐시를 관리합니다.

TaskFlow 저장 이후 목록이 갱신되지 않는 상황을 재현하고 queryKey 기준으로 수정합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

중간부터 시작하는 전체 실습 파일

기존 Next.js Route Handler TaskFlow의 /tasks 화면과 /api/tasks를 이어서 확장합니다. 인증 없는 demo 메모리 저장소를 사용하는 로컬 실습이며 계정·외부 DB·API 키가 필요 없습니다. MSW를 활성화하지 않습니다. 서버를 다시 시작하면 데이터가 초기화됩니다.

이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.

시작본에서 직접 바꾸기

시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.

  1. app/tasks/QueryStatePanel.tsx — 이전 단계에 없던 파일을 만듭니다.
  2. app/tasks/TaskDashboard.tsx — 기존 파일의 같은 위치를 갱신합니다.
  3. docs/invalidation.md — 이전 단계에 없던 파일을 만듭니다.

변경 이유와 결과: 목록과 상세의 올바른 키 계층을 점검하고 Query 상태 패널로 invalidation을 관찰합니다. 잘못된 키 재현은 docs/invalidation.md의 별도 비교로 남기며 완료본은 정상 키를 사용합니다.

완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.

app/tasks/QueryStatePanel.tsx 전체 파일

 'use client';
import { useIsFetching } from '@tanstack/react-query';
export default function QueryStatePanel() {
  const count = useIsFetching({ queryKey: ['tasks'] });
  return <p aria-live="polite">작업 관련 진행 요청: {count}</p>;
}

app/tasks/TaskDashboard.tsx 전체 파일

'use client';
import { useState } from 'react';
import TaskListClient from './TaskListClient';
import type { TaskStatus } from '../lib/tasks-api';
import TaskDetailClient from './TaskDetailClient';
import TaskForm from './TaskForm';
import QueryStatePanel from './QueryStatePanel';
export default function TaskDashboard() {
  const [status, setStatus] = useState<TaskStatus>('all');
  const [selected, setSelected] = useState<string | null>(null);
  return (
    <main>
      <h1>TaskFlow Query</h1>
      <label>필터 <select value={status} onChange={(event) => setStatus(event.target.value as TaskStatus)}>
        <option value="all">전체</option><option value="active">진행 중</option><option value="completed">완료</option>
      </select></label>
      <TaskForm />
      <TaskListClient status={status} onSelect={setSelected} />
      {selected && <TaskDetailClient taskId={selected} />}
      <QueryStatePanel />
    </main>
  );
}

docs/invalidation.md 전체 파일

# 갱신 키 비교

정상 완료본은 taskKeys.all로 무효화하여 목록/상세/페이지를 다시 확인합니다.
별도 복사에서 onSuccess의 queryKey를 ['unrelated']로 바꾸면 서버 POST는 성공해도 기존 목록 캐시는 남습니다. 다시 taskKeys.all로 복원하고 목록을 다시 확인합니다. 원본 캐시를 삭제하여 문제를 숨기지 않습니다.

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. 목록과 상세의 올바른 키 계층을 점검하고 Query 상태 패널로 invalidation을 관찰합니다. 잘못된 키 재현은 docs/invalidation.md의 별도 비교로 남기며 완료본은 정상 키를 사용합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

이 체크포인트는 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과했습니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.

아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.

연속 학습의 이전 글: TanStack Query useMutation 저장 실습: 실패·재시도와 목록 갱신까지. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

목록 queryKey와 저장 성공 콜백의 키를 비교한 뒤 정확한 키로 invalidateQueries를 호출합니다. setQueryData를 쓰면 기존 배열 형태를 유지합니다.

이전 체크포인트

query-step-06-mutation

이번 목표

TaskFlow 저장 이후 목록이 갱신되지 않는 상황을 재현하고 queryKey 기준으로 수정합니다.

재현

const mutation = useMutation({
  mutationFn: createTask,


  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: ['task'],
    });
  },
});

목록 query는 taskKeys.list('all')을 사용하므로 키가 맞지 않습니다.

수정

const mutation = useMutation({
  mutationFn: createTask,


  onSuccess: async () => {
    await queryClient
      .invalidateQueries({
        queryKey:
          taskKeys.lists(),
      });
  },
});

더 빠른 반영이 필요한 경우

onSuccess: (createdTask) => {
  queryClient.setQueryData<Task[]>(
    taskKeys.list('all'),
    (old = []) => [
      ...old,
      createdTask,
    ],
  );
},

이후 서버 정합성을 다시 확인해야 하는 서비스라면 invalidation을 추가합니다.

Network 확인

  1. POST /api/tasks 성공
  2. invalidateQueries 실행
  3. active GET /api/tasks 재요청
  4. 새 서버 응답으로 화면 갱신

이 흐름을 확인합니다.

npm run build

현재 확장 단계

✓ 1~6. 조회·캐시·mutation
→ 7. invalidation / UI 동기화
○ 8. pagination
○ 9. infinite query
○ 10~11. optimistic·캐시 동기화
○ 12~15. SSR·Hydration

완료 체크포인트

query-step-07-invalidation

다음 학습

다음 글에서는 page가 queryKey의 일부가 되는 페이지네이션을 구현하고 placeholderData로 페이지 전환 중 이전 목록을 유지합니다.

다음 글: TanStack Query 페이지네이션 실습: placeholderData로 이전 목록 유지하기

공식 문서

프로젝트 적용 후 확인: 서버 값은 바뀌었는데 화면이 그대로인 이유는 무엇일까요?

관찰 절차 저장 성공 후 Network의 변경 응답과 GET /api/tasks를 비교하고 Devtools에서 실제 목록 키를 확인합니다. 의도적으로 다른 키를 invalidate한 경우와 올바른 키로 복원한 경우를 비교합니다.

예상 결과 잘못된 키에는 재조회가 없고, 올바른 목록 키를 무효화하면 활성 목록 재조회 뒤 화면이 바뀝니다.

오류 점검 재조회가 있어도 옛 값이면 서버 응답과 별도 서버 캐시를 확인합니다. 캐시가 바뀌는데 화면만 옛 값이면 복제한 로컬 state를 찾습니다.

다음 학습 8회차에서 페이지 전환 중 이전 목록을 유지합니다.

검증 범위 전체 체크포인트의 빌드·상태 동작은 로컬 검증을 통과했습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.

글 소개·학습 준비·전체 목차
TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때

TanStack Query · 7 / 15 · 오류 해결 · mutation 이후 UI 동기화

mutation 요청은 성공했는데 화면의 목록이 그대로라면, 서버 저장 자체보다 Query cache 동기화를 먼저 확인해야 합니다.

TanStack Query는 서버 데이터를 임의로 추측해 모든 관련 캐시를 자동 변경하지 않습니다. 어떤 query가 영향을 받았는지 queryKey를 기준으로 알려줘야 합니다.

이번 글에서 배우는 내용

  • mutation 성공 후 UI가 그대로인 이유
  • queryKey 불일치 진단
  • invalidateQueries
  • setQueryData
  • 목록과 상세 캐시 갱신 기준
  • refetch와 직접 캐시 수정 선택 기준

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기