치춘짱베리굿나이스

tanstack-query 쿼리 키 관리 본문

ClientSide/라이브러리

tanstack-query 쿼리 키 관리

치춘 2023. 10. 21. 13:50

매직 리터럴 이제 안녕

💩

리얼월드 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'], ...);

한편, 배열의 값의 순서는 매우 중요하다

배열의 값의 순서가 달라질 경우 다른 키로 취급됨을 주의하자

쿼리 키의 중요한 점은, 서로 다른 쿼리에 대해 서로 다른 쿼리 키를 가져야 한다는 것이다

useQueryuseInfiniteQuery 의 쿼리 키 또한 다르게 작성하는 것이 옳다

배열 키를 사용하자

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], ...);

위의 예시들은 대부분 쿼리 키를 하나하나 선언하는 식으로 (매직-리터럴 하게) 구성하였는데, 이는 에러에 취약할 뿐더러 나중에 쿼리 키를 고치기도 어렵게 만든다

위의 예시에서, 만약 globalliked 사이에 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 (변수) : 필터 값
    • usernamefavorited / author 에서 분기한다
    • tagtagged 에서 분기한다
  • 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,
    }
  );
}

팩토리를 사용할 때는, 원하는 쿼리 키를 불러오기 위해 객체의 함수를 호출하거나 변수를 호출하기만 하면 된다

매우 간단하지 않을 수 없다


참고 자료

https://tkdodo.eu/blog/effective-react-query-keys

Comments