🔥 React Query와 TypeScript
강의 목차
TypeScript는 최근 프론트엔드 개발 분야에서 큰 인기를 얻고 있습니다. 많은 개발자들이 라이브러리가 TypeScript로 작성되거나 적어도 좋은 타입 정의를 제공하기를 기대합니다. 개인적으로 라이브러리가 TypeScript로 작성된 경우, 그 타입 정의는 가장 좋은 문서라고 생각합니다. 타입 정의는 구현을 직접 반영하기 때문에 절대 틀리지 않습니다. 저는 API 문서를 읽기 전에 자주 타입 정의를 먼저 살펴봅니다.
React Query는 처음에 JavaScript로 작성되었지만(v1), v2에서 TypeScript로 다시 작성되었습니다. 이는 현재 TypeScript 사용자들에게 매우 좋은 지원을 제공한다는 것을 의미합니다.
하지만 React Query가 동적이고 유연하기 때문에 TypeScript와 함께 사용할 때 몇 가지 주의해야 할 점이 있습니다. 이제 이러한 점들을 하나씩 살펴보면서 React Query를 더 잘 활용할 수 있는 방법을 알아보겠습니다.
제네릭
React Query는 제네릭을 많이 사용합니다. 이는 라이브러리가 실제로 데이터를 가져오지 않고, API가 반환할 데이터의 타입을 알 수 없기 때문에 필요합니다.
공식 문서의 TypeScript 섹션은 매우 간단하며, useQuery
를 호출할 때 예상되는 제네릭을 명시적으로 지정하라고 안내합니다:
function useGroups() { return useQuery<Group[], Error>({ queryKey: ['groups'], queryFn: fetchGroups, }) }
typescript
업데이트
문서가 업데이트되어 이제 이 패턴을 주로 권장하지 않습니다.
시간이 지나면서 React Query는 useQuery
훅에 더 많은 제네릭을 추가했습니다(현재 네 개). 이는 주로 더 많은 기능이 추가되었기 때문입니다. 위의 코드는 작동하며, 우리의 커스텀 훅의 data
속성이 올바르게 Group[] | undefined
로 타입이 지정되고 error
가 Error | undefined
타입이 됩니다. 하지만 이는 더 고급 사용 사례, 특히 다른 두 개의 제네릭이 필요한 경우에는 작동하지 않습니다.
네 가지 제네릭
현재 useQuery
훅의 정의는 다음과 같습니다:
export function useQuery TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey >
typescript
여기에는 많은 내용이 포함되어 있으므로 하나씩 살펴보겠습니다:
TQueryFnData
:queryFn
에서 반환되는 타입입니다. 위의 예제에서는Group[]
입니다.TError
:queryFn
에서 예상되는 오류의 타입입니다. 예제에서는Error
입니다.TData
: 우리의data
속성이 최종적으로 가질 타입입니다.select
옵션을 사용하는 경우에만 관련이 있습니다. 그렇지 않으면queryFn
이 반환하는 것과 동일합니다.TQueryKey
: 우리의queryKey
의 타입입니다.queryFn
에 전달되는queryKey
를 사용하는 경우에만 관련이 있습니다.
보시다시피 이 모든 제네릭에는 기본값이 있습니다. 이는 제공하지 않으면 TypeScript가 이러한 타입으로 fallback한다는 것을 의미합니다. 이는 JavaScript의 기본 매개변수와 거의 동일하게 작동합니다:
function multiply(a, b = 2) { return a * b } multiply(10) // ✅ 20 multiply(10, 3) // ✅ 30
javascript
타입 추론
TypeScript는 무언가의 타입을 스스로 추론(또는 파악)하도록 할 때 가장 잘 작동합니다. 이는 코드를 작성하기 쉽게 만들 뿐만 아니라(모든 타입을 입력할 필요가 없기 때문에) 읽기도 쉽게 만듭니다. 많은 경우에 코드가 JavaScript와 똑같아 보이게 만들 수 있습니다. 타입 추론의 간단한 예는 다음과 같습니다:
const num = Math.random() + 5 // ✅ `number` // 🚀 greeting과 greet의 결과 모두 string이 됩니다 function greet(greeting = 'ciao') { return `${greeting}, ${getName()}` }
typescript
제네릭의 경우, 일반적으로 사용 방식에 따라 추론될 수 있어 매우 편리합니다. 수동으로 제공할 수도 있지만 많은 경우에 그럴 필요가 없습니다.
function identity<T>(value: T): T { return value } // 🚨 제네릭을 제공할 필요가 없습니다 let result = identity<number>(23) // ⚠️ 또는 결과에 주석을 달 필요도 없습니다 let result: number = identity(23) // 😎 'string'으로 올바르게 추론됩니다 let result = identity('react-query')
typescript
부분 타입 인수 추론
...은 아직 TypeScript에 존재하지 않습니다(이 열린 이슈 참조). 이는 기본적으로 하나의 제네릭을 제공하면 모두 제공해야 한다는 것을 의미합니다. 하지만 React Query는 제네릭에 대한 기본값이 있기 때문에, 그들이 사용될 것이라는 것을 바로 알아차리지 못할 수 있습니다. 결과적으로 나타나는 오류 메시지는 매우 모호할 수 있습니다. 이것이 실제로 역효과를 내는 예를 살펴보겠습니다:
function useGroupCount() { return useQuery<Group[], Error>({ queryKey: ['groups'], queryFn: fetchGroups, select: (groups) => groups.length, // 🚨 Type '(groups: Group[]) => number' is not assignable to type '(data: Group[]) => Group[]'. // Type 'number' is not assignable to type 'Group[]'.ts(2322) }) }
typescript
세 번째 제네릭을 제공하지 않았기 때문에 기본값이 적용되어 Group[]
가 되지만, select
함수에서 number
를 반환합니다. 한 가지 해결책은 단순히 세 번째 제네릭을 추가하는 것입니다:
function useGroupCount() { // ✅ 해결됨 return useQuery<Group[], Error, number>({ queryKey: ['groups'], queryFn: fetchGroups, select: (groups) => groups.length, }) }
typescript
부분 타입 인수 추론이 없는 한, 우리는 가진 것으로 작업해야 합니다.
그렇다면 대안은 무엇일까요?
모든 것을 추론하기
제네릭을 전혀 전달하지 않고 TypeScript가 무엇을 해야 할지 파악하도록 해봅시다. 이를 위해서는 queryFn
이 좋은 반환 타입을 가져야 합니다. 물론 명시적인 반환 타입 없이 그 함수를 인라인으로 작성하면 any
가 됩니다 - axios
나 fetch
가 제공하는 것이기 때문입니다:
function useGroups() { // 🚨 여기서 data는 `any`가 됩니다 return useQuery({ queryKey: ['groups'], queryFn: () => axios.get('groups').then((response) => response.data), }) }
typescript
API 계층을 쿼리와 분리하여 유지하고 싶다면(저처럼), _암시적 any_를 피하기 위해 어쨌든 타입 정의를 추가해야 하므로 React Query가 나머지를 추론할 수 있습니다:
function fetchGroups(): Promise<Group[]> { return axios.get('groups').then((response) => response.data) } // ✅ 여기서 data는 `Group[] | undefined`가 됩니다 function useGroups() { return useQuery({ queryKey: ['groups'], queryFn: fetchGroups }) } // ✅ 여기서 data는 `number | undefined`가 됩니다 function useGroupCount() { return useQuery({ queryKey: ['groups'], queryFn: fetchGroups, select: (groups) => groups.length, }) }
typescript
이 접근 방식의 장점은 다음과 같습니다:
- 더 이상 수동으로 제네릭을 지정할 필요가 없습니다
- 세 번째(select)와 네 번째(QueryKey) 제네릭이 필요한 경우에도 작동합니다
- 더 많은 제네릭이 추가되더라도 계속 작동할 것입니다
- 코드가 덜 혼란스럽고 JavaScript와 더 비슷해 보입니다
오류는 어떻게 되나요?
오류에 대해 물어볼 수 있습니다. 기본적으로 제네릭 없이 오류는 unknown
으로 추론됩니다. 이것이 버그처럼 들릴 수 있습니다. 왜 Error
가 아닐까요? 하지만 이는 의도적입니다. JavaScript에서는 무엇이든 throw할 수 있기 때문입니다 - Error
타입일 필요가 없습니다:
throw 5 throw undefined throw Symbol('foo')
javascript
React Query는 Promise를 반환하는 함수를 담당하지 않기 때문에 어떤 종류의 오류를 생성할 수 있는지 알 수 없습니다. 따라서 unknown
이 정확합니다. TypeScript가 여러 제네릭이 있는 함수를 호출할 때 일부 제네릭을 건너뛸 수 있게 되면(자세한 정보는 이 이슈 참조) 이를 더 잘 처리할 수 있겠지만, 지금은 오류로 작업해야 하고 제네릭을 전달하고 싶지 않다면 instanceof 체크로 타입을 좁힐 수 있습니다:
const groups = useGroups() if (groups.error) { // 🚨 이것은 작동하지 않습니다. 이유: Object is of type 'unknown'.ts(2571) return <div>오류가 발생했습니다: {groups.error.message}</div> } // ✅ instanceof 체크는 타입을 `Error`로 좁힙니다 if (groups.error instanceof Error) { return <div>오류가 발생했습니다: {groups.error.message}</div> }
typescript
어쨌든 오류가 있는지 확인하기 위해 어떤 종류의 체크를 해야 하므로, instanceof 체크는 나쁜 아이디어처럼 보이지 않습니다. 또한 런타임에 우리의 오류가 실제로 message 속성을 가지고 있는지 확인할 것입니다. 이는 TypeScript 4.4 릴리스에서 계획하고 있는 것과도 일치합니다. 이 릴리스에서는 catch 변수가 any
대신 unknown
이 되는 새로운 컴파일러 플래그 useUnknownInCatchVariables
를 도입할 예정입니다(여기 참조).
업데이트: v4부터 Error
의 타입은 unknown
대신 Error
로 기본 설정됩니다. JavaScript에서는 무엇이든 throw할 수 있지만(이는 unknown
을 가장 정확한 타입으로 만듭니다), 거의 항상 Error(또는 Error의 하위 클래스)가 throw됩니다. 이 변경으로 대부분의 경우 TypeScript에서 오류 필드를 더 쉽게 다룰 수 있게 되었습니다.
또한 React Query는 모듈 확장을 통해 전역 오류를 등록할 수 있게 합니다:
declare module '@tanstack/react-query' { interface Register { defaultError: AxiosError } }
typescript
이렇게 하면 defaultError: unknown
을 설정하여 v4의 동작으로 돌아갈 수 있습니다.
타입 좁히기
저는 React Query로 작업할 때 거의 구조 분해를 사용하지 않습니다. 첫째, data
와 error
같은 이름은 매우 보편적이어서(의도적으로 그렇게 만들어졌습니다) 어차피 이름을 바꿀 가능성이 높습니다. 전체 객체를 유지하면 그 데이터가 무엇인지 또는 오류가 어디서 왔는지에 대한 맥락을 유지할 수 있습니다. 또한 status 필드나 status 불리언 중 하나를 사용할 때 TypeScript가 타입을 좁히는 데 도움이 됩니다. 구조 분해를 사용하면 이를 수행할 수 없습니다:
const { data, isSuccess } = useGroups() if (isSuccess) { // 🚨 여기서 data는 여전히 `Group[] | undefined`일 것입니다 } const groupsQuery = useGroups() if (groupsQuery.isSuccess) { // ✅ groupsQuery.data는 이제 `Group[]`가 됩니다 }
typescript
이는 React Query와 관련이 없습니다. 단순히 TypeScript가 작동하는 방식입니다. @danvdk가 이 동작에 대해 좋은 설명을 제공했습니다.
@TkDodo의 코멘트가 정확히 맞습니다. TypeScript는 개별 심볼의 타입에 대해 정제를 수행합니다. 일단 분리하면 관계를 더 이상 추적할 수 없습니다. 일반적으로 이를 수행하는 것은 계산적으로 어려울 것입니다. 사람들에게도 어려울 수 있습니다. - 2021. 2. 21.
업데이트
TypeScript 4.6은 구조 분해된 판별 유니온에 대한 제어 흐름 분석을 추가했습니다. 이로 인해 위의 예제가 작동합니다. 따라서 이는 더 이상 문제가 되지 않습니다. 🙌
enabled 옵션과 타입 안전성
저는 enabled 옵션에 대한 ♥️를 처음부터 표현했지만, 의존적 쿼리에 사용하고 일부 매개변수가 아직 정의되지 않은 동안 쿼리를 비활성화하려는 경우 타입 수준에서 약간 까다로울 수 있습니다:
function fetchGroup(id: number): Promise<Group> { return axios.get(`group/${id}`).then((response) => response.data) } function useGroup(id: number | undefined) { return useQuery({ queryKey: ['group', id], queryFn: () => fetchGroup(id), enabled: Boolean(id), }) // 🚨 Argument of type 'number | undefined' is not assignable to parameter of type 'number'. // Type 'undefined' is not assignable to type 'number'.ts(2345) }
typescript
기술적으로 TypeScript가 맞습니다. id
는 undefined
일 수 있습니다: enabled
옵션은 타입 좁히기를 수행하지 않습니다. 또한 enabled
옵션을 우회하는 방법이 있습니다. 예를 들어 useQuery
에서 반환된 refetch
메서드를 호출하는 경우입니다. 이 경우 id
가 실제로 undefined
일 수 있습니다.
non-null assertion operator를 좋아하지 않는다면, 여기서 가장 좋은 방법은 id
가 undefined
일 수 있다는 것을 받아들이고 queryFn
에서 Promise를 거부하는 것입니다. 약간의 중복이 있지만 명시적이고 안전합니다:
function fetchGroup(id: number | undefined): Promise<Group> { // ✅ id가 `undefined`일 수 있으므로 런타임에 확인합니다 return typeof id === 'undefined' ? Promise.reject(new Error('Invalid id')) : axios.get(`group/${id}`).then((response) => response.data) } function useGroup(id: number | undefined) { return useQuery({ queryKey: ['group', id], queryFn: () => fetchGroup(id), enabled: Boolean(id), }) }
typescript
업데이트: v5.25부터 skipToken
을 사용하여 queryFn
에서 좋은 타입 안전성을 유지하면서 쿼리를 비활성화할 수 있습니다:
import { useQuery, skipToken } from '@tanstack/query' function useGroup(id: number | undefined) { return useQuery({ queryKey: ['group', id], queryFn: id ? () => fetchGroup(id) : skipToken, }) }
typescript
낙관적 업데이트
TypeScript에서 낙관적 업데이트를 올바르게 수행하는 것은 쉬운 일이 아닙니다. 그래서 우리는 예제로 문서에 포함시키기로 결정했습니다.
중요한 점은 최상의 타입 추론을 얻으려면 onMutate
에 전달된 variables
인수를 명시적으로 타입 지정해야 한다는 것입니다. 왜 그런지 완전히 이해하지는 못했지만, 다시 한 번 제네릭의 추론과 관련이 있는 것 같습니다. 자세한 정보는 이 코멘트를 참조하세요.
업데이트: TypeScript 4.7은 객체와 메서드에서 개선된 함수 추론을 추가했습니다. 이는 이 문제를 해결합니다. 이제 낙관적 업데이트는 추가 작업 없이 컨텍스트에 대한 타입을 올바르게 추론해야 합니다. 🥳
또한 React Query v5는 낙관적 업데이트를 만드는 새로운 방법을 제공하여 작성 방식을 크게 단순화할 수 있습니다.
useInfiniteQuery
대부분의 경우 useInfiniteQuery
의 타입 지정은 useQuery
의 타입 지정과 다르지 않습니다. 한 가지 주목할 만한 점은 queryFn
에 전달되는 pageParam
값이 any
로 타입 지정된다는 것입니다. 라이브러리에서 분명히 개선될 수 있지만, any
인 동안에는 명시적으로 주석을 달아주는 것이 가장 좋습니다:
type GroupResponse = { next?: number; groups: Group[] } const queryInfo = useInfiniteQuery({ queryKey: ['groups'], // ⚠️ `any`를 재정의하기 위해 pageParam을 명시적으로 타입 지정합니다 queryFn: ({ pageParam = 0, }: { pageParam: GroupResponse['next'] }) => fetchGroups(groups, pageParam), getNextPageParam: (lastGroup) => lastGroup.next, })
typescript
fetchGroups
가 GroupResponse
를 반환한다면, lastGroup
은 타입이 잘 추론되며, 같은 타입을 사용하여 pageParam
에 주석을 달 수 있습니다.
업데이트: 이는 v5에서 수정되었습니다. 이제 명시적인 initialPageParam
을 제공해야 하며, 이는 올바르게 타입 지정됩니다:
const queryInfo = useInfiniteQuery({ queryKey: ['groups'], // ✅ number로 올바르게 타입 지정됩니다 queryFn: ({ pageParam }) => fetchGroups(groups, pageParam), getNextPageParam: (lastGroup) => lastGroup.next, initialPageParam: 0, })
typescript
기본 쿼리 함수의 타입 지정
저는 개인적으로 defaultQueryFn을 사용하지 않지만, 많은 사람들이 사용한다는 것을 알고 있습니다. 이는 전달된 queryKey
를 활용하여 직접 요청 URL을 구축하는 깔끔한 방법입니다. queryClient
를 만들 때 함수를 인라인으로 작성하면 전달된 QueryFunctionContext
의 타입도 추론됩니다. TypeScript는 인라인으로 작성할 때 훨씬 더 좋습니다 :)
const queryClient = new QueryClient({ defaultOptions: { queries: { queryFn: async ({ queryKey: [url] }) => { const { data } = await axios.get(`${baseUrl}/${url}`) return data }, }, }, })
typescript
이는 잘 작동합니다. 하지만 url
은 unknown
타입으로 추론됩니다. 전체 queryKey
가 unknown
배열이기 때문입니다. queryClient
를 생성할 때는 useQuery
를 호출할 때 queryKey
가 어떻게 구성될지 전혀 보장할 수 없습니다. 따라서 React Query가 할 수 있는 일은 제한적입니다. 이는 이 고도로 동적인 기능의 특성 때문입니다. 그러나 이는 나쁜 것이 아닙니다. 오히려 이제 방어적으로 작업하고 런타임 검사로 타입을 좁혀 사용해야 한다는 의미입니다. 예를 들어:
const queryClient = new QueryClient({ defaultOptions: { queries: { queryFn: async ({ queryKey: [url] }) => { // ✅ url의 타입을 string으로 좁힙니다 // 이제 이를 사용할 수 있습니다 if (typeof url === 'string') { const { data } = await axios.get( `${baseUrl}/${url.toLowerCase()}` ) return data } throw new Error('Invalid QueryKey') }, }, }, })
typescript
이는 unknown
이 any
에 비해 왜 그렇게 훌륭하고 (그리고 과소평가된) 타입인지를 잘 보여줍니다. 최근에 제가 가장 좋아하는 타입이 되었습니다 - 하지만 그것은 다른 블로그 포스트의 주제가 될 것 같네요. 😊
결론
React Query와 TypeScript를 함께 사용하면 강력한 타입 안전성과 개발자 경험을 얻을 수 있습니다. 이 글에서 다룬 주요 포인트를 요약하면 다음과 같습니다:
- 가능한 한 타입 추론을 활용하세요. 대부분의 경우 명시적인 제네릭 타입 지정은 필요하지 않습니다.
enabled
옵션을 사용할 때는queryFn
에서 적절한 타입 체크를 수행하세요.- 낙관적 업데이트를 구현할 때는
onMutate
함수의 매개변수 타입을 명시적으로 지정하세요. useInfiniteQuery
를 사용할 때는pageParam
의 타입을 명시적으로 지정하는 것이 좋습니다.- 기본 쿼리 함수를 사용할 때는
queryKey
의 타입을 적절히 좁히세요.
TypeScript와 React Query를 함께 사용하면 초기에는 약간의 학습 곡선이 있을 수 있지만, 장기적으로는 더 안정적이고 유지보수가 쉬운 코드를 작성하는 데 도움이 됩니다. 계속해서 TypeScript와 React Query의 최신 업데이트를 따라가면서 코드의 타입 안전성을 개선할 수 있을 것입니다.
이러한 기술과 패턴을 적용하면 React Query와 TypeScript를 사용하는 프로젝트에서 더 나은 개발 경험을 얻을 수 있을 것입니다. 항상 최신 문서를 참조하고, 커뮤니티의 모범 사례를 따르는 것을 잊지 마세요. 타입 시스템을 최대한 활용하면서도, 과도한 복잡성을 피하는 균형을 찾는 것이 중요합니다.