🔥 쿼리 함수 컨텍스트 활용하기
개발자로서 우리는 항상 실력 향상을 추구합니다. 시간이 지나면서 우리는 새로운 지식을 습득하고 기존의 생각을 뒤집거나 도전하게 됩니다. 또한 이상적이라고 여겼던 패턴이 현재 필요한 수준으로 확장되지 않는다는 사실을 깨닫기도 합니다.
React Query를 처음 사용하기 시작한 이후로 꽤 오랜 시간이 흘렀습니다. 이 여정에서 많은 것을 배웠고 다양한 경험을 했습니다. 제 블로그가 최신 상태를 유지하길 바랍니다. 그래야 여러분이 다시 방문해 읽어도 여전히 유효한 개념들을 접할 수 있을 테니까요. 이는 Tanner Linsley가 공식 React Query 문서에서 제 블로그를 링크하기로 동의한 지금 더욱 중요해졌습니다.
그래서 효과적인 React Query 키 글에 대한 추가 설명을 작성하기로 했습니다. 이 글을 이해하려면 먼저 해당 글을 읽어보시기 바랍니다.
파격적인 제안
인라인 함수를 사용하지 마세요. 대신 제공된 쿼리 함수 컨텍스트를 활용하고, 객체 키를 생성하는 쿼리 키 팩토리를 사용하세요.
인라인 함수는 queryFn
에 매개변수를 전달하는 가장 쉬운 방법입니다. 커스텀 훅에서 사용 가능한 다른 변수들을 클로저로 감싸기 때문입니다. 항상 등장하는 할 일 목록 예제를 살펴보겠습니다:
type State = 'all' | 'open' | 'done' type Todo = { id: number state: TodoState } type Todos = ReadonlyArray<Todo> const fetchTodos = async (state: State): Promise<Todos> => { const response = await axios.get(`todos/${state}`) return response.data } export const useTodos = () => { // 현재 사용자 선택을 어딘가에서 가져온다고 가정합니다 // 예를 들어 URL에서 가져올 수 있습니다 const { state } = useTodoParams() // ✅ queryFn은 전달된 state를 클로저로 감싸는 인라인 함수입니다 return useQuery({ queryKey: ['todos', state], queryFn: () => fetchTodos(state), }) }
typescript
이 예제가 익숙해 보일 수 있습니다. #1: 실용적인 React Query - 쿼리 키를 의존성 배열처럼 다루기의 약간 변형된 버전입니다. 이 방식은 간단한 예제에서는 잘 작동하지만, 매개변수가 많아지면 상당한 문제가 생깁니다. 큰 규모의 앱에서는 수많은 필터와 정렬 옵션을 갖는 것이 흔합니다. 저는 직접 10개 이상의 매개변수가 전달되는 경우를 본 적이 있습니다.
이제 쿼리에 정렬 기능을 추가한다고 가정해 봅시다. 저는 이런 문제를 아래에서부터 접근하는 것을 선호합니다. queryFn
부터 시작해서 컴파일러가 무엇을 바꿔야 하는지 알려주도록 하는 방식입니다:
type Sorting = 'dateCreated' | 'name' const fetchTodos = async ( state: State, sorting: Sorting ): Promise<Todos> => { const response = await axios.get(`todos/${state}?sorting=${sorting}`) return response.data }
typescript
이렇게 하면 fetchTodos
를 호출하는 커스텀 훅에서 분명히 오류가 발생할 것입니다. 그래서 이를 수정해 봅시다:
export const useTodos = () => { const { state, sorting } = useTodoParams() // 🚨 실수를 찾을 수 있나요? ⬇️ return useQuery({ queryKey: ['todos', state], queryFn: () => fetchTodos(state, sorting), }) }
typescript
이미 문제를 발견하셨나요? queryKey
가 실제 의존성과 동기화되지 않았고, 빨간 줄로 경고해주는 것도 없습니다 😔. 위의 경우에는 정렬을 변경해도 자동으로 재요청이 트리거되지 않기 때문에 문제를 빠르게 발견할 수 있을 것입니다(통합 테스트를 통해서요). 또한 이 간단한 예제에서는 꽤 명확해 보입니다. 하지만 저는 최근 몇 달 동안 queryKey
가 실제 의존성과 달라지는 경우를 여러 번 목격했고, 복잡도가 높아질수록 이런 문제들은 추적하기 어려워집니다. React에서 react-hooks/exhaustive-deps eslint 규칙을 제공하는 이유도 바로 이 때문입니다.
그렇다면 React Query도 자체 eslint 규칙을 만들어야 할까요? 👀
물론 그것도 하나의 방법이 될 수 있습니다. 또한 babel-plugin-react-query-key-gen이라는 플러그인도 있어서 모든 의존성을 포함한 쿼리 키를 자동으로 생성해 줍니다. 하지만 React Query는 의존성을 다루는 내장된 다른 방법을 제공합니다. 바로 QueryFunctionContext
입니다.
업데이트
앞서 언급한 린트 규칙이 이제 존재합니다. 여기 문서에서 자세히 확인할 수 있습니다. 🚀
QueryFunctionContext
QueryFunctionContext
는 queryFn
에 인자로 전달되는 객체입니다. 무한 쿼리를 다룰 때 이미 사용해 보셨을 겁니다:
// 이것이 QueryFunctionContext입니다 ⬇️ const fetchProjects = ({ pageParam }) => fetch('/api/projects?cursor=' + pageParam) useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: 0, })
typescript
React Query는 이 객체를 사용해 queryFn
에 쿼리에 대한 정보를 주입합니다. 무한 쿼리의 경우, getNextPageParam
의 반환값이 pageParam
으로 주입됩니다.
하지만 이 컨텍스트는 해당 쿼리에 사용된 queryKey
도 포함하고 있습니다(앞으로 컨텍스트에 더 많은 멋진 기능을 추가할 예정입니다). 이는 React Query가 필요한 모든 것을 제공하기 때문에 클로저를 사용할 필요가 없다는 뜻입니다:
const fetchTodos = async ({ queryKey }) => { // 🚀 queryKey에서 모든 매개변수를 가져올 수 있습니다 const [, state, sorting] = queryKey const response = await axios.get(`todos/${state}?sorting=${sorting}`) return response.data } export const useTodos = () => { const { state, sorting } = useTodoParams() // ✅ 매개변수를 수동으로 전달할 필요가 없습니다 return useQuery({ queryKey: ['todos', state, sorting], queryFn: fetchTodos, }) }
typescript
이 접근 방식을 사용하면 queryKey
에 추가하지 않고 queryFn
에서 추가 매개변수를 사용할 방법이 거의 없습니다. 🎉
QueryFunctionContext 타입 지정하기
이 접근 방식의 목표 중 하나는 완전한 타입 안정성을 확보하고 useQuery
에 전달된 queryKey
로부터 QueryFunctionContext
의 타입을 추론하는 것이었습니다. 이는 쉽지 않은 작업이었지만, React Query는 v3.13.3 버전부터 이를 지원합니다. queryFn
을 인라인으로 작성하면 타입이 제대로 추론되는 것을 볼 수 있습니다(제네릭 덕분입니다):
export const useTodos = () => { const { state, sorting } = useTodoParams() return useQuery({ queryKey: ['todos', state, sorting] as const, queryFn: async ({ queryKey }) => { const response = await axios.get( // ✅ queryKey가 튜플이므로 안전합니다 `todos/${queryKey[1]}?sorting=${queryKey[2]}` ) return response.data }, }) }
typescript
이 방식은 좋아 보이지만 여전히 몇 가지 문제가 있습니다:
- 클로저에 있는 것을 그대로 사용해 쿼리를 만들 수 있습니다.
- 위와 같이
queryKey
를 사용해 URL을 만드는 것은 여전히 안전하지 않습니다. 모든 것을 문자열로 변환할 수 있기 때문입니다.
쿼리 키 팩토리
이 지점에서 쿼리 키 팩토리가 다시 등장합니다. 타입 안전한 쿼리 키 팩토리로 키를 만들면, 그 팩토리의 반환 타입을 사용해 QueryFunctionContext
의 타입을 지정할 수 있습니다. 예를 들어 보겠습니다:
const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (state: State, sorting: Sorting) => [...todoKeys.lists(), state, sorting] as const, } const fetchTodos = async ({ queryKey, }: // 🤯 팩토리에서 나온 키만 받아들입니다 QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => { const [, , state, sorting] = queryKey const response = await axios.get(`todos/${state}?sorting=${sorting}`) return response.data } export const useTodos = () => { const { state, sorting } = useTodoParams() // ✅ 팩토리로 키를 만듭니다 return useQuery({ queryKey: todoKeys.list(state, sorting), queryFn: fetchTodos }) }
typescript
React Query는 QueryFunctionContext
타입을 내보냅니다. 이 타입은 queryKey
의 타입을 정의하는 제네릭을 하나 받습니다. 위 예제에서는 키 팩토리의 list 함수가 반환하는 것과 동일하게 설정했습니다. const 단언을 사용했기 때문에 모든 키는 엄격하게 타입이 지정된 튜플이 됩니다. 따라서 이 구조를 따르지 않는 키를 사용하려 하면 타입 오류가 발생합니다.
객체 쿼리 키
위의 방식으로 천천히 전환하면서, 배열 키가 그다지 효과적이지 않다는 점을 발견했습니다. 이는 현재 쿼리 키를 분해하는 방식을 보면 명확해집니다:
const [, , state, sorting] = queryKey
typescript
우리는 기본적으로 처음 두 부분(하드코딩된 todo와 list 범위)을 무시하고 동적인 부분만 사용합니다. 물론 오래지 않아 시작 부분에 또 다른 범위를 추가하게 되었고, 이는 다시 잘못된 URL 구성으로 이어졌습니다:
![[@DATA/ebee268e9bac32b73560095703e024ec_MD5.jpg|"쿼리 키 분해"]]
출처: 제가 최근에 만든 PR
객체는 이 문제를 아주 잘 해결합니다. 이름으로 구조 분해할 수 있기 때문입니다. 더욱이 쿼리 키 안에서 사용할 때 단점이 전혀 없습니다. 쿼리 무효화를 위한 퍼지 매칭이 객체와 배열에 대해 동일하게 작동하기 때문입니다. 관심 있으시다면 partialDeepEqual 함수를 살펴보세요.
이 점을 염두에 두고, 지금의 지식으로 쿼리 키를 어떻게 구성할지 보여드리겠습니다:
const todoKeys = { // ✅ 모든 키는 정확히 하나의 객체를 포함하는 배열입니다 all: [{ scope: 'todos' }] as const, lists: () => [{ ...todoKeys.all[0], entity: 'list' }] as const, list: (state: State, sorting: Sorting) => [{ ...todoKeys.lists()[0], state, sorting }] as const, } const fetchTodos = async ({ // ✅ queryKey에서 이름으로 속성을 추출합니다 queryKey: [{ state, sorting }], }: QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => { const response = await axios.get(`todos/${state}?sorting=${sorting}`) return response.data } export const useTodos = () => { const { state, sorting } = useTodoParams() return useQuery({ queryKey: todoKeys.list(state, sorting), queryFn: fetchTodos }) }
typescript
객체 쿼리 키는 순서가 없기 때문에 퍼지 매칭 기능을 더욱 강력하게 만듭니다. 배열 방식에서는 모든 할 일 관련 항목, 모든 할 일 목록, 또는 특정 필터가 적용된 할 일 목록을 다룰 수 있었습니다. 객체 키를 사용하면 이 모든 것을 할 수 있을 뿐만 아니라, 원하는 경우 모든 목록(예: 할 일 목록과 프로필 목록)을 한꺼번에 다룰 수도 있습니다:
// 🕺 할 일 기능과 관련된 모든 것을 제거합니다 queryClient.removeQueries({ queryKey: [{ scope: 'todos' }] }) // 🚀 모든 할 일 목록을 초기화합니다 queryClient.resetQueries({ queryKey: [{ scope: 'todos', entity: 'list' }] }) // 🙌 모든 범위에 걸쳐 모든 목록을 무효화합니다 queryClient.invalidateQueries({ queryKey: [{ entity: 'list' }] })
typescript
이 방식은 여러 중첩된 범위가 계층 구조를 가지고 있지만, 여전히 하위 범위에 속하는 모든 것을 매칭하고 싶을 때 매우 유용할 수 있습니다.
이 방식이 가치 있을까요?
항상 그렇듯이, 상황에 따라 다릅니다. 최근에 저는 이 방식을 아주 좋아하고 있습니다(그래서 여러분과 공유하고 싶었습니다). 하지만 복잡성과 타입 안정성 사이에는 분명 트레이드오프가 있습니다. 키 팩토리 내에서 쿼리 키를 구성하는 것이 약간 더 복잡하고(쿼리 키는 여전히 최상위 레벨에서 배열이어야 하기 때문에), 키 팩토리의 반환 타입에 따라 컨텍스트의 타입을 지정하는 것도 간단하지 않습니다. 만약 여러분의 팀이 작고, API 인터페이스가 간단하거나, 순수한 JavaScript를 사용하고 있다면 이 방식을 선택하지 않을 수도 있습니다. 늘 그렇듯이, 여러분의 특정 상황에 가장 적합한 도구와 접근 방식을 선택하세요. 🙌