Claude Code와 TanStack Query 실전 가이드: React 데이터 가져오기와 캐시 설계
Claude Code로 TanStack Query를 안전하게 구현하는 방법: query key, staleTime, optimistic update, SSR, 테스트.
Claude Code에 “TanStack Query 붙여줘”라고만 요청하면 겉으로는 동작하지만 review하기 어려운 코드가 나오기 쉽습니다. query key 모양이 파일마다 달라지고, staleTime의 근거가 남지 않으며, mutation 후 어떤 목록을 다시 가져와야 하는지도 불분명해집니다. 이런 문제는 즉시 빌드를 깨뜨리기보다, 편집 후 화면에 오래된 데이터가 남는 식으로 나타납니다.
이 글은 React + TypeScript 프로젝트 목록 화면을 기준으로, Claude Code와 TanStack Query v5를 안전하게 쓰는 방법을 설명합니다. 다루는 범위는 query key 설계, staleTime과 gcTime, mutation invalidation, optimistic update, loading/error 상태, SSR hydration, MSW 기반 network mock 테스트입니다. API 사실은 TanStack 공식 문서인 Query Keys, Important Defaults, Query Invalidation, Optimistic Updates, Server Rendering & Hydration, Testing를 기준으로 확인했습니다.
React 컴포넌트 경계는 Claude Code React 개발, 테스트 운영은 Claude Code 테스트 전략, API mock은 Claude Code MSW Mock, 클라이언트 상태와 서버 상태의 분리는 Claude Code Zustand를 함께 보면 좋습니다.
Claude Code가 수정하기 전에 경계를 정한다
TanStack Query가 맡아야 하는 것은 server state입니다. server state는 API, DB, 백엔드 서비스가 진짜 출처인 데이터입니다. 사용자가 입력 중인 검색어, dialog open 여부, 선택된 tab 같은 일시적인 UI 상태와는 분리해야 합니다.
작업 요청 전에 다음 표를 Claude Code에게 넘기면 결과가 안정됩니다.
| 결정할 것 | 예시 | review 이유 |
|---|---|---|
| query key 단위 | ["projects", "list", filters] | 캐시 주소가 흔들리지 않음 |
| freshness 정책 | 목록 60초, 상세 5분 | refetch 동작을 설명할 수 있음 |
| mutation 후 처리 | 목록 prefix와 상세 key invalidate | 저장 후 오래된 UI를 막음 |
| optimistic update | 상태 토글을 즉시 반영 | 실패 시 rollback 가능 |
| 검증 | Vitest, MSW, build | 감이 아니라 증거로 확인 |
query key는 캐시 주소입니다. TanStack Query v5 공식 문서는 query key의 top level이 배열이어야 하며, JSON 직렬화가 가능하고, 해당 데이터에 대해 고유해야 한다고 설명합니다. 그래서 ["projects", "list", { status: "active" }] 같은 key는 컴포넌트 이름이 아니라 데이터 조건을 담아야 합니다.
실무에서 자주 쓰는 유스케이스
첫 번째는 관리자 목록입니다. 사용자, 청구서, 문의, 프로젝트, 상품 목록에서는 검색어, status, page가 query key에 들어갑니다. Claude Code에는 “빈 문자열과 대소문자를 정규화한 뒤 key에 넣어라”라고 명시합니다. 그렇지 않으면 같은 검색인데도 cache가 여러 개 생깁니다.
두 번째는 상세와 편집 흐름입니다. 상세는 ["projects", "detail", id]로 관리하고, 편집 성공 후에는 상세 key와 목록 prefix를 invalidate합니다. invalidateQueries는 매칭되는 query를 stale로 표시하고, 현재 렌더링 중이면 background에서 다시 가져옵니다.
세 번째는 status 변경, 즐겨찾기, archive, 담당자 배정 같은 빠른 조작입니다. optimistic update가 유용하지만, 단순히 UI만 먼저 바꾸면 위험합니다. 진행 중인 refetch를 cancel하고, 이전 cache를 snapshot으로 보관하고, 실패하면 복구한 뒤, 마지막에 invalidate해야 합니다.
네 번째는 Next.js 같은 SSR입니다. 서버에서 prefetch하고 hydrate하면 첫 화면이 빨라질 수 있지만, server와 client의 query key가 조금이라도 다르거나 staleTime이 기본값 0이면 hydration 직후 바로 다시 fetch합니다. SSR에서는 같은 query factory를 공유하고 보통 0보다 큰 staleTime을 설정합니다.
query key, staleTime, gcTime 구현
Claude Code에는 이렇게 요청합니다.
기존 React + TypeScript 프로젝트를 읽고 TanStack Query v5로 projects 목록을 구현하세요.
query key는 top-level array를 사용하고, filters는 정규화하세요.
목록 staleTime은 60초, gcTime은 10분으로 설정하세요.
queryOptions를 사용하고 pagination 깜빡임을 줄이기 위해 useQuery에 placeholderData: keepPreviousData를 사용하세요.
API 함수, 타입, query key factory, hook을 한 파일에 모으세요.
오래된 cacheTime 옵션명은 사용하지 마세요.
아래 코드는 src/features/projects/projects.query.ts로 붙여 넣을 수 있습니다. staleTime은 데이터가 fresh로 간주되는 시간이고, gcTime은 inactive query가 cache에 남아 있는 시간입니다. v5에서는 예전 이름인 cacheTime이 아니라 gcTime을 씁니다.
import {
keepPreviousData,
queryOptions,
useQuery,
} from "@tanstack/react-query";
export type ProjectStatus = "all" | "active" | "paused";
export type EditableProjectStatus = Exclude<ProjectStatus, "all">;
export type Project = {
id: string;
name: string;
status: EditableProjectStatus;
updatedAt: string;
};
export type ProjectFilters = {
status: ProjectStatus;
search: string;
page: number;
};
export type ProjectListResponse = {
items: Project[];
page: number;
hasMore: boolean;
};
export type UpdateProjectStatusInput = {
id: string;
status: EditableProjectStatus;
};
function normalizeFilters(filters: ProjectFilters): ProjectFilters {
return {
status: filters.status,
search: filters.search.trim().toLowerCase(),
page: Math.max(1, filters.page),
};
}
async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as T;
}
export async function fetchProjects(
filters: ProjectFilters,
): Promise<ProjectListResponse> {
const normalized = normalizeFilters(filters);
const params = new URLSearchParams({
status: normalized.status,
search: normalized.search,
page: String(normalized.page),
});
const response = await fetch(`/api/projects?${params.toString()}`);
return readJson<ProjectListResponse>(response);
}
export async function fetchProject(id: string): Promise<Project> {
const response = await fetch(`/api/projects/${id}`);
return readJson<Project>(response);
}
export async function updateProjectStatus(
input: UpdateProjectStatusInput,
): Promise<Project> {
const response = await fetch(`/api/projects/${input.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: input.status }),
});
return readJson<Project>(response);
}
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
list: (filters: ProjectFilters) =>
[...projectKeys.lists(), normalizeFilters(filters)] as const,
details: () => [...projectKeys.all, "detail"] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
};
export const projectQueries = {
list: (filters: ProjectFilters) =>
queryOptions({
queryKey: projectKeys.list(filters),
queryFn: () => fetchProjects(filters),
staleTime: 60_000,
gcTime: 10 * 60_000,
}),
detail: (id: string) =>
queryOptions({
queryKey: projectKeys.detail(id),
queryFn: () => fetchProject(id),
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
}),
};
export function useProjects(filters: ProjectFilters) {
return useQuery({
...projectQueries.list(filters),
placeholderData: keepPreviousData,
});
}
이 설계의 핵심은 filters를 key에 넣기 전에 정규화한다는 점입니다. 또한 처음부터 staleTime: Infinity를 기본값으로 두지 않습니다. 여러 사람이 같은 데이터를 수정하는 관리자 화면에서는 오래된 정보를 계속 fresh로 취급하는 것이 더 위험할 수 있습니다.
mutation invalidation과 optimistic update
mutation은 서버에 변경을 보내는 작업입니다. Claude Code가 mutation을 만들 때는 성공 경로뿐 아니라 실패 복구까지 요구해야 합니다. 공식 optimistic update 패턴도 cancel, snapshot, optimistic write, rollback, invalidate의 흐름을 사용합니다.
src/features/projects/useUpdateProjectStatus.ts를 추가합니다.
import type { QueryKey } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
projectKeys,
updateProjectStatus,
type Project,
type ProjectListResponse,
} from "./projects.query";
type ListSnapshot = [QueryKey, ProjectListResponse | undefined];
export function useUpdateProjectStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProjectStatus,
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
await queryClient.cancelQueries({ queryKey: projectKeys.detail(input.id) });
const listSnapshots =
queryClient.getQueriesData<ProjectListResponse>({
queryKey: projectKeys.lists(),
}) as ListSnapshot[];
const detailSnapshot = queryClient.getQueryData<Project>(
projectKeys.detail(input.id),
);
for (const [key, data] of listSnapshots) {
if (!data) continue;
queryClient.setQueryData<ProjectListResponse>(key, {
...data,
items: data.items.map((project) =>
project.id === input.id
? { ...project, status: input.status }
: project,
),
});
}
queryClient.setQueryData<Project>(
projectKeys.detail(input.id),
(current) =>
current ? { ...current, status: input.status } : current,
);
return { listSnapshots, detailSnapshot };
},
onError: (_error, input, context) => {
for (const [key, data] of context?.listSnapshots ?? []) {
queryClient.setQueryData(key, data);
}
queryClient.setQueryData(projectKeys.detail(input.id), context?.detailSnapshot);
},
onSettled: async (_data, _error, input) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: projectKeys.lists() }),
queryClient.invalidateQueries({ queryKey: projectKeys.detail(input.id) }),
]);
},
});
}
흔한 실수는 상세 cache만 바꾸고 현재 보이는 목록을 놓치는 것입니다. 반대로 매번 인자 없이 invalidateQueries()를 호출하면 불필요한 요청이 늘고 key 설계가 흐려집니다. Claude Code에는 “이번 mutation이 invalidate한 query key를 PR 설명에 적어라”라고 요청하는 편이 좋습니다.
loading/error 상태를 화면에 드러낸다
초기 로딩, background fetching, mutation 진행 중, mutation 실패는 서로 다른 상태입니다. 모두 같은 spinner로 숨기면 사용자도 review도 문제를 발견하기 어렵습니다.
src/features/projects/ProjectListPage.tsx 예시입니다.
import { useState } from "react";
import {
useProjects,
type EditableProjectStatus,
type ProjectFilters,
type ProjectStatus,
} from "./projects.query";
import { useUpdateProjectStatus } from "./useUpdateProjectStatus";
const defaultFilters: ProjectFilters = {
status: "all",
search: "",
page: 1,
};
function nextStatus(status: EditableProjectStatus): EditableProjectStatus {
return status === "active" ? "paused" : "active";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unknown error";
}
export function ProjectListPage({
initialFilters = defaultFilters,
}: {
initialFilters?: ProjectFilters;
}) {
const [filters, setFilters] = useState<ProjectFilters>(initialFilters);
const projectsQuery = useProjects(filters);
const updateStatus = useUpdateProjectStatus();
function setStatus(status: ProjectStatus) {
setFilters((current) => ({ ...current, status, page: 1 }));
}
function setSearch(search: string) {
setFilters((current) => ({ ...current, search, page: 1 }));
}
if (projectsQuery.isPending) {
return <p role="status">Loading projects...</p>;
}
if (projectsQuery.isError) {
return (
<section role="alert">
<h2>Projects could not be loaded</h2>
<p>{errorMessage(projectsQuery.error)}</p>
<button type="button" onClick={() => void projectsQuery.refetch()}>
Retry
</button>
</section>
);
}
const projects = projectsQuery.data.items;
return (
<section aria-labelledby="projects-title">
<h2 id="projects-title">Projects</h2>
<label>
Search
<input
value={filters.search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>
Status
<select
value={filters.status}
onChange={(event) => setStatus(event.target.value as ProjectStatus)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
</select>
</label>
{projectsQuery.isFetching ? (
<p role="status">Refreshing cached data...</p>
) : null}
{updateStatus.isError ? (
<p role="alert">Update failed: {errorMessage(updateStatus.error)}</p>
) : null}
<ul>
{projects.map((project) => {
const next = nextStatus(project.status);
return (
<li key={project.id}>
<strong>{project.name}</strong>{" "}
<span aria-label={`status ${project.status}`}>
{project.status}
</span>
<button
type="button"
disabled={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: project.id, status: next })
}
aria-label={`${next} ${project.name}`}
>
Mark {next}
</button>
</li>
);
})}
</ul>
</section>
);
}
여기서 Claude Code에 review를 맡길 때는 “initial loading, background refresh, mutation failure, retry, disabled 상태가 구분되는지 확인해 달라”고 요청합니다. UI를 예쁘게 만드는 것보다 먼저 볼 부분입니다.
SSR과 hydration 주의점
SSR에서는 request마다 새 QueryClient를 만들어야 합니다. 다른 사용자의 cache가 섞이지 않게 하기 위해서입니다. 또 server와 client가 같은 query factory를 써야 hydrate된 데이터를 재사용할 수 있습니다.
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { ProjectListPage } from "@/features/projects/ProjectListPage";
import {
projectQueries,
type ProjectFilters,
type ProjectStatus,
} from "@/features/projects/projects.query";
type PageProps = {
searchParams?: {
status?: string;
search?: string;
page?: string;
};
};
function parseStatus(value: string | undefined): ProjectStatus {
return value === "active" || value === "paused" ? value : "all";
}
export default async function ProjectsPage({ searchParams }: PageProps) {
const filters: ProjectFilters = {
status: parseStatus(searchParams?.status),
search: searchParams?.search ?? "",
page: Number(searchParams?.page ?? "1") || 1,
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
},
},
});
await queryClient.prefetchQuery(projectQueries.list(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectListPage initialFilters={filters} />
</HydrationBoundary>
);
}
숨은 함정은 타입 차이입니다. server에서는 page가 문자열이고 client에서는 숫자라면 key가 달라집니다. parser와 key factory를 공유 module에 두고, Claude Code에게 server/client key가 같은지 비교하게 하세요.
MSW로 network를 mock한 테스트
component 테스트는 실제 API에 의존하지 않는 편이 안전합니다. MSW로 HTTP 경계를 mock하고, 각 테스트마다 새로운 QueryClient를 만들며, retry: false로 실패를 빠르게 드러냅니다.
src/features/projects/ProjectListPage.test.tsx입니다.
import "@testing-library/jest-dom/vitest";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { ProjectListPage } from "./ProjectListPage";
const server = setupServer(
http.get("/api/projects", () =>
HttpResponse.json({
items: [
{
id: "p-1",
name: "Docs revamp",
status: "active",
updatedAt: "2026-06-02T00:00:00.000Z",
},
],
page: 1,
hasMore: false,
}),
),
http.patch("/api/projects/:id", async ({ params, request }) => {
const body = (await request.json()) as { status: "active" | "paused" };
return HttpResponse.json({
id: String(params.id),
name: "Docs revamp",
status: body.status,
updatedAt: "2026-06-02T00:01:00.000Z",
});
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
function renderWithClient(ui: ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
});
return {
user: userEvent.setup(),
...render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
),
};
}
describe("ProjectListPage", () => {
it("loads projects from the API", async () => {
renderWithClient(<ProjectListPage />);
expect(screen.getByRole("status")).toHaveTextContent("Loading projects");
expect(await screen.findByText("Docs revamp")).toBeInTheDocument();
expect(screen.getByLabelText("status active")).toBeInTheDocument();
});
it("rolls back an optimistic update when the mutation fails", async () => {
server.use(
http.patch("/api/projects/:id", () =>
HttpResponse.json({ message: "boom" }, { status: 500 }),
),
);
const { user } = renderWithClient(<ProjectListPage />);
await screen.findByText("Docs revamp");
await user.click(
screen.getByRole("button", { name: /paused docs revamp/i }),
);
await waitFor(() =>
expect(screen.getByText(/Update failed:/)).toBeInTheDocument(),
);
expect(screen.getByLabelText("status active")).toBeInTheDocument();
});
});
로컬 확인 명령입니다.
npm create vite@latest tanstack-query-claude-demo -- --template react-ts
cd tanstack-query-claude-demo
npm install @tanstack/react-query
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw
npm pkg set scripts.test="vitest"
npm run test -- --run src/features/projects/ProjectListPage.test.tsx
npm run build
이 사이트 글을 수정한 뒤에는 다음도 확인합니다.
node scripts/check-code-fences.mjs
node scripts/check-updated-article-quality.mjs
실패 사례와 안전한 prompt
첫째, query key에 함수, class instance, raw Date를 넣지 않습니다. key는 직렬화 가능해야 하므로 ISO 문자열이나 안정적인 날짜 문자열로 바꿉니다.
둘째, staleTime과 gcTime을 혼동하지 않습니다. staleTime은 freshness와 refetch 판단에 관여하고, gcTime은 inactive query가 cache에 얼마나 남는지에 관여합니다. 기본적으로 데이터는 stale로 간주되며 inactive query는 보통 5분 후 garbage collect됩니다.
셋째, 근거 없이 queryClient.invalidateQueries()를 전역으로 호출하지 않습니다. 작은 앱에서는 괜찮아 보이지만 화면이 늘면 불필요한 request가 급격히 늘어납니다.
넷째, SSR용 key와 client hook key를 따로 만들지 않습니다. 숫자와 문자열 차이만으로도 hydrate cache를 놓칠 수 있습니다.
다섯째, component 동작 테스트를 실제 API에 묶지 않습니다. MSW, 새 QueryClient, retry: false를 사용하고 loading, success, mutation failure, rollback을 나눠 테스트합니다.
Claude Code에는 이렇게 요청합니다.
TanStack Query v5만 사용하세요. cacheTime이 아니라 gcTime을 사용하세요.
query key factory, API functions, hooks, UI, tests를 분리하세요.
mutation에는 cancel, snapshot, optimistic update, rollback, targeted invalidation을 모두 넣으세요.
SSR prefetch key와 client hook key가 완전히 같은지 review하세요.
마지막에 npm run test -- --run ProjectListPage.test.tsx 와 npm run build 검증 명령을 제시하세요.
CTA와 실제로 해본 결과
TanStack Query는 Claude Code 운영 규칙을 표준화하기 좋은 주제입니다. cache 실수는 눈에 잘 띄지 않지만 운영 비용은 큽니다. 개인은 무료 Claude Code cheatsheet로 시작하고, 반복 가능한 prompt와 review 템플릿이 필요하면 ClaudeCodeLab products를 참고하세요. 팀이 실제 repository에 query key 규칙, 권한, review policy, rollout training을 넣고 싶다면 Claude Code training and consultation이 더 현실적인 다음 단계입니다.
Masa가 이 흐름을 직접 시험했을 때 가장 효과가 컸던 것은 UI보다 query key factory를 먼저 고정한 점이었습니다. UI부터 만든 버전은 filter 정규화 누락, SSR key 불일치, mutation 후 목록 갱신 누락이 뒤늦게 나왔습니다. 반대로 query key, staleTime, gcTime, rollback, MSW 테스트를 첫 요청에 넣자 Claude Code의 diff가 작아졌고, review도 “어떤 cache를 왜 바꿨는가”로 좁혀졌습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.