🔥 효과적인 React Query 키 사용법
React Query에서 쿼리 키는 매우 중요한 핵심 개념입니다. 이 키들은 라이브러리가 내부적으로 데이터를 올바르게 캐시하고, 쿼리의 의존성이 변경될 때 자동으로 다시 가져올 수 있게 해줍니다. 또한 필요할 때 쿼리 캐시를 수동으로 조작할 수 있게 해줍니다. 예를 들어, 뮤테이션 후 데이터를 갱신하거나 일부 쿼리를 수동으로 무효화해야 할 때 유용합니다.
이제 이 세 가지 중요한 점을 살펴보고, 이를 더 효과적으로 활용하기 위해 제가 어떻게 쿼리 키를 구성하는지 알아보겠습니다.
데이터 캐싱
쿼리 캐시는 내부적으로 단순한 JavaScript 객체입니다. 이 객체에서 키는 직렬화된 쿼리 키이고, 값은 쿼리 데이터와 메타 정보입니다. 키는 결정론적 방식으로 해시되므로 객체도 사용할 수 있습니다. 다만 최상위 수준에서는 키가 문자열이나 배열이어야 합니다.
가장 중요한 점은 쿼리마다 고유한 키를 사용해야 한다는 것입니다. React Query는 캐시에서 키에 해당하는 항목을 찾으면 그것을 사용합니다. 또한 같은 키를 useQuery
와 useInfiniteQuery
에 동시에 사용할 수 없습니다. 쿼리 캐시는 하나뿐이며, 이 두 가지 사이에 데이터를 공유하면 문제가 발생할 수 있습니다. 무한 쿼리는 일반 쿼리와 근본적으로 다른 구조를 가지기 때문입니다.
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에 다른 쿼리 키를 전달하게 되고, 이로 인해 다시 가져오기가 실행됩니다.
수동 조작
쿼리 캐시를 수동으로 조작할 때 쿼리 키의 구조가 가장 중요합니다. invalidateQueries
나 setQueriesData
같은 많은 조작 메서드는 쿼리 필터를 지원합니다. 이를 통해 쿼리 키를 느슨하게 매칭할 수 있습니다.
효과적인 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를 더 효과적으로 사용할 수 있습니다. 복잡한 애플리케이션에서도 데이터를 일관되게 관리하고 쉽게 조작할 수 있게 됩니다.