TanStack Query queryOptions 패턴: 커스텀 훅 방식과 무엇이 다른가
queryOptions 패턴은 queryKey와 queryFn을 한 객체로 묶어 돌려주는 함수를 팩토리에 두는 방식이다. 키만 팩토리에 두고 useQuery를 커스텀 훅 안에 가두는 방식과 달리, 같은 옵션 객체를 여러 훅과 prefetchQuery 같은 queryClient 메서드가 그대로 받고 키에 데이터 타입이 붙는다.
기존 방식: 키 팩토리와 커스텀 훅
-
쿼리 키 팩토리는 쿼리 키를 만드는 함수들을 한 객체에 모아 둔 것이다.
-
쿼리 키를 손으로 적으면 오타가 나기 쉬우므로, 키를 만드는 자리를 한 곳으로 모은다.
-
그리고
useQuery호출은useTodo같은 커스텀 훅으로 분리해, 컴포넌트가 키와fetchTodo를 몰라도 되게 한다.// 키만 만드는 팩토리: queryFn은 여기 없다 export const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (filters: Filters) => [...todoKeys.lists(), filters] as const, detail: (id: number) => [...todoKeys.all, 'detail', id] as const, } // 키와 queryFn은 커스텀 훅 안에서 처음 만난다 export function useTodo(id: number) { return useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id), // fetchTodo는 Promise<Todo>를 돌려준다 }) } -
이 구조에서 키와
queryFn이 함께 묶이는 장소는useTodo함수 본문 하나뿐이다. -
그러면 같은 데이터를
useTodo와 다른 방식으로 받아야 할 때 어떤 일이 생기는지 따라가 볼 필요가 있다.
커스텀 훅에 묶인 옵션이 막히는 곳
-
useTodo는 본문에서useQuery를 부르도록 고정되어 있다. -
useSuspenseQuery는 데이터가 올 때까지 컴포넌트 렌더링을 멈추고, 가장 가까운<Suspense>의fallback을 대신 보여 주는 훅이다. -
만약 같은 데이터를 이렇게 Suspense로 받으려면
useQuery대신useSuspenseQuery를 불러야 한다. -
그런데 React의 Rules of Hooks에 따라 훅은 조건문 안에서 부를 수 없으므로,
useTodo가 인자를 받아 두 훅 중 하나를 고르게 만들 수 없다. -
결국
useSuspenseTodo라는 훅을 하나 더 만들고, 키와queryFn을 다시 적게 된다.export function useTodo(id: number) { return useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id), }) } // 부르는 훅만 다르고 옵션은 useTodo와 같은 내용을 복사했다 export function useSuspenseTodo(id: number) { return useSuspenseQuery({ queryKey: todoKeys.detail(id), // 중복 queryFn: () => fetchTodo(id), // 중복 }) } -
두 훅의 옵션은 복사본이라 따로 바뀔 수 있어서, 한쪽에만 옵션을 추가하면 같은 키에 서로 다른 설정이 붙는다.
-
정리하면 기존 방식에서 키는 재사용되지만, 키와
queryFn을 하나로 묶은 단위는 그것을 감싼 훅 안에 갇혀 재사용되지 않는다.
queryOptions 패턴: 옵션 객체를 팩토리가 만든다
-
queryOptions 패턴은 팩토리가
useQuery에 넘길 옵션 객체 전체를 돌려주게 바꾼다. -
옵션 객체는 TanStack Query v5의
queryOptions헬퍼로 감싸서 만든다.import { queryOptions } from '@tanstack/react-query' export const todoQueries = { // 무효화 범위로만 쓰는 상위 키는 그대로 키만 돌려준다 all: () => ['todos'] as const, lists: () => [...todoQueries.all(), 'list'] as const, // 실제로 데이터를 가져오는 단위는 키와 queryFn을 한 객체로 묶는다 list: (filters: Filters) => queryOptions({ queryKey: [...todoQueries.lists(), filters], queryFn: () => fetchTodos(filters), }), detail: (id: number) => queryOptions({ queryKey: [...todoQueries.all(), 'detail', id], queryFn: () => fetchTodo(id), staleTime: 30_000, // 이 쿼리에만 적용할 신선도 기준 }), } -
이제 키,
queryFn,staleTime같은 설정이todoQueries.detail(id)라는 한 번의 호출 결과에 모두 들어 있다. -
쓰는 쪽은 이 객체를 그대로 넘기기만 한다.
// 컴포넌트 A function TodoDetail({ id }: { id: number }) { const { data } = useQuery(todoQueries.detail(id)) } // 컴포넌트 B: Suspense가 필요하면 훅만 바꾸고 옵션은 같은 것을 넘긴다 function TodoDetailSuspense({ id }: { id: number }) { const { data } = useSuspenseQuery(todoQueries.detail(id)) } // 컴포넌트 C: 개수가 정해지지 않은 여러 쿼리도 같은 옵션 객체를 배열로 넘긴다 function TodoCards({ ids }: { ids: number[] }) { const results = useQueries({ queries: ids.map((id) => todoQueries.detail(id)) }) } // 컴포넌트 D: 이 컴포넌트만 다른 설정은 펼친 뒤 덧붙인다 function TodoTitle({ id }: { id: number }) { // 펼친 뒤에 적은 staleTime이 마지막에 놓이므로 팩토리의 30_000도 덮어쓴다 const { data } = useQuery({ ...todoQueries.detail(id), staleTime: 0, select: (todo) => todo.title }) } // 컴포넌트 밖: 라우트 로더처럼 훅을 못 쓰는 곳에서 미리 받아 둘 때도 같은 객체를 queryClient 메서드에 넘긴다 await queryClient.prefetchQuery(todoQueries.detail(1)) -
기존 방식에서 따로 만들어야 했던
useSuspenseTodo가 필요 없어지는 이유는, 재사용 단위가 훅에서 평범한 객체로 바뀌었기 때문이다. -
훅은 본문에서 부를 훅이 정해져 있지만, 객체는 어느 훅에든 인자로 넘길 수 있다.
// before: 재사용 단위가 훅이다 // 훅 안에서 useQuery를 부르므로, Suspense용은 훅을 하나 더 만들어 옵션을 복사한다 export function useTodo(id: number) { return useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) }) } export function useSuspenseTodo(id: number) { return useSuspenseQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) }) } function Before() { const a = useTodo(1) const b = useSuspenseTodo(1) } // after: 재사용 단위가 객체다 // 객체는 어떤 훅도 부르지 않으므로, 부르는 쪽이 훅을 고르고 같은 객체를 넘긴다 const detail = (id: number) => queryOptions({ queryKey: ['todos', 'detail', id], queryFn: () => fetchTodo(id) }) function After() { const a = useQuery(detail(1)) const b = useSuspenseQuery(detail(1)) }
헬퍼 없이 옵션 객체만 빼 두면 안 되나: queryOptions 를 쓰는 이유 = 타입
-
옵션을 복사하는 문제는
queryOptions없이도 풀 수 있다. -
키와
queryFn을 담은 평범한 객체를 돌려주는 함수를 두면, 그 객체도useQuery와useSuspenseQuery에 그대로 넘길 수 있다.// queryOptions로 감싸지 않은 평범한 함수 const plainDetail = (id: number) => ({ queryKey: ['todos', 'detail', id], queryFn: () => fetchTodo(id), }) // 컴포넌트 A const a = useQuery(plainDetail(1)) // 컴포넌트 B: 같은 객체를 다른 훅에 넘겨도 동작한다 const b = useSuspenseQuery(plainDetail(1)) -
실제로
@tanstack/react-queryv5 소스에서queryOptions의 구현은 받은 인자를 그대로 돌려주는 한 줄이다.// packages/react-query/src/queryOptions.ts // 위에 오버로드 선언 세 개가 있고, 아래가 실제로 실행되는 구현이다 export function queryOptions(options: unknown) { return options // 복사하지도 않고, 값을 바꾸지도 않는다 } -
그래서
queryOptions가 더하는 런타임 비용은 함수 호출 한 번이고, 번들에 들어가는 코드도 이 함수 하나다. -
렌더링마다 옵션 객체가 새로 만들어지는 것은
useQuery에 객체 리터럴을 바로 적던 기존 방식에서도 같다. -
만들어지는 객체와 쿼리 동작은 두 방식이 같고, 함수 호출 한 번 말고는 타입 검사에서만 차이가 난다.
-
반환 타입을 따로 지정하지 않은 평범한 함수에서
staleTime을stalTime으로 잘못 적으면, 정의하는 곳에서도useQuery에 넘기는 곳에서도 컴파일 오류가 나지 않는다. -
TypeScript는 객체 리터럴을 타입이 정해진 대상(타입이 정해진 인자, 타입을 적은 변수,
satisfies)과 바로 대조할 때만 모르는 속성을 오류로 잡는데, 함수가 돌려준 객체는useQuery의 인자와 대조되는 시점에 이미 리터럴이 아니기 때문이다.// useQuery의 인자 자리에 객체 리터럴을 바로 적었다: 모르는 속성 stalTime이 오류가 된다 useQuery({ queryKey: ['todos', 'detail', 1], queryFn: () => fetchTodo(1), stalTime: 30_000 }) // 컴파일 오류 // 같은 객체를 타입 없는 변수에 담았다가 넘겼다: 인자와 대조될 때는 리터럴이 아니므로 검사하지 않는다 const options = { queryKey: ['todos', 'detail', 1], queryFn: () => fetchTodo(1), stalTime: 30_000 } useQuery(options) // 통과 -
queryOptions는 인자 타입이useQuery의 옵션 타입이므로, 같은 실수를 정의하는 줄에서 바로 잡는다.// 평범한 함수: 오류가 나지 않고, staleTime은 기본값 0으로 동작한다 const plainDetail = (id: number) => ({ queryKey: ['todos', 'detail', id], queryFn: () => fetchTodo(id), stalTime: 30_000, // 오타지만 통과한다 }) useQuery(plainDetail(1)) // 여기서도 통과한다 // queryOptions: 객체 리터럴을 인자 자리에 바로 적으므로 모르는 속성이 오류가 된다 const detail = (id: number) => queryOptions({ queryKey: ['todos', 'detail', id], queryFn: () => fetchTodo(id), stalTime: 30_000, // 컴파일 오류: Did you mean to write 'staleTime'? }) -
물론 이 검사는 평범한 함수에서도 TypeScript의
satisfies로 옵션 타입을 지정하면 얻을 수 있다. -
satisfies는 값의 타입을 바꾸지 않고, 그 값이 지정한 타입에 맞는지만 검사하는 연산자다.// queryOptions 없이 오타를 잡는다: 오류 메시지도 한 줄이다 const satisfiedDetail = (id: number) => ({ queryKey: ['todos', 'detail', id], queryFn: () => fetchTodo(id), stalTime: 30_000, // 컴파일 오류: Did you mean to write 'staleTime'? }) satisfies UseQueryOptions<Todo> -
그러나 이렇게 만든 객체의 키에는 데이터 타입이 붙지 않고, 키에 데이터 타입이 붙는 것은
queryOptions로 감쌀 때뿐이다.// 둘 다 키로 캐시를 읽는다 const a = queryClient.getQueryData(satisfiedDetail(1).queryKey) // unknown: 무엇이 저장됐는지 모른다 const b = queryClient.getQueryData(todoQueries.detail(1).queryKey) // Todo | undefined -
반대로
queryOptions의 오류 메시지는satisfies보다 길다. -
queryOptions는initialData가 있는지,queryFn에skipToken을 넣을 수 있는지에 따라 오버로드가 세 개로 나뉘어 있어서, 오타 하나에 오버로드마다 같은 내용의 오류가 세 번 나온다.error TS2769: No overload matches this call. Overload 1 of 3, ... 'stalTime' does not exist in type 'DefinedInitialDataOptions<...>'. Did you mean to write 'staleTime'? Overload 2 of 3, ... 'stalTime' does not exist in type 'UnusedSkipTokenOptions<...>'. Did you mean to write 'staleTime'? Overload 3 of 3, ... 'stalTime' does not exist in type 'UndefinedInitialDataOptions<...>'. Did you mean to write 'staleTime'? -
결국 옵션 복사는 평범한 함수로도 풀리고, 오타 검사는
satisfies로도 얻으므로,queryOptions만 주는 것은 키에 붙는 데이터 타입이다.
키에 데이터 타입이 붙는다
-
기존 커스텀 훅 방식에는 복사 말고 불편함이 하나 더 있다.
-
컴포넌트 밖에서 캐시를 직접 읽을 때는
queryClient.getQueryData, 고칠 때는queryClient.setQueryData를 쓰고, 둘 다 첫 인자로 쿼리 키를 받는다. -
그런데 TypeScript가 아는
todoKeys.detail(1)의 타입은 배열의 모양뿐이고, 이 키로Todo가 저장된다는 정보는 어디에도 없다. -
그래서 키만 받은
getQueryData는 돌려줄 값의 타입을 몰라unknown을 돌려준다.// TypeScript가 아는 키의 타입: readonly ['todos', 'detail', number] const key = todoKeys.detail(1) // 키에 데이터 타입 정보가 없으므로 unknown이 된다 const data = queryClient.getQueryData(key) data.title // 컴파일 오류: 'data' is of type 'unknown' -
이 값을 쓰려면 제네릭으로 반환 타입을 직접 적어야 하는데, 컴파일러는 적은 타입이 실제로 저장된 데이터와 맞는지 확인하지 않는다.
// 제네릭으로 Todo를 적으면 Todo | undefined가 된다 const todo = queryClient.getQueryData<Todo>(todoKeys.detail(1)) // 캐시에는 Todo가 들어 있지만 User로 잘못 적어도 컴파일 오류가 나지 않는다 const user = queryClient.getQueryData<User>(todoKeys.detail(1)) user?.name // 컴파일은 통과하고, 런타임에는 Todo에 name이 없어 undefined다 -
정리하면 기존 방식에서는 「이 키에는
Todo가 저장된다」는 사실을 사람이 기억해 제네릭으로 옮겨 적는다. -
queryOptions 패턴은 이 사실을 키의 타입 안에 넣는다.
-
TypeScript가 보는
todoQueries.detail(1).queryKey의 타입은 다음과 같다.// 앞부분: 기존 키와 같은 배열 모양 // & 뒷부분: queryFn이 돌려주는 Todo를 담은 속성 (에러 타입을 담는 속성은 줄였다) (string | number)[] & { [dataTagSymbol]: Todo } -
&는 교차 타입으로, 양쪽 타입의 속성을 모두 가진 타입을 뜻한다. -
queryOptions의 반환 타입 선언이queryKey에 이 속성을 더하고,Todo는queryFn의 반환 타입에서 추론된다. -
이 속성은 타입 선언에만 있고,
queryOptions는 인자를 그대로 돌려주므로 런타임의 키 배열에는 들어가지 않는다. -
TanStack Query는 이렇게 데이터 타입을 붙인 키 타입을
DataTag라고 부르고,getQueryData와setQueryData는 키에 이 속성이 있으면 그 안의 타입을 쓴다.// 제네릭 없이 Todo | undefined로 추론된다 const todo2 = queryClient.getQueryData(todoQueries.detail(1).queryKey) todo2?.title // 통과 // setQueryData도 같은 키를 읽으므로, Todo와 모양이 다른 값을 넣으면 컴파일 오류가 난다 queryClient.setQueryData(todoQueries.detail(1).queryKey, { name: 'x' }) -
단, 제네릭을 직접 적으면 키에 붙은 타입이 무시된다.
-
getQueryData는 키 타입을 두 번째 타입 인자로 추론해야 태그를 읽는데, 타입 인자를 하나라도 직접 적으면 TypeScript는 나머지를 추론하지 않고 기본값을 쓰기 때문이다.// 첫 번째 타입 인자 User를 적었으므로 키 타입은 추론되지 않고 기본값 QueryKey가 된다 // 태그를 못 읽어서 적은 User | undefined가 그대로 반환 타입이 되고, 오류도 나지 않는다 const wrong = queryClient.getQueryData<User>(todoQueries.detail(1).queryKey) -
그래서 키에 붙은 타입의 이득은 제네릭을 적지 않을 때만 생긴다.
계층 키와 무효화는 그대로다
-
invalidateQueries는exact: true를 주지 않는 기본 설정에서, 필터의queryKey를 캐시에 있는 각 쿼리 키와 앞에서부터 원소 하나씩 비교하고, 필터 키의 원소가 모두 같으면 캐시 키가 더 길어도 일치로 본다. -
그래서 필터 키
['todos', 'list']는['todos', 'list', { done: false }]와['todos', 'list', { done: true }]를 둘 다 무효화하고,['todos', 'detail', 1]은 건드리지 않는다.
-
원소가 객체이면 필터 쪽 객체에 적힌 속성만 비교하므로, 필터 키
['todos', 'list', { done: true }]는 캐시 키['todos', 'list', { done: true, page: 2 }]와도 일치한다. -
queryOptions가 돌려준queryKey도 런타임에는 평범한 배열이어서 이 규칙대로 비교되므로, 기존 키 팩토리의 계층 구조를 queryOptions 패턴에서도 그대로 쓴다. -
대신 팩토리 안에는 키 배열을 돌려주는 함수와 옵션 객체를 돌려주는 함수가 섞인다.
-
무효화 범위로만 쓰는
all,lists는 붙일queryFn이 없으므로 키 배열을 돌려주고, 데이터를 가져오는list,detail은 옵션 객체를 돌려준다. -
그래서 쓰는 쪽은 함수마다 키를 그대로 넘길지
.queryKey를 꺼내 넘길지 구분해야 하고, 이 구분은 함수 이름에 드러나지 않는다. -
잘못 넘기면 컴파일 오류가 나므로 런타임 버그로 이어지지는 않지만, 고칠 때마다 팩토리 정의를 확인하게 된다.
export const todoQueries = { all: () => ['todos'] as const, // 키 배열 lists: () => [...todoQueries.all(), 'list'] as const, // 키 배열 list: (filters: Filters) => queryOptions({ queryKey: [...todoQueries.lists(), filters], queryFn: () => fetchTodos(filters) }), // 옵션 객체 detail: (id: number) => queryOptions({ queryKey: [...todoQueries.all(), 'detail', id], queryFn: () => fetchTodo(id) }), // 옵션 객체 } // 할 일 하나를 고친 뒤, 목록 전체와 그 할 일의 상세를 무효화한다 function invalidateAfterUpdate(id: number) { // lists()는 키 배열이므로 그대로 넘긴다 queryClient.invalidateQueries({ queryKey: todoQueries.lists() }) // detail(id)는 옵션 객체이므로 .queryKey를 꺼내 넘긴다 queryClient.invalidateQueries({ queryKey: todoQueries.detail(id).queryKey }) } // 반대로 쓰면 둘 다 컴파일 오류가 난다 queryClient.invalidateQueries({ queryKey: todoQueries.detail(1) }) // 옵션 객체는 키 배열 자리에 못 들어간다 queryClient.invalidateQueries({ queryKey: todoQueries.lists().queryKey }) // 키 배열에는 queryKey 속성이 없다
훅이 아니어서 잃는 것
-
커스텀 훅은 본문에서 다른 훅을 부를 수 있었다.
-
예를 들어 로그인했을 때만 요청해야 하는 데이터라면,
useTodo안에서 로그인 상태를 주는useUser훅을 불러enabled를 정할 수 있었다. -
그러면 로그인 조건은
useTodo한 곳에만 있고, 컴포넌트는useTodo(id)만 부르면 된다.// before: 로그인 조건이 훅 안에 한 번만 있다 export function useTodo(id: number) { const user = useUser() // ⭐️ 훅 안이라 다른 훅을 부를 수 있다 return useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id), enabled: user != null, // 로그인 전에는 요청하지 않는다 }) } function TodoDetail({ id }: { id: number }) { const { data } = useTodo(id) // 로그인 조건을 몰라도 된다 } function TodoTitle({ id }: { id: number }) { const { data } = useTodo(id) } -
todoQueries.detail은 평범한 함수라서 Rules of Hooks에 따라 안에서useUser를 부를 수 없다.
useMutation 패턴은 무엇이 달라지나
-
기존 방식에서는
useUpdateTodo같은 커스텀 훅 안에서useMutation을 부르고, 성공했을 때와 끝났을 때 할 일을 콜백으로 함께 적었다.// before: 커스텀 훅 export function useUpdateTodo() { const queryClient = useQueryClient() // 무효화에 쓸 QueryClient를 훅으로 얻는다 const navigate = useNavigate() // 페이지 이동도 훅으로 얻는다 return useMutation({ mutationFn: updateTodo, onSuccess: () => navigate('/todos'), // 성공하면 목록으로 이동한다 // 세 번째 인자 todo는 mutate에 넘긴 값이다: 성공과 실패 모두 무효화한다 onSettled: (_data, _error, todo) => queryClient.invalidateQueries({ queryKey: todoQueries.detail(todo.id).queryKey }), }) } -
콜백에서
invalidateQueries를 부르려면QueryClient인스턴스가 필요해서, 훅 안에서useQueryClient()로 얻었다. -
뮤테이션에도
queryOptions에 대응하는mutationOptions헬퍼가 있고, 구현은queryOptions처럼 받은 인자를 그대로 돌려준다. -
mutationOptions를 부르는 팩토리는 훅이 아니므로useQueryClient()도useNavigate()도 부를 수 없다.// 안 되는 코드: 팩토리의 콜백 안에서 훅을 부른다 export const todoMutations = { update: () => mutationOptions({ mutationFn: updateTodo, onSettled: (_data, _error, todo) => { // 콜백은 렌더링 밖에서 불리므로 여기서 훅을 부르면 런타임 오류가 난다 // Invalid hook call. Hooks can only be called inside of the body of a function component. const queryClient = useQueryClient() queryClient.invalidateQueries({ queryKey: todoQueries.detail(todo.id).queryKey }) }, }), } -
대신 뮤테이션 콜백의 마지막 인자
context에client(이 뮤테이션을 실행한QueryClient)가 들어 있어서, 팩토리 안에서도 무효화를 적을 수 있다.// after: 팩토리 export const todoMutations = { update: () => mutationOptions({ mutationFn: updateTodo, // 다섯 번째 인자 context.client로 무효화한다: 훅 없이 QueryClient에 닿는다 onSettled: (_data, _error, todo, _onMutateResult, context) => context.client.invalidateQueries({ queryKey: todoQueries.detail(todo.id).queryKey }), }), } -
navigate처럼 훅에서 얻는 함수는 팩토리 안에서 직접 얻을 수 없으므로, 컴포넌트가 훅으로 얻어 넘겨야 하고, 여기서는mutate의 두 번째 인자로 넘긴다.function EditTodo({ todo }: { todo: Todo }) { const navigate = useNavigate() // 컴포넌트는 훅을 부를 수 있다 const { mutate } = useMutation(todoMutations.update()) // 이동은 mutate의 두 번째 인자로 넘긴다: 무효화는 팩토리의 onSettled가 맡는다 return <button onClick={() => mutate(todo, { onSuccess: () => navigate('/todos') })}>저장</button> } -
정리하면 커스텀 훅 한 곳에 모여 있던 콜백이, 무효화는 팩토리로, 페이지 이동처럼 화면에 딸린 일은
mutate의 두 번째 인자로 나뉜다. -
나뉘면서 실행 순서도 바뀌어, before는 이동한 뒤에 무효화하지만 after는 팩토리의
onSettled가 돌려준 무효화 Promise가 끝난 뒤에 이동한다. -
mutate에 넘긴 콜백은 뮤테이션이 끝나기 전에 컴포넌트가 언마운트되면 불리지 않고,mutate를 연이어 부르면 마지막 호출의 것만 불리므로, 무효화처럼 매번 해야 하는 일은 팩토리에 둔다.