TanStack Query queryKey 설계의 핵심
queryKey는 서버 상태 캐시의 주소입니다. query 함수가 의존하며 응답을 바꾸는 값은 key에 포함하고, 모달 열림이나 보기 방식처럼 응답과 무관한 UI 상태는 제외해야 합니다. 목록·상세·필터·사용자 범위를 계층형 배열과 key factory로 통일하면 캐시 혼합과 mutation 뒤 잘못된 무효화를 함께 줄일 수 있습니다.
문서 기준: 이 글의 동작 설명과 코드는 2026-07-19에 확인한 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이 같은 함수를 사용하게 하면 단수·복수 오타, 배열 순서 차이, 목록과 상세 범위 혼동을 줄일 수 있습니다. 현재 글의 기존 경로인 TanStack Query에서 queryKey 이해하기도 이 배열 key 원칙을 설명하는 기준 URL로 유지됩니다.
페이지네이션과 무한 쿼리의 범위를 구분하기
일반 페이지네이션은 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-07-19에 확인한 TanStack Query React v5 공식 문서입니다.
같이 읽을 내부 글은 queryFn 이해하기, staleTime과 gcTime 차이, useInfiniteQuery 무한 스크롤입니다.
결론: 서버 응답의 경계를 캐시 주소에 그대로 표현한다
좋은 queryKey는 짧은 문자열을 고르는 문제가 아니라 서버 응답의 경계를 코드로 표현하는 설계입니다. 응답을 바꾸는 값은 빠뜨리지 않고, 응답과 무관한 UI 상태는 제외하며, 목록·상세·무한 목록을 계층형 배열로 분리합니다. 그 key를 query와 mutation이 같은 factory로 공유하면 필터와 권한 범위가 늘어나도 캐시 혼합과 잘못된 invalidation을 추적하기 쉬워집니다.
“TanStack Query queryKey 설계 기준: 배열 키로 캐시 구분하기”에 대한 2개의 생각