🔥 효과적인 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.tstext
'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를 더 효과적으로 사용할 수 있습니다. 복잡한 애플리케이션에서도 데이터를 일관되게 관리하고 쉽게 조작할 수 있게 됩니다.