Skip to Content
WebTypeScriptTypeScript 실무12. TanStack Query 타이핑

이번 편의 결과물: api/books.js, hooks/useBooks.ts, hooks/useBookMutations.jsservices/books.ts 하나로 통합되고, useBooksQuery·useBookQuery·useDeleteBookMutation이 명시적 제네릭 없이도 data 타입을 정확히 추론합니다. · 다루는 개념: queryKey 팩토리 타입, useQuery/useMutation의 자동 타입 추론, queryOptions 헬퍼로 타입 유지하며 재사용하기

이 편에서 만드는 파일

bookshelf-ts/src/ ├── services/ │ └── books.ts + 신규 (api/books.js + useBooks.ts + useBookMutations.js를 통합) ├── api/ │ └── books.js − 삭제 ├── hooks/ │ ├── useBooks.ts − 삭제 │ └── useBookMutations.js − 삭제 └── pages/ ├── BookListPage.tsx ~ 수정 (useBooks → useBooksQuery) └── BookDetailPage.tsx ~ 수정 (getBook 직접 호출 → useBookQuery, 삭제 버튼 추가)

개념 정리

06편은 getBooks가 아직 .jsuseBooks.tsqueryFn 안에서 as Promise<BookListResult>로 캐스팅했습니다. api/books.js 자체가 TypeScript로 바뀌면 이 캐스팅이 필요 없어집니다. TanStack Query는 대부분의 데이터 조회 라이브러리와 달리 useQuerydata 타입을 queryFn의 반환 타입에서 그대로 가져옵니다. queryFn이 반환하는 값의 타입이 명확하면, useQuery 쪽에 타입 인자를 따로 쓸 필요가 없습니다.

상황결과
queryFn의 반환 타입을 명시하지 않은 함수를 넘김dataany에 가깝게 추론되기 쉬움
반환 타입을 명시한 함수((): Promise<BookListResult>)를 넘김data가 정확히 BookListResult | undefined로 추론됨
useQuery<BookListResult>({...})처럼 제네릭을 직접 지정대부분 불필요함. queryFn 반환 타입이 이미 흐르기 때문

useMutation도 같은 원리입니다. mutationFn의 매개변수 타입이 mutate 함수 호출 인자의 타입이 되고, mutationFn의 반환 타입이 onSuccessdata 매개변수 타입이 됩니다.

옵션 객체를 다른 곳에서도 재사용하려면 queryOptions 헬퍼로 감싸는 것이 권장 패턴입니다. queryOptions로 감싸면 queryClient.getQueryData(...)처럼 다른 API에 넘길 때도 타입 인자 없이 같은 타입 추론을 그대로 받을 수 있습니다.

실습

1. services/books.ts 작성

// src/services/books.ts import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Book, BookStatus } from '../types/book.ts' const API_BASE = 'http://localhost:3001' export interface BookListParams { status?: BookStatus | 'all' sortBy?: keyof Book sortOrder?: 'asc' | 'desc' search?: string page?: number pageSize?: number } export interface BookListResult { items: Book[] total: number page: number pageSize: number } export const bookKeys = { all: ['books'] as const, list: (params: BookListParams) => ['books', params] as const, detail: (id: number) => ['books', id] as const, } export async function getBooks(params: BookListParams): Promise<BookListResult> { const { status = 'all', sortBy = 'title', sortOrder = 'asc', search = '', page = 1, pageSize = 5 } = params const query = new URLSearchParams({ _sort: sortBy, _order: sortOrder, _page: String(page), _limit: String(pageSize), }) if (status !== 'all') query.set('status', status) if (search) query.set('q', search) const response = await fetch(`${API_BASE}/books?${query.toString()}`) if (!response.ok) { throw new Error('책 목록을 불러오지 못했습니다') } const total = Number(response.headers.get('X-Total-Count') ?? 0) const items = (await response.json()) as Book[] return { items, total, page, pageSize } } export async function getBook(id: number): Promise<Book> { const response = await fetch(`${API_BASE}/books/${id}`) if (!response.ok) { throw new Error('책 정보를 불러오지 못했습니다') } return (await response.json()) as Book } export async function deleteBook(id: number): Promise<{ id: number }> { const response = await fetch(`${API_BASE}/books/${id}`, { method: 'DELETE' }) if (!response.ok) { throw new Error('책을 삭제하지 못했습니다') } return { id } } export function booksQueryOptions(params: BookListParams) { return queryOptions({ queryKey: bookKeys.list(params), queryFn: () => getBooks(params), }) } export function useBooksQuery(params: BookListParams) { return useQuery(booksQueryOptions(params)) } export function useBookQuery(id: number) { return useQuery({ queryKey: bookKeys.detail(id), queryFn: () => getBook(id), enabled: Number.isFinite(id), }) } export function useDeleteBookMutation() { const queryClient = useQueryClient() return useMutation({ mutationFn: deleteBook, onSuccess: () => { queryClient.invalidateQueries({ queryKey: bookKeys.all }) }, }) }

bookKeys의 세 함수 모두 반환값에 as const를 붙여, queryKey가 정확한 길이의 읽기 전용 튜플로 고정되게 했습니다. getBooks, getBook, deleteBook은 반환 타입을 명시적으로 적어, 이 함수들을 queryFn·mutationFn으로 넘겼을 때 그 타입이 그대로 전해지게 합니다. useBookQueryenabled: Number.isFinite(id)는 08편에서 enabled: Boolean(id)idundefined일 때 요청을 막던 가드를, 문자열이 아닌 숫자 id 기준으로 옮긴 것입니다.

2. BookListPage에서 useBooksQuery로 교체

// src/pages/BookListPage.tsx (import와 훅 호출부만 수정 — 나머지는 10~11편 코드 유지) import { useBooksQuery } from '../services/books.ts' // ...컴포넌트 안에서 const { data, isPending, isError } = useBooksQuery({ status: statusFilter, sortBy, sortOrder, search: searchQuery, page, })

hooks/useBooks.ts가 하던 일을 services/books.tsuseBooksQuery가 그대로 이어받습니다. data, isPending, isError를 쓰는 나머지 코드는 한 글자도 바뀌지 않습니다.

3. BookDetailPage에서 useBookQuery·삭제 기능 적용

// src/pages/BookDetailPage.tsx import { useParams, useNavigate, Link } from 'react-router' import { useBookQuery, useDeleteBookMutation } from '../services/books.ts' function BookDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const bookId = Number(id) const { data: book, isPending, isError } = useBookQuery(bookId) const deleteBookMutation = useDeleteBookMutation() if (isPending) return <p role="status">책 정보를 불러오는 중입니다...</p> if (isError || !book) return <p role="alert">책 정보를 찾을 수 없습니다.</p> function handleDelete() { deleteBookMutation.mutate(bookId, { onSuccess: () => navigate('/'), }) } return ( <article> <h1>{book.title}</h1> <p>{book.author}</p> <p>{book.pages}쪽</p> <p>상태: {book.status}</p> {book.memo && <p>{book.memo}</p>} <button type="button" onClick={handleDelete} disabled={deleteBookMutation.isPending}> 삭제 </button> <Link to="/">목록으로 돌아가기</Link> </article> ) } export default BookDetailPage

useBookQuery, useDeleteBookMutation 모두 services/books.ts에서 가져온 함수라 캐스팅이 없습니다. book에 마우스를 올리면 Book(널 가드 이후)으로 뜨고, deleteBookMutation.mutate(bookId, ...)에서 bookId 대신 book.title(문자열)을 넘기면 그 자리에서 오류가 납니다.

4. 남은 파일 삭제

rm src/api/books.js rm src/hooks/useBooks.ts rm src/hooks/useBookMutations.js

5. 실행

npm run dev

6. 확인

  • 책 목록 조회·필터·정렬·검색·페이지 이동이 지금까지와 동일하게 동작합니다.
  • 책 상세 페이지에서 “삭제”를 누르면 목록 페이지로 돌아가고, 삭제한 책이 목록에서 사라집니다.
  • VS Code에서 useBooksQuery(...)가 반환하는 data에 마우스를 올려 BookListResult | undefined로 추론되는지 확인합니다.
  • deleteBookMutation.mutate('abc')처럼 문자열을 넘겨보고 컴파일 오류가 나는지 확인한 뒤 원래 코드로 되돌립니다.

직접 해보기

  1. services/books.tscreateBook(payload: Omit<Book, 'id'>): Promise<Book> 함수를 추가하고, useCreateBookMutation을 만들어 보세요. mutate 호출 시 넘기는 값의 타입이 Omit<Book, 'id'>으로 강제되는지 확인합니다.
  2. booksQueryOptionsqueryClient.getQueryData(booksQueryOptions(params).queryKey) 형태로 다른 곳에서 호출해 보고, 타입 인자 없이도 BookListResult | undefined가 추론되는 것을 확인해 보세요.

정답 보기

// src/services/books.ts (추가분) export async function createBook(payload: Omit<Book, 'id'>): Promise<Book> { const response = await fetch(`${API_BASE}/books`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { throw new Error('책을 추가하지 못했습니다') } return (await response.json()) as Book } export function useCreateBookMutation() { const queryClient = useQueryClient() return useMutation({ mutationFn: createBook, onSuccess: () => { queryClient.invalidateQueries({ queryKey: bookKeys.all }) }, }) }

mutationFn: createBook의 매개변수 타입이 Omit<Book, 'id'>이므로, useCreateBookMutation()이 반환하는 mutate도 그 타입만 받습니다. id를 포함해서 넘기면 초과 프로퍼티로 오류가 납니다.

자주 하는 실수

증상원인고치는 법
useQuerydataany로 추론됨queryFn으로 넘긴 함수에 반환 타입을 명시하지 않음getBooks(params: BookListParams): Promise<BookListResult>처럼 반환 타입을 명시한다
굳이 useQuery<BookListResult>({...})처럼 제네릭을 매번 적음queryFn 반환 타입 추론을 활용하지 않고 습관적으로 제네릭을 씀queryFn의 반환 타입만 정확하면 대부분 제네릭이 필요 없다
bookKeys.list(params)의 결과를 다른 곳에서 배열로 다루다 타입이 어긋남as const를 빠뜨려 queryKey가 넓은 배열 타입으로 추론됨반환값에 as const를 붙여 튜플 형태를 고정한다
mutate 호출 인자 타입이 예상과 다름mutationFn의 매개변수 타입을 잘못 선언함mutationFn의 매개변수 타입이 곧 mutate 인자 타입이라는 점을 기준으로 시그니처를 맞춘다

확인 문제

문제 14지선다
useQuery의 data 타입이 결정되는 가장 근본적인 기준은
문제 24지선다
queryOptions 헬퍼로 옵션 객체를 감싸서 얻는 이점으로 가장 알맞은 것은
문제 34지선다
bookKeys.list가 반환하는 배열에 as const를 붙이는 이유는
문제 44지선다
useDeleteBookMutation이 반환하는 mutate 함수가 number 타입 인자만 받게 되는 이유는
문제 54지선다
getBooks 함수에 반환 타입(Promise 형태)을 명시하지 않으면 생기는 문제로 가장 알맞은 것은

참고 자료

Last updated on