🔥 효과적인 React Query 키 사용법

1067자
12분

React Query에서 쿼리 키는 매우 중요한 핵심 개념입니다. 이 키들은 라이브러리가 내부적으로 데이터를 올바르게 캐시하고, 쿼리의 의존성이 변경될 때 자동으로 다시 가져올 수 있게 해줍니다. 또한 필요할 때 쿼리 캐시를 수동으로 조작할 수 있게 해줍니다. 예를 들어, 뮤테이션 후 데이터를 갱신하거나 일부 쿼리를 수동으로 무효화해야 할 때 유용합니다.

이제 이 세 가지 중요한 점을 살펴보고, 이를 더 효과적으로 활용하기 위해 제가 어떻게 쿼리 키를 구성하는지 알아보겠습니다.

데이터 캐싱

쿼리 캐시는 내부적으로 단순한 JavaScript 객체입니다. 이 객체에서 키는 직렬화된 쿼리 키이고, 값은 쿼리 데이터와 메타 정보입니다. 키는 결정론적 방식으로 해시되므로 객체도 사용할 수 있습니다. 다만 최상위 수준에서는 키가 문자열이나 배열이어야 합니다.

가장 중요한 점은 쿼리마다 고유한 키를 사용해야 한다는 것입니다. React Query는 캐시에서 키에 해당하는 항목을 찾으면 그것을 사용합니다. 또한 같은 키를 useQueryuseInfiniteQuery에 동시에 사용할 수 없습니다. 쿼리 캐시는 하나뿐이며, 이 두 가지 사이에 데이터를 공유하면 문제가 발생할 수 있습니다. 무한 쿼리는 일반 쿼리와 근본적으로 다른 구조를 가지기 때문입니다.

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})
 
// 🚨 이렇게 하면 안 됩니다
useInfiniteQuery({
  queryKey: ['todos'],
  queryFn: fetchInfiniteTodos,
})
 
// ✅ 대신 이렇게 다른 키를 사용하세요
useInfiniteQuery({
  queryKey: ['infiniteTodos'],
  queryFn: fetchInfiniteTodos,
})
 
javascript

자동 다시 가져오기

쿼리는 선언적입니다. 이는 매우 중요한 개념으로, 이해하는 데 시간이 걸릴 수 있습니다. 많은 사람들이 쿼리, 특히 다시 가져오기를 명령형으로 생각합니다.

예를 들어, 쿼리가 있고 이 쿼리가 데이터를 가져옵니다. 이제 버튼을 클릭하고 다른 매개변수로 다시 가져오고 싶다고 생각합니다. 많은 사람들이 다음과 같은 방식을 시도합니다:

function Component() {
  const { data, refetch } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
 
  // ❓ refetch에 어떻게 매개변수를 전달하지? ❓
  return <Filters onApply={() => refetch(???)} />
}
 
javascript

하지만 이는 refetch의 목적이 아닙니다. refetch는 같은 매개변수로 다시 가져오기 위한 것입니다.

데이터를 변경하는 상태가 있다면, 그 상태를 쿼리 키에 넣기만 하면 됩니다. React Query는 키가 변경될 때마다 자동으로 다시 가져오기를 실행합니다. 따라서 필터를 적용하고 싶다면, 클라이언트 상태만 변경하면 됩니다:

function Component() {
  const [filters, setFilters] = React.useState()
  const { data } = useQuery({
    queryKey: ['todos', filters],
    queryFn: () => fetchTodos(filters),
  })
 
  // ✅ 로컬 상태를 설정하고 이를 통해 쿼리를 실행합니다
  return <Filters onApply={setFilters} />
}
 
javascript

setFilters 업데이트로 인한 다시 렌더링은 React Query에 다른 쿼리 키를 전달하게 되고, 이로 인해 다시 가져오기가 실행됩니다.

수동 조작

쿼리 캐시를 수동으로 조작할 때 쿼리 키의 구조가 가장 중요합니다. invalidateQueriessetQueriesData 같은 많은 조작 메서드는 쿼리 필터를 지원합니다. 이를 통해 쿼리 키를 느슨하게 매칭할 수 있습니다.

효과적인 React Query 키 사용법

이제부터 제가 개인적으로 선호하는 쿼리 키 사용 방법을 소개하겠습니다. 이는 앱이 복잡해질 때 가장 잘 작동하며 확장성도 좋습니다. 간단한 Todo 앱에는 이 정도로 복잡한 방식이 필요하지 않을 수 있습니다.

공동 배치

Kent C. Dodds의 "공동 배치를 통한 유지 보수성"이라는 글을 아직 읽지 않았다면 꼭 읽어보세요. 모든 쿼리 키를 /src/utils/queryKeys.ts와 같은 곳에 전역으로 저장하는 것은 좋지 않습니다. 저는 쿼리 키를 해당 쿼리와 함께 feature 디렉토리에 같이 배치합니다. 예를 들면 다음과 같습니다:

- src
  - features
    - Profile
      - index.tsx
      - queries.ts
    - Todos
      - index.tsx
      - queries.ts
text

'queries' 파일에는 React Query와 관련된 모든 것이 포함됩니다. 저는 보통 사용자 정의 훅만 내보내고, 실제 쿼리 함수와 쿼리 키는 로컬에 유지합니다.

항상 배열 키 사용하기

쿼리 키는 문자열도 가능하지만, 일관성을 위해 항상 배열을 사용하는 것이 좋습니다. React Query는 내부적으로 어차피 배열로 변환합니다:

// 🚨 어차피 ['todos']로 변환됩니다
useQuery({ queryKey: 'todos' })
// ✅
useQuery({ queryKey: ['todos'] })
 
javascript

업데이트
React Query v4부터는 모든 키가 반드시 배열이어야 합니다.

구조

쿼리 키를 가장 일반적인 것부터 가장 구체적인 것 순으로 구조화하세요. 필요에 따라 여러 단계의 세분화를 추가할 수 있습니다. 예를 들어, 필터링 가능한 목록과 상세 보기가 있는 할 일 목록은 다음과 같이 구조화할 수 있습니다:

['todos', 'list', { filters: 'all' }]
['todos', 'list', { filters: 'done' }]
['todos', 'detail', 1]
['todos', 'detail', 2]
 
javascript

이 구조를 사용하면 ['todos']로 할 일(todo) 관련 모든 것을 무효화하거나, 모든 목록 또는 모든 상세 정보를 무효화할 수 있습니다. 또한 정확한 키를 알고 있다면 특정 목록만 타겟팅할 수도 있습니다. 이를 통해 뮤테이션 응답에서의 업데이트가 훨씬 더 유연해집니다. 필요한 경우 모든 목록을 타겟팅할 수 있기 때문입니다:

function useUpdateTitle() {
  return useMutation({
    mutationFn: updateTitle,
    onSuccess: (newTodo) => {
      // ✅ 할 일 상세 정보 업데이트
      queryClient.setQueryData(
        ['todos', 'detail', newTodo.id],
        newTodo
      )
 
      // ✅ 이 할 일을 포함하는 모든 목록 업데이트
      queryClient.setQueriesData(['todos', 'list'], (previous) =>
        previous.map((todo) =>
          todo.id === newTodo.id ? newTodo : todo
        )
      )
    },
  })
}
 
javascript

목록과 상세 정보의 구조가 많이 다르다면 이 방법이 효과적이지 않을 수 있습니다. 그런 경우에는 대신 모든 목록을 무효화할 수 있습니다:

function useUpdateTitle() {
  return useMutation({
    mutationFn: updateTitle,
    onSuccess: (newTodo) => {
      queryClient.setQueryData(
        ['todos', 'detail', newTodo.id],
        newTodo
      )
 
      // ✅ 모든 목록을 무효화합니다
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list']
      })
    },
  })
}
 
javascript

URL에서 필터를 읽어 현재 어떤 목록에 있는지 알 수 있다면, 이 두 방법을 조합하여 현재 목록에 setQueryData를 호출하고 다른 모든 목록을 무효화할 수 있습니다:

function useUpdateTitle() {
  // URL에 저장된 현재 필터를 반환하는
  // 사용자 정의 훅을 상상해보세요
  const { filters } = useFilterParams()
 
  return useMutation({
    mutationFn: updateTitle,
    onSuccess: (newTodo) => {
      queryClient.setQueryData(
        ['todos', 'detail', newTodo.id],
        newTodo
      )
 
      // ✅ 현재 보고 있는 목록을 업데이트합니다
      queryClient.setQueryData(
        ['todos', 'list', { filters }],
        (previous) =>
          previous.map((todo) =>
            todo.id === newTodo.id ? newTodo : todo
          )
      )
 
      // 🥳 모든 목록을 무효화하지만,
      // 활성 목록은 다시 가져오지 않습니다
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list'],
        refetchActive: false,
      })
    },
  })
}
 
javascript

업데이트
v4에서는 refetchActive가 refetchType으로 대체되었습니다. 위의 예에서는 아무것도 다시 가져오지 않기를 원하므로 refetchType: 'none'이 됩니다.

쿼리 키 팩토리 사용하기

위의 예에서 볼 수 있듯이, 쿼리 키를 수동으로 선언하는 경우가 많습니다. 이는 오류가 발생하기 쉽고, 나중에 키에 다른 세분화 수준을 추가하는 등의 변경을 어렵게 만듭니다.

그래서 저는 기능마다 하나의 쿼리 키 팩토리를 사용하는 것을 추천합니다. 이는 쿼리 키를 생성하는 항목과 함수가 있는 간단한 객체로, 사용자 정의 훅에서 사용할 수 있습니다. 위의 예시 구조에 대한 쿼리 키 팩토리는 다음과 같습니다:

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
}
 
javascript

이 방식은 각 수준이 다른 수준 위에 구축되면서도 독립적으로 접근할 수 있어 많은 유연성을 제공합니다:

// 🕺 할 일 기능과 관련된 모든 것을 제거합니다
queryClient.removeQueries({
  queryKey: todoKeys.all
})
 
// 🚀 모든 목록을 무효화합니다
queryClient.invalidateQueries({
  queryKey: todoKeys.lists()
})
 
// 🙌 단일 할 일을 미리 가져옵니다
queryClient.prefetchQueries({
  queryKey: todoKeys.detail(id),
  queryFn: () => fetchTodo(id),
})
 
javascript

이렇게 쿼리 키를 구조화하고 관리하면 React Query를 더 효과적으로 사용할 수 있습니다. 복잡한 애플리케이션에서도 데이터를 일관되게 관리하고 쉽게 조작할 수 있게 됩니다.