Skip to Content
WebReactReact 실무06. 낙관적 업데이트와 페이지네이션 캐시

이번 편의 결과물: 삭제 버튼을 누르면 서버 응답 전에 즉시 목록에서 사라지고, 실패하면 원래대로 되돌아갑니다. 목록이 페이지 단위로 조회됩니다. · 다루는 개념: onMutate 낙관적 업데이트, 실패 시 롤백(onError), 페이지 단위 조회와 placeholderData

05편까지는 삭제 버튼을 누르면 서버 응답이 올 때까지 기다렸다가 invalidateQueries로 다시 조회했습니다. 네트워크가 느리면 사용자는 클릭 후 잠깐 멈춘 화면을 보게 됩니다. 이 편에서는 서버 응답을 기다리지 않고 화면을 먼저 바꾸는 낙관적 업데이트를 적용하고, 목록을 페이지 단위로 나눕니다.

이 편에서 만드는 파일

bookshelf/src/ ├── api/ │ └── books.js ~ (getBooks가 page 파라미터를 받고 총 개수를 함께 반환) ├── hooks/ │ ├── useBooks.js ~ (placeholderData 추가로 페이지 전환 시 깜빡임 제거) │ └── useBookMutations.js ~ (useDeleteBook에 낙관적 업데이트 적용) └── pages/ └── BookListPage.jsx ~ (낙관적 삭제, 페이지 이동 UI)

개념 정리

개념설명
onMutate요청을 보내기 전에 실행. 여기서 캐시를 직접 수정해 화면을 먼저 바꾼다
롤백onMutate가 반환한 값(변경 전 스냅샷)을 onError에서 다시 캐시에 넣어 되돌린다
cancelQueries진행 중인 조회가 낙관적 변경을 덮어쓰지 않도록 취소한다
placeholderData새 페이지를 불러오는 동안 이전 페이지 데이터를 화면에 유지한다

낙관적 업데이트는 3단계로 이뤄집니다. (1) onMutate에서 캐시를 먼저 바꾸고 이전 상태를 저장, (2) 요청이 실패하면 onError에서 저장해둔 이전 상태로 복원, (3) 성공하든 실패하든 onSettled에서 서버와 다시 동기화합니다. 이 세 단계를 빠뜨리면 화면과 서버 상태가 어긋난 채로 남을 수 있습니다.

json-server_page, _limit 쿼리 파라미터로 페이지 단위 조회를 지원하고, 응답 헤더 X-Total-Count에 전체 개수를 담아줍니다.

실습

1. API 함수에 페이지 파라미터 추가

// bookshelf/src/api/books.js const API_BASE = 'http://localhost:3001' export async function getBooks({ page = 1, limit = 4 } = {}) { const response = await fetch(`${API_BASE}/books?_page=${page}&_limit=${limit}`) if (!response.ok) { throw new Error(`책 목록을 가져오지 못했습니다 (상태 코드: ${response.status})`) } const items = await response.json() const total = Number(response.headers.get('X-Total-Count') ?? items.length) return { items, total } } export async function createBook(book) { const response = await fetch(`${API_BASE}/books`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(book), }) if (!response.ok) { throw new Error('책을 추가하지 못했습니다') } return response.json() } export async function deleteBook(id) { const response = await fetch(`${API_BASE}/books/${id}`, { method: 'DELETE' }) if (!response.ok) { throw new Error('책을 삭제하지 못했습니다') } return response.json() }

getBooks의 반환 모양이 배열에서 { items, total } 객체로 바뀝니다. 이 계약 변경 때문에 hooks/useBooks.js가 넘겨주는 data의 모양도 함께 바뀌고, BookListPage도 그에 맞게 고쳐야 합니다.

2. useBooks에 placeholderData 추가

// bookshelf/src/hooks/useBooks.js import { useQuery, keepPreviousData } from '@tanstack/react-query' import { getBooks } from '../api/books.js' export function useBooks(params = {}) { return useQuery({ queryKey: ['books', params], queryFn: () => getBooks(params), placeholderData: keepPreviousData, }) }

3. useDeleteBook에 낙관적 업데이트 적용

05편에서 만든 useDeleteBook은 성공한 뒤에야 캐시를 무효화했습니다. 이제 요청을 보내기 전에 캐시를 먼저 바꾸도록 훅 내부만 고칩니다. 이 훅을 쓰는 컴포넌트 쪽 코드는 바뀌지 않습니다.

// bookshelf/src/hooks/useBookMutations.js (useDeleteBook만 발췌 — useCreateBook은 05편 코드 유지) import { useMutation, useQueryClient } from '@tanstack/react-query' import { createBook, deleteBook } from '../api/books.js' export function useDeleteBook(queryKey) { const queryClient = useQueryClient() return useMutation({ mutationFn: deleteBook, onMutate: async (id) => { await queryClient.cancelQueries({ queryKey }) const previous = queryClient.getQueryData(queryKey) queryClient.setQueryData(queryKey, (old) => old ? { ...old, items: old.items.filter((book) => book.id !== id) } : old, ) return { previous } }, onError: (mutationError, id, context) => { queryClient.setQueryData(queryKey, context.previous) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['books'] }) }, }) }

queryKey가 페이지마다 달라지므로(['books', { page }]), 어떤 페이지를 보고 있을 때 삭제했는지 알아야 정확한 캐시를 바꿀 수 있습니다. 그래서 useDeleteBookqueryKey를 인자로 받도록 시그니처를 바꿨습니다.

4. 목록 화면에 낙관적 삭제와 페이지 이동 적용

// bookshelf/src/pages/BookListPage.jsx import { useState } from 'react' import { Link } from 'react-router' import { useBooks } from '../hooks/useBooks.js' import { useDeleteBook } from '../hooks/useBookMutations.js' import BookList from '../components/BookList.jsx' const PAGE_SIZE = 4 export default function BookListPage() { const [page, setPage] = useState(1) const params = { page, limit: PAGE_SIZE } const queryKey = ['books', params] const { data, isLoading, isError, error } = useBooks(params) const deleteMutation = useDeleteBook(queryKey) if (isLoading) { return <p role="status">책 목록을 불러오는 중입니다...</p> } if (isError) { return <p role="alert">목록을 불러오지 못했습니다: {error.message}</p> } const { items, total } = data const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) return ( <section> <header> <h1>내 서재</h1> <Link to="/books/new">새 책 등록</Link> </header> <BookList books={items} onDelete={(id) => deleteMutation.mutate(id)} /> <nav aria-label="페이지 이동"> <button type="button" disabled={page === 1} onClick={() => setPage((p) => p - 1)}> 이전 </button> <span> {page} / {totalPages} </span> <button type="button" disabled={page === totalPages} onClick={() => setPage((p) => p + 1)}> 다음 </button> </nav> </section> ) }

invalidateQueries({ queryKey: ['books'] })처럼 앞부분만 지정하면 ['books', 1], ['books', 2] 등 페이지별 캐시를 한번에 무효화합니다. queryKey가 배열이라 부분 일치로 동작합니다.

5. 실행과 확인

npx json-server db.json --port 3001 npm run dev
  • / 화면에서 4권씩 목록이 보이고 “이전/다음” 버튼으로 페이지를 넘길 수 있다.
  • 삭제 버튼을 누르면 네트워크 응답을 기다리지 않고 즉시 목록에서 사라진다.
  • 브라우저 개발자 도구의 네트워크 탭에서 응답 속도를 느리게(Slow 3G) 설정하고 삭제해도 화면은 바로 바뀐다.
  • 마지막 페이지에서 “다음” 버튼이 비활성화된다.

직접 해보기

  1. json-server를 잠깐 끈 상태에서 삭제 버튼을 누르고, 화면이 원래대로 롤백되는지 확인해 보세요.
  2. keepPreviousData 대신 placeholderData를 빼면 페이지를 넘길 때 화면이 어떻게 달라지는지 관찰해 보세요.

정답 보기

placeholderData: keepPreviousData를 제거하면 페이지를 넘길 때마다 isLoading이 다시 true가 되어 “불러오는 중입니다…”가 매번 깜빡입니다. keepPreviousData는 새 페이지 데이터가 도착할 때까지 이전 페이지 데이터를 화면에 유지해 깜빡임을 없앱니다.

json-server를 끄면 deleteBookfetch가 실패해 deleteMutationonError가 실행되고, context.previous로 캐시가 복원되어 사라졌던 항목이 다시 나타납니다.

자주 하는 실수

증상원인고치는 법
롤백 후에도 다른 페이지 데이터가 깨짐queryKey를 페이지 번호 없이 고정 문자열로 사용['books', page]처럼 페이지를 포함한 키를 쓴다
삭제 도중 화면이 두 번 깜빡임onMutate에서 cancelQueries를 생략진행 중인 조회를 취소한 뒤 캐시를 수정한다
페이지를 넘길 때마다 로딩 문구가 다시 보임placeholderData 미설정keepPreviousDataplaceholderData에 지정한다

확인 문제

문제 14지선다
낙관적 업데이트에서 onMutate가 실행되는 시점은
문제 24지선다
onError에서 context.previous로 캐시를 되돌리는 이유는
문제 34지선다
placeholderData에 keepPreviousData를 지정하면 어떤 효과가 있는가
문제 44지선다
queryKey를 books, page 형태의 배열로 만드는 이유는

참고 자료

Last updated on