React Query에서 query key를 효율적으로 관리하는 방법
React Query를 쓰다 보면 생각보다 빨리 이런 시점이 옵니다.
- query는 늘어나는데 key 이름이 제각각이다
- 어떤 mutation 뒤에 뭘 invalidate해야 할지 헷갈린다
- 리스트와 상세가 같은 도메인인지도 코드만 보고는 잘 안 보인다
- prefetch를 붙이고 싶은데 어떤 key를 재사용해야 하는지 애매하다
처음엔 ['posts'], ['post', id] 정도로 충분해 보이지만, 프로젝트가 조금만 커져도 query key는 그냥 문자열 배열이 아니라 캐시 구조 그 자체가 됩니다.
그래서 실무에서 React Query를 오래 안정적으로 쓰고 싶다면, fetch 함수보다 먼저 query key를 어떻게 설계할지부터 잡는 편이 좋습니다.
한눈에 보면
짧게 정리하면 이렇습니다.
- query key는 단순 식별자가 아니라 캐시 구조입니다.
- 잘 만든 query key는 조회, prefetch, invalidate, 캐시 직접 수정까지 같은 언어로 이어집니다.
- 실무에서는 도메인 단위
query key factory를 두는 편이 가장 안정적입니다. - 중요한 것은 멋진 추상화보다 팀 전체가 같은 naming 규칙을 쓰는 것입니다.
즉, query key를 잘 관리한다는 것은 문법 문제가 아니라 서버 상태를 어떻게 분류하고 연결할 것인가를 정하는 일에 가깝습니다.
왜 query key가 중요한가?
React Query는 결국 key를 기준으로 움직입니다.
- 어떤 데이터를 캐시에 저장할지
- 어떤 데이터를 재사용할지
- 어떤 데이터를 invalidate할지
- 어떤 데이터를 prefetch할지
즉 같은 fetch 함수를 써도 key가 다르면 다른 캐시고, fetch 함수가 달라도 key가 잘못 겹치면 같은 캐시처럼 오동작할 수 있습니다.
예를 들어 아래처럼 흩어져 있으면 금방 혼란이 옵니다.
['posts']['postList']['articles'][('posts', page)][('post', id)][('post-detail', id)];전부 게시글 도메인을 다루는 것 같지만, naming 규칙이 없으니:
- 어떤 key가 리스트인지
- 어떤 key가 상세인지
- 어떤 범위를 invalidate해야 하는지
가 코드만 봐서는 분명하지 않습니다.
가장 먼저 해야 할 것: 도메인 기준으로 나누기
보통은 화면 단위보다 도메인 단위로 나누는 편이 좋습니다.
예를 들어:
postsusersnotificationscomments
처럼 먼저 상위 도메인을 정합니다.
그 다음 각 도메인 안에서 다시:
- 전체
- 리스트
- 상세
- 필터가 있는 리스트
순으로 내려갑니다.
이 관점이 중요한 이유는 mutation 이후 invalidate 범위를 자연스럽게 잡을 수 있기 때문입니다.
가장 기본적인 패턴
도메인 단위 factory는 보통 이렇게 시작합니다.
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: { page: number; sort: string }) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (postId: number) => [...postKeys.details(), postId] as const,
};이 구조의 장점은 명확합니다.
posts도메인이라는 것이 바로 보이고- 리스트와 상세의 관계가 드러나며
- invalidate 범위를 계층적으로 잡을 수 있습니다
예를 들어 조회는 이렇게 씁니다.
useQuery({
queryKey: postKeys.list({ page: 1, sort: 'latest' }),
queryFn: () => getPosts({ page: 1, sort: 'latest' }),
});
useQuery({
queryKey: postKeys.detail(postId),
queryFn: () => getPost(postId),
});그리고 invalidate도 같은 언어로 이어집니다.
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
queryClient.invalidateQueries({ queryKey: postKeys.detail(postId) });즉, key 설계가 잘 되면 조회 코드와 동기화 코드가 자연스럽게 연결됩니다.
리스트와 상세를 왜 분리해서 생각해야 할까?
이건 실무에서 특히 중요합니다.
예를 들어 게시글 수정이 일어났다고 가정해보겠습니다.
- 상세 화면은 방금 수정한 데이터 하나만 바뀌면 되고
- 리스트 화면은 제목, 썸네일, 카운트 같은 일부 요약 정보가 바뀔 수 있습니다
이때 key 구조가 아래처럼 분명하면:
postKeys.lists();
postKeys.detail(postId);수정 후 전략을 분리하기 쉬워집니다.
queryClient.setQueryData(postKeys.detail(updatedPost.id), updatedPost);
queryClient.invalidateQueries({ queryKey: postKeys.lists() });즉, 상세는 직접 캐시 수정, 리스트는 invalidate처럼 전략을 섞기 쉬워집니다.
반대로 key 구조가 흐리면 "일단 ['posts'] 다 날릴까?" 같은 식으로 거칠어지기 쉽습니다.
필터는 query key에 어디까지 넣어야 할까?
실무에서 자주 헷갈리는 부분입니다.
원칙은 단순합니다.
서버 응답 결과를 바꾸는 값은 query key에 들어가야 합니다.
예를 들어:
- page
- sort
- category
- search keyword
- status filter
같은 값은 key에 포함해야 합니다.
postKeys.list({
page: 1,
sort: 'latest',
});혹은 조금 더 복잡한 경우:
['posts', 'list', { page, sort, category, keyword }];이렇게 해야 React Query가 "이건 같은 리스트가 아니라 다른 조건의 리스트"라고 인식합니다.
반대로 서버 응답을 바꾸지 않는 값은 굳이 key에 넣지 않는 편이 좋습니다.
예를 들어:
- 클라이언트 전용 UI 탭 상태
- 패널 열림 여부
- 선택된 카드 하이라이트 상태
이런 값은 client state 문제이지 server state 식별자 문제는 아닙니다.
객체를 key에 넣어도 될까?
가능합니다. 실제로도 많이 씁니다.
['posts', 'list', { page: 1, sort: 'latest' }];다만 실무에서는 아래를 같이 신경 써야 합니다.
- 의미 없는 값이 섞이지 않도록 하기
undefined, 빈 문자열, 불필요한 옵션을 줄이기- 같은 의미인데 매번 다른 모양의 객체를 만들지 않기
예를 들어:
['posts', 'list', { page: 1, sort: 'latest' }][('posts', 'list', { sort: 'latest', page: 1 })];이 정도는 보통 라이브러리가 잘 처리하지만, 팀 차원에서는 항상 같은 필드 집합과 같은 의미의 파라미터를 넣는 습관이 중요합니다.
즉, 객체 key를 쓴다고 문제가 생긴다기보다, 의미 없는 필드가 섞인 불안정한 filter 객체가 문제를 만드는 경우가 많습니다.
query key factory는 어디까지 추상화해야 할까?
여기서도 과한 추상화는 오히려 독이 될 수 있습니다.
좋은 방향은 보통 이 정도입니다.
- 도메인별 key factory를 둔다
- 리스트/상세 계층을 나눈다
- 필요한 파라미터만 함수 인자로 받는다
예를 들어 이 정도는 좋습니다.
export const userKeys = {
all: ['users'] as const,
list: (filters: UserListFilter) => [...userKeys.all, 'list', filters] as const,
detail: (userId: string) => [...userKeys.all, 'detail', userId] as const,
};반면 너무 범용적으로 가면 오히려 읽기 어려워질 수 있습니다.
createQueryKeys('posts', {
list: (params) => [params],
detail: (id) => [id],
});이런 추상화는 처음엔 깔끔해 보여도, 실제 코드 리뷰나 디버깅 시 도메인 의미가 바로 안 보이는 문제가 생기기 쉽습니다.
즉, key factory의 목적은 추상화 그 자체가 아니라 의도를 코드에서 바로 읽히게 만드는 것입니다.
invalidate는 key 구조를 따라가야 한다
실무에서 query key가 진짜 힘을 발휘하는 지점은 invalidate입니다.
예를 들어 게시글 생성 후에는 보통 리스트가 오래되었을 가능성이 높습니다.
queryClient.invalidateQueries({ queryKey: postKeys.lists() });게시글 상세 수정 후에는 이렇게 갈 수 있습니다.
queryClient.setQueryData(postKeys.detail(updatedPost.id), updatedPost);
queryClient.invalidateQueries({ queryKey: postKeys.lists() });즉, invalidate는 "대충 posts 전체"가 아니라 이 mutation이 어떤 캐시 계층에 영향을 주는가를 기준으로 잡아야 합니다.
이게 잘 되려면 query key 구조도 계층적으로 설계되어 있어야 합니다.
prefetch도 같은 key를 재사용해야 한다
prefetch는 종종 별도 코드처럼 느껴지지만, 사실 같은 key 체계를 재사용해야 합니다.
예를 들어 리스트에서 상세로 넘어가기 전 미리 데이터를 가져온다고 하면:
queryClient.prefetchQuery({
queryKey: postKeys.detail(postId),
queryFn: () => getPost(postId),
});이렇게 하면:
- 실제 상세 화면의
useQuery - hover 시 prefetch
- SSR prefetch
가 모두 같은 언어를 쓰게 됩니다.
즉, query key를 잘 관리하는 팀은 prefetch 코드도 자연스럽게 읽힙니다.
무한 스크롤에서는 key를 어떻게 봐야 할까?
useInfiniteQuery에서도 원칙은 같습니다.
필터가 다르면 다른 리스트입니다.
['posts', 'infinite', { category, sort, keyword }];여기서 중요한 점은 pageParam은 key에 넣지 않는다는 것입니다. pageParam은 내부적으로 다음 페이지를 가져오는 흐름에 쓰고, 캐시 식별자는 "이 무한 리스트가 어떤 조건의 리스트인가"를 기준으로 잡습니다.
즉:
category,sort,keyword: key에 포함pageParam:queryFn과getNextPageParam흐름에서 관리
이 구조를 지키면 필터 변경과 페이지 누적이 깔끔하게 분리됩니다.
query options와 같이 두면 더 좋아진다
실무에서는 key만 따로 두기보다, query options 생성 함수와 묶는 패턴도 꽤 좋습니다.
import { queryOptions } from '@tanstack/react-query';
export function postDetailQueryOptions(postId: number) {
return queryOptions({
queryKey: postKeys.detail(postId),
queryFn: () => getPost(postId),
staleTime: 1000 * 60,
});
}그러면:
useQuery(postDetailQueryOptions(postId))prefetchQuery(postDetailQueryOptions(postId))
처럼 조회와 prefetch가 더 자연스럽게 재사용됩니다.
즉, 잘 만든 key는 나중에 options 재사용 패턴과도 연결되기 좋습니다.
자주 하는 실수
정리하면 아래 실수가 정말 자주 나옵니다.
- 같은 도메인인데 key 이름이 제각각이다
- 리스트와 상세를 같은 수준에서 섞어버린다
- 필터를 query key에 넣지 않는다
- 서버 응답과 무관한 UI 상태까지 key에 넣는다
- invalidate 범위를 너무 넓게 잡는다
- prefetch와 실제 조회가 다른 key를 쓴다
이 문제들은 대부분 문법 실수라기보다, 캐시 구조에 대한 공통 규칙이 없는 상태에서 생깁니다.
추천하는 시작점
처음부터 거대한 규칙을 만들 필요는 없습니다. 보통은 아래 정도면 충분히 좋습니다.
- 도메인별
keys.ts를 만든다 all,lists,list,details,detail정도의 계층만 먼저 둔다- 필터는 "응답을 바꾸는 값만" key에 넣는다
- invalidate와 prefetch도 같은 key factory를 재사용한다
이 네 가지만 맞춰도 query key 관리 수준이 꽤 올라갑니다.
정리하면
React Query에서 query key를 효율적으로 관리한다는 것은 결국 캐시를 사람이 읽을 수 있는 구조로 만든다는 뜻입니다.
핵심은 이렇습니다.
- key는 단순 식별자가 아니라 캐시 구조이고
- 도메인 단위 factory를 두면 리스트/상세/prefetch/invalidate가 같은 언어로 이어지며
- 필터는 서버 응답을 바꾸는 값만 포함해야 하고
- 중요한 것은 화려한 추상화보다 팀 전체가 일관된 규칙을 쓰는 것입니다
실무에서 React Query를 오래 잘 쓰는 팀은 fetch 코드보다 query key 구조가 먼저 읽힙니다. 결국 서버 상태를 잘 다룬다는 것은 데이터를 잘 가져오는 것보다, 그 데이터를 어떤 이름과 계층으로 유지할지 잘 정하는 것에 더 가깝습니다.
