TanStack Query queryKey 설계의 핵심
queryKey는 서버 상태 캐시의 주소입니다. query 함수가 의존하며 응답을 바꾸는 값은 key에 포함하고, 모달 열림이나 보기 방식처럼 응답과 무관한 UI 상태는 제외해야 합니다. 목록·상세·필터·사용자 범위를 계층형 배열과 key factory로 통일하면 캐시 혼합과 mutation 뒤 잘못된 무효화를 함께 줄일 수 있습니다.
문서 기준: 이 글의 동작 설명과 코드는 2026-09-12에 확인한 TanStack Query React v5 공식 문서를 기준으로 합니다.
queryKey를 캐시 주소로 이해하기
TanStack Query는 queryKey를 기준으로 query 결과를 저장하고 다시 찾습니다. React v5에서 최상위 key는 배열이어야 하며, JSON.stringify로 직렬화할 수 있고 해당 데이터에 고유하면 문자열, 숫자, 중첩 객체를 조합할 수 있습니다. 같은 응답을 가리키는 요청은 같은 key를, 응답이 달라질 수 있는 요청은 다른 key를 사용해야 합니다.
따라서 key를 설계할 때 가장 먼저 물을 질문은 “이 값이 바뀌면 서버 응답도 바뀌는가?”입니다. 게시글 ID, 검색어, 정렬, 페이지, 로그인 사용자나 tenant 범위처럼 결과를 바꾸는 값이 빠지면 서로 다른 응답이 같은 캐시 주소를 공유할 수 있습니다. 반대로 결과를 바꾸지 않는 상태까지 넣으면 같은 데이터를 여러 캐시에 나눠 저장하고 불필요하게 다시 요청하게 됩니다.

useQuery({
queryKey: ['posts', 'detail', postId],
queryFn: () => fetchPost(postId),
})
useQuery({
queryKey: ['users', 'list', { keyword, role, page, pageSize }],
queryFn: () => fetchUsers({ keyword, role, page, pageSize }),
})
공식 Query Keys 문서는 query 함수가 의존하고 변경되는 변수를 key에 포함하라고 안내합니다. key는 단순 라벨이 아니라 query 함수의 의존성을 드러내는 계약으로 보는 편이 정확합니다. query 함수의 책임을 함께 정리하려면 기존 내부 글 TanStack Query에서 queryFn 이해하기를 이어서 볼 수 있습니다.
queryKey에 포함할 값: 서버 응답을 바꾸는 조건
URL 경로, 검색 조건, 데이터 범위 중 실제 응답을 바꾸는 값은 key에 포함합니다. 화면 코드에 직접 보이지 않고 인증 컨텍스트나 전역 상태에서 query 함수가 읽더라도 결과가 달라진다면 key에 명시하는 편이 캐시 경계를 이해하고 테스트하기 쉽습니다.
- 상세 조회의
id,slug, 상품 코드 같은 식별자 - 검색어, 카테고리, 상태, 역할, 날짜 범위 같은 필터
- 정렬 필드와 정렬 방향
page,pageSize처럼 목록 응답을 바꾸는 페이지네이션 조건userId,tenantId,organizationId, 선택한 워크스페이스처럼 데이터 범위를 바꾸는 값- 미리보기 여부나 서버 로케일처럼 같은 리소스의 반환 형태를 바꾸는 옵션
사용자나 tenant 값을 key에 넣는 것은 캐시 분리 규칙이지 권한 검사를 대신하는 보안 장치가 아닙니다. 서버는 요청마다 인증과 권한을 검증해야 하고, 로그아웃이나 계정 전환 시에는 앱 정책에 맞게 민감한 캐시를 제거하거나 새 QueryClient 경계로 분리해야 합니다.
type OrderFilters = {
keyword: string
status: 'open' | 'closed' | 'all'
page: number
pageSize: number
tenantId: string
}
const filters: OrderFilters = {
keyword: keyword.trim(),
status,
page,
pageSize,
tenantId,
}
useQuery({
queryKey: ['orders', 'list', filters],
queryFn: () => fetchOrders(filters),
})
queryKey에서 제외할 값: 응답과 무관한 UI 상태
모달 열림, 선택 행, 접힘 여부, 카드형·목록형 보기처럼 서버 응답을 바꾸지 않는 상태는 보통 queryKey에 넣지 않습니다. 이 값을 포함하면 같은 서버 데이터를 UI 상태마다 별도 캐시로 만들고 요청 수와 무효화 범위를 늘릴 수 있습니다.
- 모달과 드롭다운의 열림 여부
- 서버 요청 조건과 무관한 탭·행 선택 상태
- 카드형·표형 보기와 애니메이션 상태
- 아직 제출하지 않은 임시 입력값
- 함수, DOM 노드, class instance처럼 JSON 직렬화 key로 쓰기 부적절한 값
객체 속성 순서는 결정적 해시로 처리되므로 { status, page }와 { page, status }는 같은 key로 간주됩니다. 하지만 배열 요소 순서는 의미가 있으므로 ['todos', status, page]와 ['todos', page, status]는 다릅니다. 날짜는 서버와 합의한 ISO 문자열처럼 한 형식으로 정규화하고, 누락과 전체를 구분해야 한다면 우연한 undefined 대신 null이나 명시적인 문자열을 사용하는 편이 의도를 읽기 쉽습니다.
// 객체 속성 순서는 같게 해시됩니다.
['todos', { status, page }]
['todos', { page, status }]
// 배열 요소 순서가 바뀌면 다른 key입니다.
['todos', status, page]
['todos', page, status]
목록·상세·필터 계층과 key factory
실무에서는 첫 요소에 도메인, 다음 요소에 list·detail·infinite 같은 데이터 유형, 그 뒤에 식별자나 필터를 두는 계층형 배열이 관리하기 쉽습니다. 목록과 상세가 같은 게시글을 다루더라도 반환 필드와 갱신 범위가 다를 수 있으므로 하위 key를 분리합니다.

type PostFilters = {
keyword: string
status: 'draft' | 'published' | 'all'
page: number
}
const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: PostFilters) =>
[...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (postId: number) =>
[...postKeys.details(), postId] as const,
}
useQuery({
queryKey: postKeys.list({ keyword, status, page }),
queryFn: () => fetchPosts({ keyword, status, page }),
})
useQuery({
queryKey: postKeys.detail(postId),
queryFn: () => fetchPost(postId),
})
key factory는 거대한 공용 도구일 필요가 없습니다. 도메인 가까이에 두고 query와 mutation이 같은 함수를 사용하게 하면 단수·복수 오타, 배열 순서 차이, 목록과 상세 범위 혼동을 줄일 수 있습니다.
페이지네이션과 무한 쿼리의 범위를 구분하기
일반 페이지네이션은 page와 pageSize가 응답을 바꾸므로 key에 포함합니다. 반면 useInfiniteQuery의 각 페이지 cursor는 query 함수가 전달받는 pageParam으로 관리합니다. 피드 전체의 범위를 바꾸는 검색어, 정렬, 주제, 사용자 범위는 queryKey에 넣고 각 다음 페이지의 cursor는 pageParam으로 넘깁니다.
useInfiniteQuery({
queryKey: ['feed', 'infinite', { topic, sort, viewerId }],
queryFn: ({ pageParam }) =>
fetchFeed({ topic, sort, viewerId, cursor: pageParam }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
일반 query와 infinite query는 데이터 구조가 다르므로 같은 key를 공유하면 안 됩니다. 무한 목록 구현 흐름은 내부 글 TanStack Query useInfiniteQuery 무한 스크롤에서 이어서 확인할 수 있습니다.
mutation과 invalidation까지 연결해서 설계하기
queryKey 설계의 품질은 mutation 뒤 어떤 캐시를 갱신할지 결정할 때 드러납니다. 상위 prefix를 사용하면 관련 계층을 넓게 무효화하고, 구체적인 key와 exact: true를 사용하면 정확히 한 캐시만 대상으로 삼을 수 있습니다. 넓은 무효화는 편하지만 필터 조합이 많을수록 불필요한 요청을 늘릴 수 있으므로 실제로 변경된 데이터 범위와 맞춥니다.
const queryClient = useQueryClient()
const updatePostMutation = useMutation({
mutationFn: updatePost,
onSuccess: async (updatedPost) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: postKeys.lists(),
}),
queryClient.invalidateQueries({
queryKey: postKeys.detail(updatedPost.id),
exact: true,
}),
])
},
})
목록에 제목과 상태가 보이고 상세에는 본문이 보인다면 수정 뒤 두 계층 모두 영향을 받을 수 있습니다. 반대로 상세 화면 일부만 바뀌는 작업에 모든 게시글 query를 무효화할 필요는 없습니다. staleTime과 캐시 보존 시간을 함께 설계하려면 내부 글 TanStack Query staleTime과 gcTime 차이를 참고할 수 있습니다.
자주 생기는 queryKey 설계 실수
| 실수 | 증상 | 수정 기준 |
|---|---|---|
| 필터를 API에는 보내고 key에서 제외 | 이전 검색 결과가 다른 조건에 섞임 | 응답을 바꾸는 필터를 key 객체에 포함 |
page만 넣고 pageSize 제외 |
같은 페이지 번호의 서로 다른 크기가 충돌 | 응답 묶음을 바꾸는 두 값을 함께 포함 |
| 로그인 사용자나 tenant 범위 제외 | 계정 전환 뒤 이전 범위 데이터가 보임 | query 함수가 의존하는 범위를 key에 포함하고 서버 권한도 별도 검증 |
| 일반 목록과 infinite 목록이 같은 key 사용 | 캐시 데이터 형태가 충돌 | list와 infinite 하위 key 분리 |
| 컴포넌트마다 key를 직접 작성 | mutation의 invalidation이 실제 query와 불일치 | 도메인별 key factory 공유 |
| UI 상태까지 key에 포함 | 캐시가 과도하게 분리되고 요청 증가 | 서버 응답과 무관한 상태는 로컬·URL 상태로 관리 |
실무 체크리스트
- 같은 서버 데이터는 모든 화면에서 같은 key factory로 조회하는가?
- query 함수가 의존하며 응답을 바꾸는 식별자·필터·정렬·페이지 값이 들어갔는가?
- 사용자·tenant·조직 범위가 응답을 바꾸면 key와 서버 권한 검사에 모두 반영했는가?
- 목록·상세·일반 페이지·무한 목록의 데이터 형태를 하위 key로 분리했는가?
- 객체는 JSON 직렬화 가능한 값으로 만들고 배열 요소 순서를 팀 규칙으로 고정했는가?
- 응답과 무관한 모달·선택·보기 상태를 key에서 제외했는가?
- mutation이 갱신할 범위를 prefix와 exact 중 의도에 맞게 선택했는가?
- invalidation Promise를 기다려 저장 완료 UI와 후속 갱신 시점을 맞췄는가?
확인한 공식 문서와 관련 글
아래 외부 링크는 2026-09-12에 확인한 TanStack Query React v5 공식 문서입니다.
필터 캐시가 다시 요청되는 시점은 staleTime과 gcTime에서 이어집니다.
결론: 서버 응답의 경계를 캐시 주소에 그대로 표현한다
좋은 queryKey는 짧은 문자열을 고르는 문제가 아니라 서버 응답의 경계를 코드로 표현하는 설계입니다. 응답을 바꾸는 값은 빠뜨리지 않고, 응답과 무관한 UI 상태는 제외하며, 목록·상세·무한 목록을 계층형 배열로 분리합니다. 그 key를 query와 mutation이 같은 factory로 공유하면 필터와 권한 범위가 늘어나도 캐시 혼합과 잘못된 invalidation을 추적하기 쉬워집니다.
아래 실습에서 staleTime은 60초입니다. 종이→필기구→종이로 돌아오면 보존된 fresh 캐시를 재사용합니다. staleTime은 캐시 보존 시간이 아니므로 gcTime을 0으로 두면 비활성화된 캐시가 사라져 복귀 때 다시 요청할 수 있습니다. UI 테스트에서도 캐시 재사용을 검증하려고 gcTime을 60초로 유지합니다.
실습: 필터 캐시와 UI 보기 상태 구분
아래 파일은 하나의 완성 프로젝트입니다. 전체 실습 ZIP 내려받기 후 빈 폴더에서 압축을 풀어 실행하세요. HTML, CSS, JavaScript 파일을 분리했습니다. Node.js 22.12 이상이 필요하며 의존성 설치에는 인터넷 연결이 필요합니다.
npm install
npm test
npm run build
npm run dev
확인한 조합은 React 19.3.0, TanStack Query 5.102.8, Vite 8.3.0, Vitest 4.1.11입니다. 요청은 외부 서버 대신 로컬 fixture를 사용하며 실제 저장·인증·HTTP 서버를 제공하지 않습니다. 테스트는 매 렌더마다 새 QueryClient를 만들어 캐시를 분리합니다.
package.json
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Query 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
package.json
{
"private": true,
"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",
"@testing-library/user-event": "14.6.7",
"@testing-library/jest-dom": "7.0.1"
}
}
src/App.jsx
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { productKeys, loadProducts } from './queries.js';
export default function App({ load = loadProducts }) {
const [category, setCategory] = useState('paper');
const [compact, setCompact] = useState(false);
const filters = { category };
const query = useQuery({
queryKey: productKeys.list(filters),
queryFn: ({ queryKey }) => load(queryKey[2]),
staleTime: 60000,
});
return (
<main>
<h1>카테고리별 캐시</h1>
<label>
카테고리
<select value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="paper">종이</option>
<option value="pen">필기구</option>
</select>
</label>
<button onClick={() => setCompact((value) => !value)}>
표시: {compact ? '간단히' : '자세히'}
</button>
{query.isPending && <p role="status">불러오는 중</p>}
{query.isError && <p role="alert">{query.error.message}</p>}
<ul>
{query.data?.map((item) => (
<li key={item.id}>
{item.title}
{!compact && ` / 상품 ${item.id}`}
</li>
))}
</ul>
</main>
);
}
src/main.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App.jsx';
import './styles.css';
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>,
);
src/queries.js
export const productKeys = {
all: ['catalog'],
lists: () => ['catalog', 'list'],
list: (filters) => ['catalog', 'list', filters],
detail: (id) => ['catalog', 'detail', id],
};
export async function loadProducts({ category }) {
return category === 'paper' ? [{ id: 1, title: '노트' }] : [{ id: 2, title: '펜' }];
}
src/styles.css
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #fff;
color: #222;
font-family: system-ui, sans-serif;
line-height: 1.7;
}
main {
max-width: 760px;
margin: 40px auto;
padding: 24px;
}
button,
input,
select {
font: inherit;
padding: 8px 12px;
border: 1px solid #777;
background: #fff;
color: #222;
margin: 4px;
}
button:disabled {
color: #777;
}
section {
border-top: 1px solid #aaa;
padding-block: 16px;
}
li {
margin-block: 8px;
}
:focus-visible {
outline: 2px solid #222;
outline-offset: 3px;
}
test/App.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { expect, test, vi } from 'vitest';
import App from '../src/App.jsx';
function show(props = {}) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 60000 } },
});
const view = render(
<QueryClientProvider client={client}>
<App {...props} />
</QueryClientProvider>,
);
return { client, ...view };
}
import { productKeys, loadProducts } from '../src/queries.js';
test('필터 변경은 별도 요청, 보기 변경과 fresh 캐시 복귀는 요청하지 않는다', async () => {
const load = vi.fn(loadProducts);
const { client } = show({ load });
const user = userEvent.setup();
await screen.findByText('노트 / 상품 1');
await user.click(screen.getByRole('button'));
expect(load).toHaveBeenCalledTimes(1);
await user.selectOptions(screen.getByLabelText('카테고리'), 'pen');
await screen.findByText('펜');
await user.selectOptions(screen.getByLabelText('카테고리'), 'paper');
await screen.findByText('노트');
expect(load).toHaveBeenCalledTimes(2);
expect(client.getQueryData(productKeys.list({ category: 'pen' }))).toEqual([
{ id: 2, title: '펜' },
]);
});
test('객체 속성 순서는 동일 주소, prefix 무효화는 상세를 제외한다', async () => {
const client = new QueryClient();
const first = productKeys.list({ category: 'paper', page: 1 });
client.setQueryData(first, ['목록']);
client.setQueryData(productKeys.detail(1), { title: '노트' });
expect(client.getQueryData(productKeys.list({ page: 1, category: 'paper' }))).toEqual(
['목록'],
);
await client.invalidateQueries({
queryKey: productKeys.lists(),
refetchType: 'none',
});
expect(client.getQueryState(first).isInvalidated).toBe(true);
expect(client.getQueryState(productKeys.detail(1)).isInvalidated).toBe(false);
client.clear();
});
test/setup.js
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(cleanup);
vite.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
oxc: { jsx: { runtime: 'automatic' } },
test: { environment: 'jsdom', setupFiles: './test/setup.js' },
});
index.html
vite.config.js
src/main.jsx
src/styles.css
src/App.jsx
src/queries.js
test/setup.js
test/App.test.jsx
검증 결과와 적용 범위
필터별 별도 캐시, UI 보기 변경 시 요청 없음, fresh 캐시 복귀 시 요청 없음, 객체 속성 순서에 무관한 키 일치, prefix 무효화의 상세 제외를 확인합니다.
이 프로젝트는 Vitest/jsdom 테스트와 Vite production build를 통과했습니다. jsdom은 실제 브라우저가 아니므로 레이아웃·키보드 포커스의 브라우저별 차이·실제 네트워크·CORS·서버 권한·전송 취소까지 검증한 것은 아닙니다. 테스트의 fixture와 주입 함수는 정해진 입력으로 화면과 Query 동작을 확인하는 용도입니다.
연결 학습: 키 팩토리와 접두사 무효화
목표·선행 지식: 목록 키와 상세 키를 분리하고 무효화 범위를 예측합니다.
[“todo”]는 [“todo”, “list”]와 [“todo”, “detail”, id]의 공통 접두사입니다. 이것은 동일 키의 중복이 아니라 접두사 매칭입니다. exact: true는 정확히 같은 키만 선택합니다. 서버 결과에 영향을 주는 필터·페이지·ID는 키에 포함합니다.
직접 확인할 과제
목록과 두 상세 캐시를 만든 뒤 all, list, detail(id), exact 옵션별로 영향받는 키를 표로 적으세요. 무효화는 기본적으로 활성 쿼리를 재조회하며 모든 미사용 캐시를 즉시 다시 받는다는 뜻은 아닙니다.
공통 실습 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의 새 글을 확인할 수 있습니다.