TanStack Query에서 목록·상세·필터의 queryKey를 어떻게 설계하나요?
queryKey는 Query 캐시를 구분하는 식별자입니다. 목록·상세·필터처럼 다른 자료를 같은 키로 묶으면 캐시가 섞이고, 같은 자료에 불필요하게 다른 키를 쓰면 재사용이 어려워집니다.
본문 근거: tanstack.com/query/latest/docs/framework/react/guides/query-keys
이 기술만 따로 연습하기
이 개념 직접 실행
버튼을 조작하고 로딩·결과·캐시의 변화를 확인합니다.
예제 크게 보기 (새 탭)기대 결과: 전체는 읽기·쓰기, 완료는 읽기만 보입니다. 전체로 60초 안에 돌아오면 해당 키의 데이터를 즉시 재사용합니다.
직접 실행: 환경 준비와 전체 파일
실행 환경은 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
} 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 } },
});
type Filter = 'all' | 'done';
const rows = [{ id: 1, title: '읽기', done: true }, { id: 2, title: '쓰기', done: false }];
async function readRows(filter: Filter) {
await wait();
return rows.filter((row) => filter === 'all' || row.done);
}
function Demo() {
const [filter, setFilter] = useState<Filter>('all');
const query = useQuery({
queryKey: ['rows', 'list', filter],
queryFn: () => readRows(filter),
staleTime: 60_000,
});
return (
<main>
<button onClick={() => setFilter('all')}>전체</button>
<button onClick={() => setFilter('done')}>완료</button>
<p>필터: {filter}</p>
{query.isPending ? <p>요청 중</p> : query.isError ? <p>{query.error.message}</p>
: <ul>{query.data.map((row) => <li key={row.id}>{row.title}</li>)}</ul>}
</main>
);
}
export default function App() {
return (
<QueryClientProvider client={client}>
<Demo />
</QueryClientProvider>
);
}
npm run dev
npm run build
자가 확인·변형: queryKey에서 filter만 빼고 완료 버튼을 누르면 queryFn 입력 변경을 캐시가 알아내지 못합니다. 실험 뒤 키를 복구하세요.
검증 기록: 위 전체 코드는 타입검사·빌드와 jsdom 동작 검증을 통과했습니다. 실제 브라우저 화면과 누적 Next.js 프로젝트 통합 실행은 별도 확인 대상입니다.
queryKey는 캐시 주소입니다
다음 query는 작업 목록 캐시를 사용합니다.
useQuery({
queryKey: ['tasks'],
queryFn: getTasks,
});
상세 작업은 다른 캐시여야 합니다.
useQuery({
queryKey: ['tasks', taskId],
queryFn: () => getTask(taskId),
});
queryKey가 다르면 TanStack Query는 별도의 query로 관리합니다.
변수가 queryFn 결과를 바꾸면 키에도 넣기
다음 코드에는 문제가 있습니다.
useQuery({
queryKey: ['tasks'],
queryFn: () => getTasksByStatus(status),
});
status가 바뀌어도 queryKey가 그대로이므로 캐시 식별자가 요청 조건을 표현하지 못합니다.
다음처럼 조건을 키에 포함합니다.
useQuery({
queryKey: ['tasks', { status }],
queryFn: () => getTasksByStatus(status),
});
원칙은 단순합니다.
queryFn의 결과를 바꾸는 입력값
→ queryKey에도 포함
목록과 상세 키 계층
다음처럼 계층을 만들 수 있습니다.
['tasks']
['tasks', 'list']
['tasks', 'list', { status: 'active' }]
['tasks', 'detail', 'task-1']
이 구조는 부분 키로 여러 query를 함께 무효화할 때도 유용합니다.
queryClient.invalidateQueries({
queryKey: ['tasks'],
});
query key factory
문자열을 여러 파일에서 직접 반복하면 오타가 생기기 쉽습니다.
app/lib/task-keys.ts
export type TaskStatus =
| 'all'
| 'active'
| 'completed';
export const taskKeys = {
all: ['tasks'] as const,
lists: () =>
[...taskKeys.all, 'list'] as const,
list: (status: TaskStatus) =>
[
...taskKeys.lists(),
{ status },
] as const,
details: () =>
[...taskKeys.all, 'detail'] as const,
detail: (id: string) =>
[
...taskKeys.details(),
id,
] as const,
};
사용할 때는 다음처럼 씁니다.
useQuery({
queryKey: taskKeys.list(status),
queryFn: () => getTasksByStatus(status),
});
객체를 queryKey에 사용할 때
필터 조건이 여러 개라면 객체가 읽기 좋습니다.
['tasks', 'list', {
status,
page,
keyword,
}]
캐시 키에는 직렬화 가능한 값을 사용하고, 함수나 컴포넌트 인스턴스 같은 값을 키에 넣지 않습니다.
queryKey와 mutation
작업을 저장한 뒤 어떤 query를 갱신해야 하는지는 queryKey 설계에 달려 있습니다.
queryClient.invalidateQueries({
queryKey: taskKeys.lists(),
});
목록 전체 계층을 일관되게 만든 이유가 여기서 드러납니다.
흔한 실수
문자열 하나로 모든 요청을 관리
['tasks']
목록·상세·검색까지 모두 같은 키를 쓰면 요청 조건이 캐시에 반영되지 않습니다.
같은 데이터를 서로 다른 키로 표현
['tasks', id]
['task', id]
['tasks', 'detail', id]
하나의 규칙을 정하고 프로젝트 전체에서 유지합니다.
학습 완료 체크리스트
- queryKey가 캐시 식별자라는 것을 설명할 수 있습니다.
- queryFn 입력값을 queryKey에 포함할 수 있습니다.
- 목록과 상세 키를 계층적으로 설계할 수 있습니다.
- query key factory로 키 규칙을 한곳에 모을 수 있습니다.
- invalidation 범위를 키 구조로 제어할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 서버 데이터의 조회·변경과 캐시를 관리합니다.
TaskFlow의 목록과 상세 queryKey를 taskKeys로 통일합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
중간부터 시작하는 전체 실습 파일
기존 Next.js Route Handler TaskFlow의 /tasks 화면과 /api/tasks를 이어서 확장합니다. 인증 없는 demo 메모리 저장소를 사용하는 로컬 실습이며 계정·외부 DB·API 키가 필요 없습니다. MSW를 활성화하지 않습니다. 서버를 다시 시작하면 데이터가 초기화됩니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
app/api/tasks/[id]/route.ts— 이전 단계에 없던 파일을 만듭니다.app/lib/task-keys.ts— 이전 단계에 없던 파일을 만듭니다.app/tasks/TaskDashboard.tsx— 기존 파일의 같은 위치를 갱신합니다.app/tasks/TaskDetailClient.tsx— 이전 단계에 없던 파일을 만듭니다.app/tasks/TaskListClient.tsx— 기존 파일의 같은 위치를 갱신합니다.
변경 이유와 결과: task-keys.ts로 목록 필터와 상세 키를 나눕니다. 선택·필터 UI와 실제 GET /api/tasks/:id Route Handler를 추가하여 없는 함수를 남기지 않습니다.
완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.
app/api/tasks/[id]/route.ts 전체 파일
import { listTasks, changeTask } from '@/app/lib/store';
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Context) {
const { id } = await params;
const task = listTasks('demo').find((row) => row.id === id);
return task ? Response.json(task, { headers: { 'Cache-Control': 'private, no-store' } }) : Response.json({ message: '작업 없음' }, { status: 404 });
}
export async function PATCH(request: Request, { params }: Context) {
const { id } = await params;
try {
const body: unknown = await request.json();
if (!body || typeof body !== 'object' || !('completed' in body) || typeof body.completed !== 'boolean') return Response.json({ message: 'completed boolean 필요' }, { status: 400 });
await new Promise((resolve) => setTimeout(resolve, 350));
if ('simulateFailure' in body && body.simulateFailure === true) return Response.json({ message: '실습 전환 실패' }, { status: 503 });
const current = listTasks('demo').find((task) => task.id === id);
if (!current) return Response.json({ message: '작업 없음' }, { status: 404 });
if (current.completed !== body.completed) changeTask('demo', id, 'toggle');
return Response.json(listTasks('demo').find((task) => task.id === id));
} catch {
return Response.json({ message: '올바른 JSON이 필요합니다.' }, { status: 400 });
}
}
app/lib/task-keys.ts 전체 파일
import type { TaskStatus } from './tasks-api';
export const taskKeys = {
all: ['tasks'] as const,
lists: () => ['tasks', 'list'] as const,
list: (status: TaskStatus) => ['tasks', 'list', { status }] as const,
details: () => ['tasks', 'detail'] as const,
detail: (id: string) => ['tasks', 'detail', id] as const,
page: (page: number) => ['tasks', 'page', page] as const,
infinite: () => ['tasks', 'infinite'] as const,
};
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';
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>
<TaskListClient status={status} onSelect={setSelected} />
{selected && <TaskDetailClient taskId={selected} />}
</main>
);
}
app/tasks/TaskDetailClient.tsx 전체 파일
'use client';
import { useQuery } from '@tanstack/react-query';
import { getTask } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
export default function TaskDetailClient({ taskId }: { taskId: string }) {
const query = useQuery({ queryKey: taskKeys.detail(taskId), queryFn: ({ signal }) => getTask(taskId, signal), staleTime: 30_000 });
return (
<section aria-label="선택 작업 상세">
<h2>작업 상세</h2>
{query.isPending ? <p>상세 로딩 중</p> : query.isError ? <p role="alert">{query.error.message}</p> : <p>{query.data.title} · {query.data.completed ? '완료' : '진행 중'}</p>}
</section>
);
}
app/tasks/TaskListClient.tsx 전체 파일
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { Task } from '../lib/types';
import { getTasks, type TaskStatus } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
export default function TaskListClient({ status = 'all', onSelect = () => {} }: {
status?: TaskStatus; onSelect?: (id: string) => void;
}) {
const [fail, setFail] = useState(false);
const query = useQuery({
queryKey: taskKeys.list(status),
queryFn: ({ signal }) => getTasks(signal, status, fail),
});
return (
<section aria-label="서버 작업 목록">
<label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실습 조회 실패</label>
<button onClick={() => void query.refetch()}>목록 다시 확인</button>
{query.isFetching && <p role="status">요청 중입니다.</p>}
{query.isPending ? <p>불러오는 중입니다.</p> : query.isError ? <p role="alert">{query.error.message}</p> : query.data.length === 0 ? <p>표시할 작업이 없습니다.</p> : (
<ul>
{query.data.map((task) => (
<li key={task.id}>
<button onClick={() => onSelect(task.id)}>{task.title}</button>
<span> · {task.completed ? '완료' : '진행 중'}</span>
</li>
))}
</ul>
)}
</section>
);
}
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후http://localhost:3000/tasks를 엽니다.- task-keys.ts로 목록 필터와 상세 키를 나눕니다. 선택·필터 UI와 실제 GET /api/tasks/:id Route Handler를 추가하여 없는 함수를 남기지 않습니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
이 체크포인트는 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과했습니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.
이전 프로젝트 단계: TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.
app/lib/task-keys.ts를 추가하고 목록·상세의 queryKey와 mutation invalidation 키를 함께 맞춥니다.
이전 체크포인트
query-step-03-queryfn
이번 목표
TaskFlow의 목록과 상세 queryKey를 taskKeys로 통일합니다.
변경 파일
app/lib/task-keys.ts
app/tasks/TaskListClient.tsx
app/tasks/TaskDetailClient.tsx
TaskListClient 핵심 코드
const query = useQuery({
queryKey: taskKeys.list('all'),
queryFn: ({ signal }) =>
getTasks(signal),
});
TaskDetailClient 핵심 코드
const query = useQuery({
queryKey: taskKeys.detail(taskId),
queryFn: ({ signal }) =>
getTask(taskId, signal),
});
Before / After
Before
각 컴포넌트가 queryKey 문자열을 직접 작성
After
taskKeys가 목록·상세·필터 캐시 규칙을 관리
실행 확인
React Query Devtools를 사용하는 경우 목록과 상세이 서로 다른 queryKey로 표시되는지 확인합니다. Devtools가 없다면 Network 요청과 화면 전환으로 확인해도 됩니다.
npm run build
현재 확장 단계
✓ 1~3. 경계·Provider·queryFn
→ 4. queryKey
○ 5. 캐시 수명
○ 6~11. mutation·동기화·복구
○ 12~15. SSR·Hydration
완료 체크포인트
query-step-04-querykey
프로젝트 다음 단계
프로젝트 다음 단계에서는 데이터가 언제 fresh에서 stale로 바뀌는지, 사용하지 않는 캐시가 언제 제거되는지 staleTime과 gcTime을 기준으로 정리합니다.
다음 프로젝트 단계: TanStack Query staleTime gcTime 차이: 캐시 시간 기준
공식 문서
프로젝트 적용 후 확인: 필터를 바꾸면 어떤 캐시가 선택될까요?
관찰 절차 TaskFlow 전체/완료 필터를 전환하며 Network의 요청 파라미터와 Devtools의 [‘tasks’,’list’,’all’], [‘tasks’,’list’,’done’] 키를 비교합니다. 이전 필터로 돌아갈 때 새 요청이 발생하는지도 staleTime과 함께 기록합니다.
예상 결과 필터마다 다른 캐시 항목이 생기며 동일 키는 해당 캐시 데이터를 재사용합니다. 재요청 여부는 freshness 설정에 따릅니다.
오류 점검 서로 다른 필터가 같은 목록이면 queryKey에 필터 값이 포함됐는지, queryFn이 그 값을 요청에 전달하는지 확인합니다.
프로젝트 다음 단계 5회차에서 캐시 신선도와 제거 시간을 나눠 봅니다.
검증 범위 전체 체크포인트의 빌드·상태 동작은 로컬 검증을 통과했습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.
글 소개·학습 준비·전체 목차

TanStack Query · 4 / 15 · 필수 학습 · queryKey
TanStack Query에서 queryKey는 단순한 이름표가 아니라 캐시를 구분하는 식별자입니다.
목록, 상세, 필터 결과가 같은 키를 사용하면 서로 다른 서버 데이터를 같은 캐시로 취급할 수 있습니다. 반대로 같은 데이터를 매번 다른 키로 만들면 캐시를 재사용하지 못합니다.
기술 개념을 차례로 읽는다면 다음 글은 TanStack Query queryFn 사용법: 데이터 요청 로직 분리하기입니다. TaskFlow 프로젝트를 이어 만들 때는 아래 프로젝트 다음 단계를 따르세요.
이번 글에서 배우는 내용
- queryKey가 필요한 이유
- 배열 키 구조
- 목록·상세·필터 키 구분
- 키에 변수를 포함하는 기준
- query key factory
- invalidateQueries와 키 설계의 관계
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.