tanstack-query 쿼리 키 관리
매직 리터럴 이제 안녕

💩
리얼월드 in Vue (뷰얼월드…) 프로젝트를 수행하면서 Tanstack Query 를 도입했는데, 규모가 그다지 크지 않은 프로젝트를 수행했을 때의 습관이 남아서 (+ 데이터 캐싱 용도로만 TQ를 대충 사용했어서… 😕) 쿼리 키를 전부 제각각인 문자열로 다뤘더니 혼돈의 도가니탕이 되었다
쿼리 키를 전부 하드코딩한 탓에 어떤 상황에서 어떤 쿼리 키를 사용해야 하는지 혼란스러워진 것;;;
추후 좋아요 버튼에 Optimistic Update 를 적용하고 싶은데, 이를 위해서 사전 작업을 할 필요도 좀 느끼게 됐다
따라서 이번 시간에는 쿼리 키를 객체로 적절하게 다뤄보는 연습을 할 것이다…
기존 쿼리 키 관리 코드
export function useGetArticles(page: Ref<number>, params?: Params) {
return useQuery<ArticlesResponse>(
['articles', page],
() => articles.get({ ...params, offset: page.value * 10 - 10, limit: 10 }),
{
cacheTime: CACHE_TIME,
staleTime: STALE_TIME,
},
);
}
export function useGetArticle(slug: string | string[]) {
if (Array.isArray(slug)) slug = slug.join('');
return useQuery<ArticleData>(['article', slug], () => articles.getBySlug(slug as string), {
cacheTime: CACHE_TIME,
staleTime: STALE_TIME,
});
}
게시물 부분의 쿼리 키는 크게 두 가지로 구분되는데, 첫 번째는 게시물 리스트를 페이지로 구분한 쿼리이고, 두 번째는 게시물 각각을 slug로 구분한 쿼리이다
매직 리터럴로 모든 것을 작성하다 보니 나중에 useMutate 에서 쿼리 cancel, invalidate 등을 시도할 때 스스로가 헷갈리는 문제가 생겼다
이 부분을 상수화해서 한 곳에서 관리할 수 있도록 해 보자
쿼리 키
Tanstack Query에 엄청나게 많은 기여를 하신 tkdodo씨의 블로그 글을 적절히 번역해서 내가 읽어볼 만한 내용만 편집하였다…
쿼리 키는 Tanstack Query 에서 매우 중요한 컨셉 중 하나이다
Tanstack Query가 내부적으로 데이터를 적절하게 캐싱하고, 디펜던시에 변화가 생겼을 때 자동적으로 데이터를 refetch할 수 있도록 도움을 주며, 쿼리 캐시에 직접적으로 접근할 때에도 필요하다
tkdodo 씨가 어떻게 쿼리 키를 관리하는지 한번 엿보도록 하자
Deterministic way로 저장되는 쿼리 키
useQuery(['articles', { page, filter }], ...);
useQuery(['articles', { filter, page }], ...);
내부적으로 쿼리 캐시는 자바스크립트 객체로 관리되는 반면, 쿼리 키는 deterministic way로 해시화되어 저장된다
deterministic way라 함은, 위의 예시에서 객체 내부의 값의 순서가 바뀌어도 동일한 키로 취급된다는 뜻이다
배열의 맨 앞 값 (top-level 값) 만 문자열로 잘 넣어준다면, 뒤에는 객체가 들어와도 상관없다
useQuery(['articles', 'global', 15], ...);
useQuery(['articles', 15, 'global'], ...);
한편, 배열의 값의 순서는 매우 중요하다
배열의 값의 순서가 달라질 경우 다른 키로 취급됨을 주의하자
쿼리 키의 중요한 점은, 서로 다른 쿼리에 대해 서로 다른 쿼리 키를 가져야 한다는 것이다
useQuery와 useInfiniteQuery 의 쿼리 키 또한 다르게 작성하는 것이 옳다
배열 키를 사용하자
useQuery('articles', ...);
물론 문자열 쿼리 키도 사용할 수는 있지만, 이거 어차피 내부적으로 [’articles’] 로 변환된다고 한다
또한 Tanstack Query v4부터는 어차피 배열로만 사용하게끔 강제한다
쿼리 키 구조
useQuery(['articles', 'global', 'liked', 'chichoon', 5], ...);
// articles (게시물)
// global (전체 게시물 목록 중)
// liked (좋아요가 눌린)
// chichoon (chichoon에 의해)
// 5 (페이지는 5)
쿼리 키 내부 요소는 가급적 Generic (일반적인) 한 것부터 Specific (특정) 한 것 순으로 배치하자
이렇게 하면 쿼리 키에 구조가 잡히기 때문에 mutate 등의 이벤트가 일어났을 때 특정 키에 해당하는 모든 쿼리를 invalidate하기 조금 쉬워진다
예를 들면,
articles쿼리를 invalidate하면 쿼리 키에articles가 포함된 모든 쿼리들을 invalidate시킬 수 있다chichoon쿼리를 invalidate 하면articles,global,liked한 쿼리들 중chichoon쿼리들만 invalidate할 수 있다
이런 식으로 invalidate 하고자 하는 쿼리의 범위를 조절하기 간편하다
쿼리 키 팩토리 구성하기
useQuery(['articles', 'global', 'liked', 'chichoon', 5], ...);
위의 예시들은 대부분 쿼리 키를 하나하나 선언하는 식으로 (매직-리터럴 하게) 구성하였는데, 이는 에러에 취약할 뿐더러 나중에 쿼리 키를 고치기도 어렵게 만든다
위의 예시에서, 만약 global과 liked 사이에 visible 이라는 쿼리 키 단계를 추가하고 싶다면? 프로젝트 크기가 작으면 금방 고치겠지만, 크기가 매우 크고 복잡할 경우 하나하나 고치는 것도 아주 일일 것이다
이를 손쉽게 하기 위해 tkdodo 씨는 단일 쿼리 키 팩토리를 구성하는 것을 추천한다 - 쿼리 키 팩토리란? 쿼리 키를 반환하는 함수와 값으로 이루어진 객체이다
const articleKeys = {
all: ['articles'],
global: () => [...articleKeys.all, 'global'],
local: () => [...articleKeys.all, 'local'],
filtered: {
liked: (by: string, page: number) => [...articleKeys.global(), 'liked', by, page],
author: (by: string, page: number) => [...articleKeys.global(), 'author', by, page]
},
}
// ['articles', 'global', 'liked', 'chichoon', 5] 쿼리 키는
// articleKeys.filtered.liked('chichoon', 5) 와 같이 구성이 가능하다
위의 쿼리 키를 articleKeys 팩토리로 간단하게 구성해 보았다
어떤 쿼리가 어떤 쿼리에 의존적인지 쉽게 확인할 수 있고, 확장에 매우 용이하면서, 각 쿼리 키에 독자적으로 접근이 가능하다
쿼리 키 팩토리 객체를 만들어보기
쿼리 구분하기
리얼월드 프로젝트에서 게시물을 받아오는 케이스는 다음과 같다
게시물 목록을 받아오는 경우
- 페이지 변수를 받아 모든 게시물 목록을 받아오는 경우 (global)
- 페이지 변수를 이용하여 offset 을 계산한다
- 모든 게시물 목록 중 페이지에 해당하는 게시물 10개를 응답받는다
- 페이지 변수를 받아 내 피드 게시물 목록을 받아오는 경우 (feed)
- 페이지 변수를 이용하여 offset을 계산한다
- 내가 구독하는 작성자들의 게시물 목록 중 페이지에 해당하는 게시물 10개를 응답받는다
- 페이지 변수와 특정 값을 받아 게시물 목록을 필터링해서 받아오는 경우 (filtered)
- 필터링 경우의 수는 3가지가 있다: tag (태그), author (작성자), favorited (좋아요한 주체)
- 필터링된 게시물 목록 중 페이지에 해당하는 게시물 10개를 응답받는다
개별 게시물 객체를 받아오는 경우
- slug로 게시물을 판별하여 받아오는 경우 (slug)
- slug 를 인자로 받아, 이를 이용하여 게시물을 판별한다
- 단 하나의 게시물 객체를 응답받는다
쿼리 계층 구조 고민해보기
게시물 목록을 받아오는 경우
articles: 가장 일반적인, 게시물 목록 전체에 대한 쿼리global/feed: 모든 게시물 목록 / 내 피드의 게시물 목록에 대한 쿼리articles로부터 분기한다
favorited/author/tagged: 필터링 범주- 게시물 전체에서 필터링해 오므로,
global로부터 분기한다
- 게시물 전체에서 필터링해 오므로,
username(변수) /tag(변수) : 필터 값username은favorited/author에서 분기한다tag는tagged에서 분기한다
page: 페이지 번호- 모든 게시물 목록에 대해, 페이지별로 게시물을 가져오게끔 하는 키
- 가장 specific한 쿼리라고 볼 수 있다
개별 게시물 객체를 받아오는 경우
article: 가장 일반적인, 임의의 게시물 하나에 대한 쿼리slug(변수) : 게시물 구분 값- 특정 게시물을 구분하기 위한 키
- 가장 specific한 쿼리라고 볼 수 있다
쿼리 팩토리 만들어보기
export const articleKeys = {
// 게시글 목록에 관한 쿼리
lists: {
all: ['articles'] as const, // 전체 게시물 목록에 대한 쿼리
feed: {
all: () => [...articleKeys.lists.all, 'feeds'],
paged: (page: number) => [...articleKeys.lists.all, 'feeds', page] as const,
},
global: {
all: () => [...articleKeys.lists.all, 'global'],
paged: (page: number) => [...articleKeys.lists.all, 'global', page] as const,
},
filtered: {
favorited: (by: string, page: number) => [...articleKeys.lists.global.all(), 'favorited', by, page] as const, // 좋아요한 게시물 목록에 대한 쿼리
author: (by: string, page: number) => [...articleKeys.lists.global.all(), 'author', by, page] as const, // 작성자로 필터링한 게시물 목록에 대한 쿼리
tagged: (tag: string, page: number) => [...articleKeys.lists.global.all(), 'tagged', tag, page] as const, // 태그로 필터링한 게시물 목록에 대한 쿼리
},
},
// 게시물 각각에 관한 쿼리
article: {
only: ['article'] as const,
slug: (slug: string) => [...articleKeys.article.only, slug] as const, // 게시물에 대한 쿼리
},
};
계층 구조는 배열의 앞에 오는 값, 또는 객체의 구조로 구분할 수 있다
쿼리 키를 사용하고 싶을 때, articleKeys 객체를 이용하여 간단하게 특정 쿼리 키를 가져올 수 있게 되었다
또한 변경이 필요할 때도 중구난방으로 흩어진 쿼리 키들을 하나하나 변경할 필요 없이 단일 객체에서 수정하면 된다 (와〰️)
쿼리 팩토리 사용하기
export function useGetArticles(page: Ref<number>, params?: Params) {
return useQuery<ArticlesResponse>(
articleKeys.lists.global.paged(page.value), // 수정된 부분
() => articles.get({ ...params, offset: page.value * 10 - 10, limit: 10 }),
{
cacheTime: CACHE_TIME,
staleTime: STALE_TIME,
},
);
}
export function useGetArticle(slug: string | string[]) {
if (Array.isArray(slug)) slug = slug.join('');
return useQuery<ArticleData>(
articleKeys.article.slug(slug), // 수정된 부분
() => articles.getBySlug(slug as string),
{
cacheTime: CACHE_TIME,
staleTime: STALE_TIME,
}
);
}
팩토리를 사용할 때는, 원하는 쿼리 키를 불러오기 위해 객체의 함수를 호출하거나 변수를 호출하기만 하면 된다
매우 간단하지 않을 수 없다