React Query에서 쿼리 추상화 만들기
이 글은 TkDodo의 Creating Query Abstractions를 번역한 글입니다.
개발자들은 추상화를 참 좋아합니다.
다른 곳에서도 쓸 것 같은 코드가 보이면 추상화를 만들고, 고작 3줄짜리 코드가 살짝 달라도 플래그를 추가해 추상화를 만들고, 모든 useQuery에 공통으로 뭔가 적용하고 싶어지면 또 추상화를 만듭니다.
추상화 자체가 나쁜 건 아닙니다. 하지만 모든 것이 그렇듯 트레이드오프가 있습니다. Dan의 강연 The Wet Codebase는 제가 가장 좋아하는 강연 중 하나인데, 이 점을 정말 잘 설명해 줍니다.
커스텀 훅
React에서 추상화를 만드는 것은 대부분 커스텀 훅과 연결됩니다. 여러 컴포넌트 간 로직을 공유하거나, 복잡한 useEffect를 의미 있는 이름 뒤에 숨기는 데 아주 유용하죠. 오랫동안 useQuery 위에 커스텀 추상화를 만드는 방법은 커스텀 훅을 작성하는 것이었습니다:
function useInvoice(id: number) {
return useQuery({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id),
});
}
const { data } = useInvoice(1);
// const data: Invoice | undefined간단명료합니다. 이제 queryKey와 queryFn을 매번 반복할 필요 없이 useInvoice()를 어디서든 호출할 수 있습니다. queryKey의 일관성을 보장해 주니 중복 캐시 항목이 생기는 것도 방지됩니다. 그리고 useQuery의 반환값을 그대로 돌려주기 때문에, TanStack Query의 API 표면과 일치하는 인터페이스를 갖게 되어 사용처에서 놀랄 일이 없습니다.
타입도 완전히 추론됩니다. 어디서도 제네릭을 수동으로 지정하지 않았기 때문이죠. TypeScript 코드는 일반 JavaScript처럼 보일수록 좋습니다.
쿼리 옵션
하지만 이 커스텀 훅의 입력은 어떨까요? useQuery에는 24개의 옵션이 있는데, 현재 추상화로는 그 어떤 것도 전달할 수 없습니다. 특정 화면에서 백그라운드 업데이트가 중요하지 않아 다른 staleTime을 주고 싶다면? 파라미터로 받으면 되겠죠:
function useInvoice(id: number, staleTime: number) {
return useQuery({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id),
staleTime,
});
}아직까진 괜찮아 보입니다. 그런데 이번엔 누군가 Error Boundary 연동을 위해 throwOnError도 넘기고 싶어 합니다. 파라미터가 너무 많아지니 처음부터 객체로 받을걸 싶어지죠:
function useInvoice(id: number, options?: { staleTime?: number; throwOnError?: boolean }) {
return useQuery({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id),
...options,
});
}이쯤 되면 제대로 가고 있는 건지 의문이 생깁니다. 새로운 사용 사례가 생길 때마다 이 작은 추상화를 계속 건드려야 하는 건 이상적이지 않습니다. 반환값은 라이브러리가 반환하는 것을 그대로 따르기로 했는데, 옵션도 그렇게 할 수 없을까요?
UseQueryOptions 시도
React Query가 UseQueryOptions라는 타입을 제공합니다. 딱 원하는 것 같네요:
import type { UseQueryOptions } from '@tanstack/react-query';
function useInvoice(id: number, options?: Partial<UseQueryOptions>) {
return useQuery({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id),
...options,
});
}타입 오류도 없으니 잘 동작하는 것 같죠? 그런데 사용처를 다시 보면:
const { data } = useInvoice(1, { throwOnError: true });
// const data: unknowndata가 unknown 타입이 되어 버렸습니다. 의외일 수 있지만, 이건 이상적인 타입 추론을 위해 제네릭이 동작하는 방식 때문입니다. options가 실제로 어떤 타입으로 추론되는지 살펴보면 문제가 더 명확해집니다:
declare const options: UseQueryOptions;
// const options: UseQueryOptions<unknown, Error, unknown, readonly unknown[]>UseQueryOptions도 4개의 제네릭을 가지고 있고, 생략하면 기본값이 사용됩니다. data의 기본값이 unknown이라서, 이 옵션들을 useQuery에 스프레드하면 타입이 unknown으로 넓어져 버립니다.
TypeScript 라이브러리
이건 타입 추론을 통해 높은 수준의 타입 안전성을 제공하는 라이브러리들에서 공통적으로 나타나는 문제입니다. "직접" 사용하면 정말, 정말 잘 동작하지만, 그 위에 저수준의 제네릭 추상화를 만들려고 하면 제대로 하기가 어렵습니다.
TanStack Query는 제네릭이 4개뿐이라 어떻게든 재현할 수 있을 겁니다. TanStack Form은 대부분의 타입에 23개의 타입 파라미터가 있고, TanStack Router는… 말하지 않는 게 낫겠네요. 😂
분명히 이 방식은 한계가 있습니다. TanStack Query에서 이걸 동작시키는 방법에 대한 4년 된 트윗을 올린 적이 있는데, 솔직히 엉망이었습니다:
잘못된 해결책
너무 복잡하다 보니, 이런 잘못된 방식이 아주 흔하게 보입니다. UseQueryOptions에 첫 번째 타입 파라미터만 직접 선언하는 것이죠:
function useInvoice(id: number, options?: Partial<UseQueryOptions<Invoice>>) {
return useQuery({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id),
...options,
});
}
const { data } = useInvoice(1, { throwOnError: true });
// const data: Invoice | undefineddata 타입은 다시 살아나지만, select처럼 다른 타입 파라미터에 의존하는 옵션에서 무너집니다:
const { data } = useInvoice(1, {
select: (invoice) => invoice.createdAt,
// ❌ 오류: Type '(invoice: Invoice) => string' is not assignable to type
// '(data: Invoice) => Invoice'
});트윗에서 보여줬듯이 우리 추상화에도 더 많은 타입 파라미터를 추가할 수 있지만, 그러면 "그냥 JavaScript처럼 보이는 코드"라는 약속에서 점점 멀어집니다. 라이브러리가 복잡한 TypeScript를 대신 처리해 줘서 우리는 신경 쓸 필요 없다는 약속이었는데 말이죠…
더 나은 추상화 찾기
저는 커스텀 훅이 여기서 적합한 추상화가 아니라는 결론에 도달했습니다. 그 이유는 여러 가지입니다:
- 커스텀 훅은 컴포넌트나 다른 훅 안에서만 사용할 수 있습니다. React Query가 처음 출시됐을 때는 괜찮았을 수 있지만, 이후로 서버에서도, 라우트 로더에서도, 프리페칭이나 이벤트 핸들러에서도 쓰고 싶어졌습니다. 이런 곳에서는 훅을 사용할 수 없습니다.
- 커스텀 훅은 컴포넌트 간 로직을 공유하기에 좋지만, 여기서 우리가 공유하는 건 로직이 아니라 설정(configuration)입니다.
- 커스텀 훅은 특정 구현에 종속됩니다.
useQuery로 감싸면, Suspense를 사용한 데이터 페칭을 위해 다른 훅(useSuspenseQuery)이 필요합니다. 여러 쿼리를 병렬로 실행하는useQueries도 있는데,useInvoice와 어떻게 조합할 수 있을까요? 할 수 없습니다…
Query Options API
v5부터 쿼리 추상화를 만드는 제가 선호하는 방식은 더 이상 커스텀 훅이 아니라 queryOptions입니다.
queryOptions에는 다른 장점들도 있는데, #24: The Query Options API에서 이미 다뤘습니다. 먼저 읽어보시는 것을 추천합니다.
이 API는 앞서 언급한 모든 문제와 그 이상을 해결합니다. 다양한 훅 간에 사용할 수 있고, 명령형 함수와도 공유할 수 있습니다. 그냥 일반 함수이기 때문에 어디서든 동작합니다. 런타임에서는 아무것도 하지 않습니다. 트랜스파일된 결과를 보면:
// queryOptions.js
function queryOptions(options) {
return options;
}하지만 타입 레벨에서는 강력한 힘을 발휘하여, 쿼리 설정을 공유하는 가장 좋은 방법이 됩니다:
import { queryOptions } from '@tanstack/react-query';
function invoiceOptions(id: number) {
return queryOptions({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id),
});
}
const { data: invoice1 } = useQuery(invoiceOptions(1));
// data: Invoice | undefined
const { data: invoice2 } = useSuspenseQuery(invoiceOptions(2));
// data: Invoice ✅ (undefined 없음!)좋습니다, 상호 운용성 문제는 해결됐습니다. 그런데 옵션은 이제 어떻게 전달하나요? invoiceOptions에 옵션을 파라미터로 추가하면 다시 원점으로 돌아가는 건데요.
QueryOptions 합성하기
여기서 좋은 소식이 있습니다: 그럴 필요가 없습니다. invoiceOptions는 모든 사용처에서 공유하고 싶은 옵션만 담으면 됩니다. 최고의 추상화는 설정할 수 없는(not configurable) 추상화입니다. 그냥 그대로 두면 됩니다. 추가 옵션이 필요하면 사용하는 곳에서 invoiceOptions 위에 직접 전달하면 됩니다:
import { queryOptions } from '@tanstack/react-query';
function invoiceOptions(id: number) {
return queryOptions({
queryKey: ['invoice', id],
queryFn: () => fetchInvoice(id),
});
}
const invoiceQuery = useQuery({
...invoiceOptions(1),
throwOnError: true,
select: (invoice) => invoice.createdAt,
});
invoiceQuery.data;
// data: string | undefined잘 동작합니다! 모든 옵션이 가능하고, 완전한 타입 추론이 되고, JavaScript처럼 보이고, 완전히 직관적입니다. 물론 원한다면 커스텀 훅을 만들 수 있지만, 그것들은 queryOptions 위에 구축되어야 합니다. queryOptions가 가장 먼저 손을 뻗어야 할 첫 번째 추상화 빌딩 블록이니까요. 추상화에선 단순함이 최고인데 이보다 더 단순할 수가 없습니다. 👑