TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준

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

TanStack Query 목록·상세 캐시는 언제 직접 갱신하고 언제 무효화하나요?

같은 작업이 목록과 상세 query에 들어 있어도 두 캐시는 자동으로 하나가 되지 않습니다. 변경 결과의 구조가 단순하면 각각 직접 갱신하고, 필터·정렬·페이지 소속까지 바뀌면 invalidation을 검토합니다.

적용 조건·범위: mutation이 최신 객체를 반환하고 캐시 모양이 단순하면 직접 갱신할 수 있습니다. 필터·정렬·페이지에 따라 목록 소속이 바뀌면 invalidation으로 서버 결과를 다시 계산하는 편이 단순할 수 있습니다. 아래 실습에서 상세만 변경한 상태와 목록도 변경한 상태를 비교해 선택 기준을 확인합니다.

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

이 기술만 따로 연습하기

기대 결과: 상세만 변경하면 목록은 이전 제목, 상세는 새 제목입니다. 목록도 변경해야 두 화면이 같아집니다.

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

실행 환경은 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 {
  QueryClient,
  QueryClientProvider,
  useQuery,
  useQueryClient
} from '@tanstack/react-query';

const client = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

type Item = { id: number; title: string };
const initial: Item = { id: 1, title: '이전 제목' };
function Demo() {
  const queryClient = useQueryClient();
  const list = useQuery({ queryKey: ['items', 'list'], queryFn: async () => [initial], staleTime: Infinity });
  const detail = useQuery({ queryKey: ['items', 'detail', 1], queryFn: async () => initial, staleTime: Infinity });
  const updated: Item = { id: 1, title: '새 제목' };
  return (
    <main>
      <p>목록: {list.data?.[0].title}</p>
      <p>상세: {detail.data?.title}</p>
      <button onClick={() => queryClient.setQueryData(['items', 'detail', 1], updated)}>상세만 변경</button>
      <button onClick={() => queryClient.setQueryData<Item[]>(['items', 'list'],
        (before) => before?.map((item) => item.id === updated.id ? updated : item),
      )}>목록도 변경</button>
    </main>
  );
}

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

자가 확인·변형: 이 실험은 캐시 조작만 하므로 새로고침하면 초기 제목입니다. 필터별 목록에서 변경된 항목을 제거·추가해야 한다면 단순 map 대신 invalidation이 더 적절한 이유를 설명하세요.

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

같은 Task가 여러 query에 존재할 수 있습니다

['tasks', 'list', { status: 'all' }]
→ Task A, B, C


['tasks', 'detail', 'A']
→ Task A

Task A가 수정되어도 두 cache를 모두 자동으로 같은 객체로 연결해 주는 것은 아닙니다.

mutation 응답으로 상세 cache 갱신

서버가 수정된 Task 전체를 반환한다면 상세 cache는 직접 갱신하기 쉽습니다.

onSuccess: (updatedTask) => {
  queryClient.setQueryData(
    taskKeys.detail(
      updatedTask.id,
    ),
    updatedTask,
  );
},

목록 cache도 직접 갱신

정확한 목록 key를 알고 있다면 map으로 교체할 수 있습니다.

queryClient.setQueryData<Task[]>(
  taskKeys.list('all'),
  (old = []) =>
    old.map((task) =>
      task.id === updatedTask.id
        ? updatedTask
        : task,
    ),
);

여러 목록 query가 있을 때

필터별 목록이 여러 개라면 직접 모든 key를 찾아 수정하는 비용이 커집니다.

all
active
completed
page 1
page 2
infinite
검색 결과

이때는 관련 목록 계층을 invalidation해서 서버에서 다시 계산하도록 하는 것이 더 단순할 수 있습니다.

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

setQueriesData

여러 query를 대상으로 캐시 변환을 적용할 수도 있습니다.

queryClient.setQueriesData<Task[]>(
  {
    queryKey:
      taskKeys.lists(),
  },
  (old) => {
    if (!old) {
      return old;
    }


    return old.map((task) =>
      task.id === updatedTask.id
        ? updatedTask
        : task,
    );
  },
);

하지만 모든 목록 cache의 data shape가 Task[]로 동일할 때만 이런 공통 업데이트가 안전합니다.

페이지네이션이나 infinite query처럼 shape가 달라지면 별도의 로직이 필요합니다.

수동 정규화가 복잡해지는 지점

일반 목록
→ Task[]


페이지네이션
→ { items, nextPage }


infinite query
→ InfiniteData<TaskPage>


상세
→ Task

같은 엔티티를 여러 shape에서 직접 찾아 수정하려면 mutation마다 많은 cache 조작 코드가 생길 수 있습니다.

기본 전략

대부분의 프로젝트에서는 다음 순서로 시작하는 편이 단순합니다.

1. queryKey를 명확히 설계
2. mutation 성공
3. 관련 query invalidate
4. 성능 문제가 실제로 확인되면
   setQueryData 최적화 검토

수동 정규화는 기본값이 아니라 필요에 따른 최적화입니다.

직접 cache 업데이트가 적합한 경우

  • mutation 응답이 완성된 최신 객체를 반환함
  • 영향을 받는 query 수가 적음
  • 데이터 shape가 단순하고 동일함
  • 추가 네트워크 요청 비용이 큼
  • 즉시 반영 UX가 중요함

invalidation이 적합한 경우

  • 서버에서 파생 필드를 계산함
  • 필터·정렬·페이지 조건이 복잡함
  • 관련 query가 많음
  • mutation 결과만으로 모든 cache를 정확히 계산하기 어려움
  • 서버가 최종 원본이라는 흐름을 단순하게 유지하고 싶음

학습 완료 체크리스트

  • 목록과 상세 query가 독립 cache라는 것을 이해했습니다.
  • setQueryData로 상세 cache를 수정할 수 있습니다.
  • 여러 목록 동기화가 복잡해지는 이유를 설명할 수 있습니다.
  • setQueriesData와 invalidation의 차이를 이해했습니다.
  • 수동 정규화를 기본값으로 사용하지 않을 수 있습니다.

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

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

TaskFlow의 완료 mutation 이후 상세 cache는 즉시 수정하고 목록은 invalidation하는 혼합 전략을 적용합니다.

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

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

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/TaskItem.tsx — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: 성공한 Task 응답으로 목록·상세 캐시를 함께 갱신합니다. 필터별 소속은 invalidation으로 서버 결과를 다시 확인하며 정규화 저장소라고 과장하지 않습니다.

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

app/tasks/TaskItem.tsx 전체 파일

'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { Task } from '../lib/types';
import { updateTask } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
export default function TaskItem({ task, onSelect }: { task: Task; onSelect: (id: string) => void }) {
  const [fail, setFail] = useState(false);
  const client = useQueryClient();
  const mutation = useMutation({
    mutationFn: (completed: boolean) => updateTask(task.id, completed, fail),
    onMutate: async (completed) => {
      await client.cancelQueries({ queryKey: taskKeys.all });
      const lists = client.getQueriesData<Task[]>({ queryKey: taskKeys.lists() });
      const detail = client.getQueryData<Task>(taskKeys.detail(task.id));
      client.setQueriesData<Task[]>({ queryKey: taskKeys.lists() }, (rows) => rows?.map((row) => row.id === task.id ? { ...row, completed } : row));
      if (detail) client.setQueryData(taskKeys.detail(task.id), { ...detail, completed });
      return { lists, detail };
    },
    onError: (_error, _variables, context) => {
      for (const [key, rows] of context?.lists ?? []) client.setQueryData(key, rows);
      if (context?.detail) client.setQueryData(taskKeys.detail(task.id), context.detail);
    },
    onSuccess: (updated) => {
      client.setQueryData(taskKeys.detail(updated.id), updated);
      client.setQueriesData<Task[]>({ queryKey: taskKeys.lists() }, (rows) => rows?.map((row) => row.id === updated.id ? updated : row));
    },
    onSettled: () => client.invalidateQueries({ queryKey: taskKeys.all }),
  });
  return (
    <li>
      <button onClick={() => onSelect(task.id)}>{task.title}</button>
      <button disabled={mutation.isPending} onClick={() => mutation.mutate(!task.completed)}>{task.completed ? '완료 취소' : '완료'}</button>
      <label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실습 전환 실패</label>
      {mutation.isError && <span role="alert">{mutation.error.message}</span>}
    </li>
  );
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 http://localhost:3000/tasks를 엽니다.
  3. 성공한 Task 응답으로 목록·상세 캐시를 함께 갱신합니다. 필터별 소속은 invalidation으로 서버 결과를 다시 확인하며 정규화 저장소라고 과장하지 않습니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

기존 체크포인트 검증 기록: TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과한 기록입니다. 별도 실제 브라우저 화면 검수는 그 파일 검증 범위에 포함하지 않았습니다. 이번 문장 보완에서 재실행한 결과는 아닙니다.

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

연속 학습의 이전 글: TanStack Query Todo 실습: 서버 연결·낙관적 업데이트·실패 롤백. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.

mutation 응답으로 상세 cache를 갱신하고 복잡한 목록은 invalidation합니다. 배열·페이지·infinite cache를 같은 map으로 처리하지 않습니다.

이전 체크포인트

query-step-10-optimistic

이번 목표

TaskFlow의 완료 mutation 이후 상세 cache는 즉시 수정하고 목록은 invalidation하는 혼합 전략을 적용합니다.

권장 예제

const mutation = useMutation({
  mutationFn: updateTask,


  onSuccess: async (
    updatedTask,
  ) => {
    queryClient.setQueryData(
      taskKeys.detail(
        updatedTask.id,
      ),
      updatedTask,
    );


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

이 구조는 상세 화면은 즉시 최신 응답을 사용하고, 필터·페이지 조건이 있는 목록은 서버에서 다시 계산하게 합니다.

Before / After

Before
목록·상세 모든 cache를 수동으로 맞추려 함


After
정확히 알고 있는 상세은 setQueryData
복잡한 목록 집합은 invalidateQueries

검증

  1. 상세 화면에서 작업을 수정합니다.
  2. 상세 cache가 즉시 바뀌는지 확인합니다.
  3. 목록 query가 invalidation되는지 확인합니다.
  4. active/completed 필터에서도 서버 결과가 맞는지 확인합니다.
npm run build

현재 확장 단계

✓ 1~10. 조회·mutation·optimistic
→ 11. 목록·상세 cache 설계
○ 12. 서버 prefetch
○ 13. HydrationBoundary
○ 14. Hydration 오류
○ 15. Next.js cache 경계

완료 체크포인트

query-step-11-normalization

다음 학습

다음 글부터 Next.js App Router의 Server Component와 TanStack Query를 연결합니다. 서버에서 query cache를 준비하고 브라우저로 넘기는 SSR/Hydration 흐름을 시작합니다.

다음 글: TanStack Query SSR prefetchQuery: Server Component에서 초기 데이터 준비하기

공식 문서

프로젝트 적용 후 확인: 목록과 상세가 서로 다른 값을 보이면?

관찰 절차 같은 작업을 목록과 상세에서 열어 두고 수정 후 Devtools의 목록 키와 상세 키를 각각 확인합니다. invalidateQueries 방식과 setQueryData로 두 캐시를 직접 갱신하는 방식의 화면 변화를 비교합니다.

예상 결과 선택한 전략에 따라 두 화면이 같은 서버 값으로 수렴해야 합니다. 직접 수정한 캐시는 불변 업데이트로 새 객체를 반환합니다.

오류 점검 한쪽만 옛 값이면 상세 키의 ID 타입과 목록 키 범위를 확인합니다. 직접 캐시 변경이 복잡하면 무효화와 재조회로 단순화합니다.

다음 학습 12회차에서 서버 렌더링 전에 목록을 준비합니다.

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

글 소개·학습 준비·전체 목차
TanStack Query 수동 캐시 정규화: 목록·상세 동기화와 선택 기준

TanStack Query · 11 / 15 · 선택 심화 · 목록·상세 캐시 동기화

TanStack Query는 Redux Entity Adapter나 정규화된 데이터베이스처럼 모든 엔티티를 자동으로 하나의 저장소에 합치지 않습니다.

같은 Task가 목록 query와 상세 query에 각각 존재하면 두 query는 서로 독립된 cache입니다.

mutation이 최신 객체를 반환하고 캐시 모양이 단순하면 직접 갱신할 수 있습니다. 필터·정렬·페이지에 따라 목록 소속이 바뀌면 invalidation으로 서버 결과를 다시 계산하는 편이 단순할 수 있습니다. 아래 실습에서 상세만 변경한 상태와 목록도 변경한 상태를 비교해 선택 기준을 확인합니다.

이번 글에서 배우는 내용

  • TanStack Query cache가 자동 정규화되지 않는다는 의미
  • 목록 query와 상세 query
  • setQueryData로 상세 갱신
  • setQueriesData로 여러 목록 갱신
  • invalidateQueries 선택 기준
  • 수동 캐시 정규화 비용

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기