sharingStorage

React query 쿼리키 관리하기 (feat: query key factory) 본문

Front-End

React query 쿼리키 관리하기 (feat: query key factory)

Anstrengung 2024. 8. 27. 15:32

원래의 mile 서비스는 다음과 같이 각 페이지 폴더 내에 커스텀훅을 모아두는 페이지 상단에 객체로 키를 정의해두었습니다.

“커스텀훅을 사용해서 관심사를 분리하고, 객체 키를 사용해서 오타나 중복 등의 휴먼에러를 줄인다.” 라는 접근으로 키를 구성하였지만 이런 과정에서 두가지 놓친 부분은 쿼리키의 hierarchy 구조를 잘 활용하지 못하는 방향으로 구성되어있고, 다른 페이지에서의 쿼리키는 여전히 중복 가능성이 존재한다는 것입니다.

 

이로인해 쿼리키를 invalid할때 관련된 여러 쿼리를 invalid하는데에 어려움과 키가 고유해야한다는 가장 중요한 부분을 놓칠 수도 있다는 문제점이 발생하였고

 

이제는 MVP를 구성하고 데모데이에 우리의 서비스를 보여주는 것에 집중하는 것에 넘어서 이제는 우리서비스를 더 쉽게 유지보수할 수 있고 더 견고하게 만들기 위해 react query의 query key를 알아보고 해당 부분을 개선해보려고 합니다.

 

본격적으로 들어가기 전에 해당 글에서는 tanstack query보다 조금 더 익숙한 react query로 언급하고 있음을 미리 말씀드립니다.

 

Query Key

간단히 Query Key에 대해서 알아보자면

React query의 query key는 라이브러리에서 내부적으로 데이터를 올바르게 캐시하고 query에 대한 종속성이 변경될 때 자동으로 다시 fetch하기 위해 필요한 핵심 개념이고, 데이터 변경 후 업데이트가 필요하거나 일부 query를 수동으로 invalid시켜야할 때 query캐시에 수동으로 접근할 수 있게 합니다.

 

내부적으로 query캐시는 단순히 JS객체입니다. 여기서 key는 직렬화된 query key이고 value는 query data와 meta data입니다. 키는 결정적으로 해시되므로 객체로 사용할 수도 있습니다. (최상위 레벨에서는 키는 문자열 또는 배열이여야함)

 

가장 중요한 부분은 query key가 고유해야한다는 것입니다.

 

PS) useQuery와 useInfiniteQuery에서는 동일한 키를 사용할 수 없습니다. infiniteQuery는 일반적인 query와는 구조적인 차이가 있기 때문입니다.

 

Query는 선언형(Declarative)입니다.

즉 query는 어떻게 동작해야하는지 구체적인 절차나 단계를 기술하는 것(명령형)이 아닌 무엇을 할지에 집중하는 방식으로 구성됩니다.

function Component() {
  const { data, refetch } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  // ❓ how do I pass parameters to refetch ❓
  return <Filters onApply={() => refetch(???)} />

위와 같은 방법은 refetch의 용도가 아닙니다. refetch는 동일한 매개변수로 데이터를 다시 가져오는데 사용됩니다.

이것을 더 좋은 방법으로 개선하는 방법은 다음과 같습니다.

만약 데이터에 변화를 주는 상태(state)가 있다면 그 상태를 query key에 포함시키기만 하면 됩니다. React-query는 키가 변경될 때마다 자동으로 다시 데이터를 가져오도록 trigger 하기 때문입니다. 따라서 필터를 적용하고 싶으면 단지 클라이언트 상태만 변경하면 됩니다. 즉 아래와 같이 query key를 구성할 수 있습니다.

 

type State = 'all' | 'open' | 'done'
type Todo = {
  id: number
  state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}

export const useTodosQuery = (state: State) =>
  useQuery({
    queryKey: ['todos', state],
    queryFn: () => fetchTodos(state),
  })

 

 

수동으로 상호작용 하는 방법

query 캐시와 수동으로 상호작용할 때는 query key의 구조가 가장 중요합니다.

invalidadteQueries나 setQueriesData와 같은 여러 query 메서드는 쿼리 필터 (query filter)를 지원하며 이를 통해 query key를 대략적으로 일치시킬 수 있습니다.

Query filter

query filter는 다음과 같은 특정 조건을 충족하는 쿼리와 일치하는 개체입니다.

// 모든 쿼리를 취소
await queryClient.cancelQueries()

// 'posts'로 시작하는 모든 inactive queries를 제거
queryClient.removeQueries({ queryKey: ['posts'], type: 'inactive' })

// 모든 active 쿼리를 refetch
await queryClient.refetchQueries({ type: 'active' })

// 'posts'로 시작하는 모든 active쿼리를 refetch
await queryClient.refetchQueries({ queryKey: ['posts'], type: 'active' })

간단히 몇가지 옵션만 살펴보고 넘어가자면 등등의 옵션으로 필터를 활용할 수 있습니다.

queryKey? 쿼리키
type? (active | inactive | all) 활성화 상태에 대한 쿼리를 필터링
stale? (boolean) true일 때 stale한 쿼리, false일 때 fresh한 쿼리를 필터링

 

 

tkdodo가 말하는 효과적인 React Query key - Colocation

tkdodo는 복잡해졌을 때 가장 잘 작동하며 보다 잘 확장된다고 생각하는 효과적인 키 배치에 대해 말합니다.

두괄식으로 말하자면 Query key를 /src/utils/queryKeys.ts 에 전역적으로 저장하는 것은 권장하는 방법이 아닙니다. 이는 공동 배치(colocation)의 이점을 잃는 것입니다.

 

그 예로 주석을 들 수 있는데요. 여러분들은 주석을 어디에 배치하시나요? 보통은 설명하기 위한 함수 내지 컴포넌트의 상단에 배치할 것 입니다.

허면 주석을 docs/ 라는 폴더 안에 md파일로 구성해 완전히 별도의 파일에 넣으면 어떨까요?? 이는 아래와 같은 문제를 발생시킬 수 있습니다.

 

유지 보수의 어려움 - 동기화되기 쉽지 않음

적용 가능성 - 코드를 보는 사람들이 해당 주석파일을 깨닫지 못해 중요한 주석을 놓치거나 코드에 주석을 달지 못함

사용 편의성 - 이런 구조는 컨텍스트를 전환하는 것도 어렵고 필요한 해당 요소를 모두 갖추었는지 확인하기 어려워짐. 또한 파일이 비대해지는 것을 막을 수 없음

 

결국 이 원리는 "함께 변하는 것들은 가능한 한 가까이에 위치해야 한다” 라는 개념에 기반합니다.

 

Query key 구조

가장 일반적인 것부터 구체적인 순으로 query key를 구조화하고, 그 사이에 적절하다고 생각하는 수준의 세분화된 기준을 적용합니다.

제가 이부분을 개선하면서 고려한 부분은 아래와 같습니다. 아래 4개의 고려사항을 생각하며 query key factories라는 개념을 사용해서 문제를 해결해보겠습니다.

  1. 중복되지 않는 키
  2. 배열로 위계가 잡힌 키 (효율적인 invalid를 위해)
  3. 쉽게 파악할 수 있는 키 구조
  4. 공동배치 (colocation)

 

현재의 문제점

쿼리키가 hierarchy 구조로 이루어져있지 않습니다.

 

저희 서비스 mile은 글쓰기 커뮤니티로 글모임 - 글 - 댓글, 좋아요 등으로 충분히 위계를 잡을 수 있는 구조입니다. 해서 이부분을 고려하여 더 나은 query key 구조를 구성해봐야합니다.

현재 폴더구조는 아래와 같고 hooks내에 query.ts 파일에 해당 페이지에서 사용하는 query key 객체로 선언하여 중복을 피하고 오타등의 휴먼 에러를 줄이기 위해 노력했지만, 페이지별로 나누어져있기에 postDetail page키와 groupFeed page 키가 중복될 가능성이 여전히 존재합니다.

 |pages
    |-- 📁postDetail
    |   |     |-- 📁apis
    |   |    	|-- 📁components
    |   |    	|-- 📁constants
    |   |	    |-- 📁hooks
    |   |    	|-- postDetail.tsx
    |-- 📁groupFeed
    |   |     |-- 📁apis
    |   |    	|-- 📁components
    |   |    	|-- 📁constants
    |   |	    |-- 📁hooks
    |   |    	|-- groupFeed.tsx

 

 

저희 서비스 중 groupFeed page에서 사용하는 api와 해당하는 쿼리키들을 세분화 한 것입니다.

group info user topic
info detail userName todayTopic
user auth userInfo posts
topic public userGroups  
  curiousWriter    
  curiousPosts    
       

해당 query key들의 위계를 잡은 기준은 api구조와 데이터가 refetch 될 때 같이 invalid해야하는 것들은 무엇이 있을까에 대한 고민에 기반하였습니다. 예를 들면 admin페이지에서 그룹의 정보가 바뀌면 detail, auth, isPublic 등이 한번에 바뀌어야하기 때문에 info로 invalid해주어서 위계 상 아래에 있는 모든 캐싱데이터를 invalid해주면 되고

글감을 변경하거나 추가하는 api에서는 topics를 invalid해주면 다른 캐싱된 데이터들이 불필요하게 invalid되지 않습니다.

 

 

개선 전  query key 구조

export const QUERY_KEY_GROUPFEED = {
  getGroupFeedAuth: 'getGroupFeedAuth',
  getGroupFeedPublicStatus: 'getGroupFeedPublicStatus',
  todayTopic: 'todayTopic',
  getCuriousPost: 'getCuriousPost',
  getGroupFeedCategory: 'getGroupFeedCategory',
  getCuriousWriters: 'getCuriousWriters',
  getArticleList: 'getArticleList',
  fetchHeaderGroup: 'fetchHeaderGroup',
  getWriterNameOnly: 'getWriterNameOnly',
  getWriterInfo: 'getWriterInfo',
};

 

개선 후 query key 구조

export const groupQueryKey = {
  all: ['group'],
  info: () => [...groupQueryKey.all, 'info'],
  detail: (groupId: string) => [...groupQueryKey.info(), 'detail', groupId],
  auth: (groupId: string) => [...groupQueryKey.info(), 'auth', groupId],
  isPublic: (groupId: string) => [...groupQueryKey.info(), 'public', groupId],
  topics: (groupId: string) => [...groupQueryKey.all, 'topic', groupId],
  topic: (groupId: string) => [...groupQueryKey.topics(groupId), 'todayTopic'],
  posts: (topicId: string, groupId: string) => [...groupQueryKey.topics(groupId), 'posts', topicId],
  curiousPosts: (groupId: string) => [...groupQueryKey.info(), 'curiousPosts', groupId],
  curiousWriters: (groupId: string) => [...groupQueryKey.info(), 'curiousWriter', groupId],

  user: () => [...groupQueryKey.all, 'user'],
  userName: (groupId: string) => [...groupQueryKey.user(), 'userName', groupId],
  userInfo: (writerNameId: number | undefined) => [
    ...groupQueryKey.user(),
    'userInfo',
    writerNameId,
  ],
  userGroups: () => [...groupQueryKey.user(), 'groups'],
};

 

 

https://github.com/lukemorales/query-key-factory

  1. 이러한 방법은@lukemorales/query-key-factory 라는 라이브러리를 사용해서도 구현할 수 있고 라이브러리 도입시 일관성있고 표준화된 사용이 가능하고 mergeQueryKeys를 통해 하나의 파일에서 query를 import하고 관리할 수 있습니다. (선언은 각 폴더에) 

    이는 추후 라이브러리에서 제공하는 기능들이 무엇이 있는지 알아보고 조금 더 공부해본 후 모든 쿼리키 관리 로직을 마이그레이션 하려고 합니다.
  2. 쿼리팩토리 적용시에 queryFn도 같이 적용할 수 있지만 지금은 queryKey의 위계를 잡고 중복을 없애고자 하는 목표를 가지고 시작한 일이니 해당 태스크에 집중해보려고 합니다.

 

몇가지 추가 정보

Q 왜 일반적인 배열 형태가 아닌 [...groupQueryKey.all, 'info'] 함수형식() => [...groupQueryKey.all, 'info']으로 선언하나요?

A 일반적으로 groupQueryKey를 정의하는 객체 내에서 ...groupQueryKey.all에 접근할 수 없기 때문입니다.

 

따라서 위와 같이 함수형식으로 정의하고 함수가 실행될 때는 groupQueryKey가 이미 정의된 상태이므로 에러가 발생하지 않습니다. (객체 내부 속성값으로 함수를 선언하면 함수는 정의시점이 아닌 실행시점에 평가됨)

 

Q as const를 왜 사용해야하나요?

A const assertion을 사용해서 보다 단순 문자열이 아닌 구체적인 타입을 지정하게 할 수 있습니다.

객체나 배열에서는 읽기 전용(read only)로 변경하고 타입의 범위를 좁힐 수 있습니다.

이러한 특징으로 현재의 쿼리키들이 단순히 string 배열이 아닌 각자의 타입의 범위로 좁힐 수 있습니다.

ex) const assertion 예시

const foo=hello; // type:string

const foo= hello as const // type: hello

 

 

결론

react query dev tools로 확인해본 결과 아래와 같이 위계를 잡을 수 있었고

결론적으로 query key 중복 가능성을 없애고 이후 키의 세분화에 변경등의 더 쉽게 대처할 수 있게 되었으며 더 용이한 수동 상호작용 (invalidateQueries)이 가능해졌습니다.

 

 

Reference

 

'Front-End' 카테고리의 다른 글

Next.js톺아보기 What How When Why  (10) 2023.11.29
cookie  (0) 2023.11.15
[패키지 매니저] npm, yarn, pnpm, yarn-berry  (0) 2023.04.14
IE에서 ajax cache이슈  (0) 2022.05.11
Comments