이번 편의 결과물: BookListPage가 로컬 배열 대신 mock API 응답으로 렌더됩니다. 로딩 스피너와 에러 메시지가 화면에 표시됩니다. · 다루는 개념: QueryClient/QueryClientProvider 설정, json-server mock API 서버, useQuery로 조회, 로딩/에러 상태
이 편에서 만드는 파일
bookshelf/
├── db.json + (json-server용 mock 데이터)
├── package.json ~ (@tanstack/react-query 추가)
└── src/
├── main.jsx ~ (QueryClientProvider 추가)
├── api/
│ └── books.js + (책 목록 조회 함수)
├── hooks/
│ └── useBooks.js ~ (localStorage+reducer 기반 → useQuery 기반으로 교체)
├── reducers/
│ └── booksReducer.js − 삭제(TanStack Query 캐시가 대체)
├── utils/
│ └── booksStorage.js − 삭제(TanStack Query 캐시가 대체)
└── pages/
└── BookListPage.jsx ~ (useBooks 훅으로 서버 데이터 렌더)개념 정리
TanStack Query는 앱 전체에서 하나의 QueryClient 인스턴스를 공유합니다. QueryClientProvider로 컴포넌트 트리를 감싸면, 그 안 어디서든 useQuery로 서버 데이터를 조회할 수 있습니다.
| 요소 | 역할 |
|---|---|
QueryClient | 캐시 저장소 하나. 앱 전체에서 인스턴스 하나만 만든다 |
QueryClientProvider | React Context로 QueryClient를 하위 트리에 전달 |
queryKey | 캐시 안에서 이 데이터를 식별하는 배열(예: ['books']) |
queryFn | 데이터를 가져오는 비동기 함수. 반환값이 캐시에 저장됨 |
data, isLoading, isError | useQuery가 반환하는, 화면이 바로 쓸 수 있는 상태 |
이 과목은 실제 서버 대신 json-server로 mock API를 띄웁니다. public/practice/bookshelf/db.json에 책 목록과 사용자 데이터가 들어 있고, json-server가 이 파일을 읽어 REST API처럼 응답합니다. 통합·E2E 테스트(15~16편)에서는 MSW로 같은 흐름을 재현하지만, 지금은 실제로 떠 있는 서버에 요청을 보내며 눈으로 확인합니다.
실습
1. mock API 서버 준비
https://zeno.it.kr/practice/bookshelf/db.json 파일을 내려받아 bookshelf/db.json으로 저장합니다.
curl -o db.json https://zeno.it.kr/practice/bookshelf/db.jsondb.json은 다음 구조입니다.
// bookshelf/db.json (일부)
{
"books": [
{ "id": 1, "title": "클린 코드", "author": "로버트 C. 마틴", "status": "done", "rating": 5, "pages": 584, "coverColor": "#2563eb", "startedAt": "2026-07-01", "finishedAt": "2026-07-20", "memo": "함수는 한 가지 일만." }
],
"users": [
{ "id": 1, "email": "zeno@example.com", "password": "password123", "name": "Zeno Kim" }
]
}2. 패키지 설치와 서버 실행
npm install @tanstack/react-query
npx json-server db.json --port 3001json-server는 db.json의 books 배열을 http://localhost:3001/books로, users 배열을 http://localhost:3001/users로 그대로 노출합니다. 이 터미널은 실습 내내 켜 둡니다.
3. 조회 함수 작성
// bookshelf/src/api/books.js
const API_BASE = 'http://localhost:3001'
export async function getBooks() {
const response = await fetch(`${API_BASE}/books`)
if (!response.ok) {
throw new Error(`책 목록을 가져오지 못했습니다 (상태 코드: ${response.status})`)
}
return response.json()
}4. QueryClientProvider로 앱 감싸기
// bookshelf/src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './router.jsx'
import './styles/index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
createRoot(document.getElementById('root')).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)react_2가 남긴 router.jsx(createBrowserRouter)를 그대로 씁니다. App.jsx는 react_2 03편부터 이미 쓰이지 않으므로 새로 만들지 않습니다.
staleTime을 30초로 두면, 같은 화면에 30초 안에 다시 들어와도 캐시를 그대로 쓰고 네트워크 요청을 다시 보내지 않습니다.
5. useBooks 훅을 TanStack Query 기반으로 교체
react_2의 hooks/useBooks.js는 useReducer와 localStorage로 책 목록을 관리했습니다. 이 편부터는 같은 이름의 훅이 서버 데이터를 조회하는 useQuery 래퍼로 바뀝니다. 훅 이름과 사용법(컴포넌트에서 useBooks() 호출)은 그대로 두고 내부 구현만 교체해, BookListPage가 이후 편에서도 같은 훅 이름을 계속 쓸 수 있게 합니다.
// bookshelf/src/hooks/useBooks.js
import { useQuery } from '@tanstack/react-query'
import { getBooks } from '../api/books.js'
export function useBooks(params = {}) {
return useQuery({
queryKey: ['books', params],
queryFn: () => getBooks(params),
})
}useBooks는 useQuery가 반환하는 객체(data, isLoading, isError, error 등)를 그대로 돌려줍니다. 별도로 감싸지 않아, TanStack Query가 제공하는 값을 컴포넌트가 바로 씁니다.
// bookshelf/src/pages/BookListPage.jsx
import { useBooks } from '../hooks/useBooks.js'
import BookCard from '../components/BookCard.jsx'
export default function BookListPage() {
const { data: books, isLoading, isError, error } = useBooks()
if (isLoading) {
return <p role="status">책 목록을 불러오는 중입니다...</p>
}
if (isError) {
return <p role="alert">목록을 불러오지 못했습니다: {error.message}</p>
}
return (
<section>
<h1>내 서재</h1>
<ul>
{books.map((book) => (
<li key={book.id}>
<BookCard book={book} />
</li>
))}
</ul>
</section>
)
}isLoading, isError는 useQuery가 대신 관리합니다. 컴포넌트는 세 가지 상태(로딩·에러·성공)를 분기하기만 하면 됩니다.
실행
npx json-server db.json --port 3001
npm run dev두 명령을 각각 다른 터미널에서 실행합니다. json-server가 먼저 떠 있어야 브라우저에서 목록이 보입니다.
확인
- 브라우저에서
/로 이동하면 잠깐 “책 목록을 불러오는 중입니다…”가 보인 뒤 6권의 책 목록으로 바뀐다 - 브라우저 개발자 도구 네트워크 탭에
http://localhost:3001/books요청이 찍혀 있다 json-server를 켠 터미널을 끄고 새로고침하면 “목록을 불러오지 못했습니다”가 표시된다
직접 해보기
staleTime을 0으로 바꾸고 화면을 왔다 갔다 하면 네트워크 탭에서 무엇이 달라지는지 확인해보세요.
staleTime이 0이면 컴포넌트가 다시 마운트될 때마다 데이터를 즉시 오래된 것으로 취급해, 화면 전환마다 /books 요청이 다시 나갑니다. 30초로 두면 그 시간 안에는 캐시를 재사용해 요청이 나가지 않습니다.
getBooks가 실패하는 상황을 재현하려면 어떻게 해야 할지 생각해보세요(서버를 끄는 것 말고 다른 방법).
API_BASE의 포트 번호를 일부러 틀리게 바꾸면(3001 대신 3099) 요청이 실패해 같은 에러 화면을 볼 수 있습니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
useQuery를 어디서든 쓸 수 없다는 에러가 난다 | QueryClientProvider로 감싸지 않음 | main.jsx에서 App을 QueryClientProvider로 감싼다 |
| 목록이 영영 안 뜬다 | json-server를 안 켜거나 포트가 다름 | 3001 포트로 json-server가 떠 있는지 확인 |
데이터가 화면에서 undefined로 보인다 | response.json()을 await 하지 않음 | getBooks에서 return response.json()으로 프라미스를 반환 |