From 1bea119db4abf60ce6529c239c17a7ff5a0505bd Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 15:23:04 +0900 Subject: [PATCH 1/9] =?UTF-8?q?docs:=20=EB=AF=B8=EC=85=98=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2.3-TanStack Query/README.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 02-state-management-tools/2.3-TanStack Query/README.md diff --git a/02-state-management-tools/2.3-TanStack Query/README.md b/02-state-management-tools/2.3-TanStack Query/README.md new file mode 100644 index 0000000..498edb1 --- /dev/null +++ b/02-state-management-tools/2.3-TanStack Query/README.md @@ -0,0 +1,53 @@ +# 02-3. 서버상태관리 - TanStack Query + +💡해당 미션은 **여러 사용자가 하나의 서버를 공유하고, 식당 목록이 주기적으로 업데이트되는 환경**을 가정하여 진행합니다. + +## 🎯 요구사항 + +- TanStack Query를 사용해 서버 상태를 클라이언트 상태와 분리하고, 효율적인 데이터 캐싱과 요청 관리를 구현해 보세요. + - 쿼리 및 뮤테이션 설정은 명확한 이유가 있다면 자유롭게 변경해도 좋습니다. +- TanStack Query를 **왜** 사용하는지, 서버 상태와 클라이언트 상태를 분리하였을때 어떤 점이 달랐는지, 또 trade-off가 있는지 적어주세요. + - 기술적인 것도 좋고 개발자의 경험 측면에서도 좋습니다. +- TanStack Query Devtools를 이용하여 Query의 변화와 Mutation의 발생을 확인해보세요. +- (선택) 뮤테이션 로직에 낙관적 업데이트(Optimistic Update)를 적용해 보고 어떤 상황에서 낙관적 업데이트가 효과적인지, 그리고 주의해야 할 점은 무엇인지 적어주세요. + - Browser Throttling 기능을 활용하여 네트워크 속도를 느리게 설정한 뒤 낙관적 업데이트가 실제로 어떻게 동작하는지 확인해 보세요. + +### 😗구현 예시 + +- 컴포넌트의 이름이나 구조를 정한 이유가 명확해야하며 타인에게 설명할 수 있어야합니다. +- 아래는 main.jsx의 설정 모습입니다. + +```javascript +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById("root")).render( + + + +); +``` + +## ✅ 키워드 + +- props drilling +- 서버 상태관리 + - TanStack Query + - QueryClient + - Query Key + - useQuery + - useMutation + - Optimistic Update + +## 🧙‍♀️ 진행 가이드 + +- 진행시간 : 4시간 내에 완료하는 것을 목표로 합니다. + +## 🔗 참고 문서 + +- [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) +- [TanStack Query 메인테이너 Tk Dodo 님의 블로그](https://tkdodo.eu/blog/tags/react-query) +- [테코톡(시모의 Tanstack Query)](https://www.youtube.com/watch?v=RfK15tw8H-I) \ No newline at end of file From 49b51ba69bcd074e4785539762b91aaa5f938e2f Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 17:34:41 +0900 Subject: [PATCH 2/9] =?UTF-8?q?chore:=20TanStack=20Query=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 55 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 57 insertions(+) diff --git a/package-lock.json b/package-lock.json index d46a1e2..71b60cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "self-paced-react", "version": "0.0.0", "dependencies": { + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", "react": "^18.2.0", "react-dom": "^18.2.0", "styled-components": "^6.4.2", @@ -1139,6 +1141,59 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz", + "integrity": "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz", + "integrity": "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.2", + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index fcd53d9..3df5853 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "server": "npx json-server db.json" }, "dependencies": { + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", "react": "^18.2.0", "react-dom": "^18.2.0", "styled-components": "^6.4.2", From 2448f28e6a6ce0d196f3825840cd20ce7272f597 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 17:34:55 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20useRestaurantStore=EB=A5=BC=20u?= =?UTF-8?q?seFilterStore=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=83=81=ED=83=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 8 +++---- src/store/useFilterStore.js | 21 +++++++++++++++++ src/store/useRestaurantStore.js | 41 --------------------------------- 3 files changed, 24 insertions(+), 46 deletions(-) create mode 100644 src/store/useFilterStore.js delete mode 100644 src/store/useRestaurantStore.js diff --git a/src/App.jsx b/src/App.jsx index 3531734..220ea04 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,13 +5,11 @@ import RestaurantList from "./components/RestaurantList.jsx"; import { useState } from "react"; import RestaurantDetailModal from "./components/RestaurantDetailModal.jsx"; import AddRestaurantModal from "./components/AddRestaurantModal.jsx"; -import useRestaurantStore from "./store/useRestaurantStore.js"; +import useFilterStore from "./store/useFilterStore.js"; function App() { - const selectedCategory = useRestaurantStore( - (state) => state.selectedCategory, - ); - const setSelectedCategory = useRestaurantStore( + const selectedCategory = useFilterStore((state) => state.selectedCategory); + const setSelectedCategory = useFilterStore( (state) => state.setSelectedCategory, ); const [clickedRestaurant, setClickedRestaurant] = useState(null); diff --git a/src/store/useFilterStore.js b/src/store/useFilterStore.js new file mode 100644 index 0000000..c4a51cc --- /dev/null +++ b/src/store/useFilterStore.js @@ -0,0 +1,21 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { ALL_CATEGORY } from "../constants/categories.js"; + +const useFilterStore = create( + persist( + (set) => ({ + // 상태 + selectedCategory: ALL_CATEGORY, + + // 액션 + setSelectedCategory: (category) => set({ selectedCategory: category }), + }), + { + name: "category-filter", + storage: createJSONStorage(() => sessionStorage), + }, + ), +); + +export default useFilterStore; diff --git a/src/store/useRestaurantStore.js b/src/store/useRestaurantStore.js deleted file mode 100644 index 31a81f0..0000000 --- a/src/store/useRestaurantStore.js +++ /dev/null @@ -1,41 +0,0 @@ -import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; -import { getRestaurants, createRestaurant } from "../api.js"; -import { ALL_CATEGORY } from "../constants/categories.js"; - -const useRestaurantStore = create( - persist( - (set, get) => ({ - // 상태 - restaurants: [], - isLoading: false, - error: null, - selectedCategory: ALL_CATEGORY, - - // 액션 - fetchRestaurants: async () => { - set({ isLoading: true, error: null }); - try { - const data = await getRestaurants(); - set({ restaurants: data }); - } catch { - set({ error: "음식점 목록을 불러오지 못했습니다." }); - } finally { - set({ isLoading: false }); - } - }, - addRestaurant: async (restaurant) => { - await createRestaurant(restaurant); - await get().fetchRestaurants(); - }, - setSelectedCategory: (category) => set({ selectedCategory: category }), - }), - { - name: "restaurant-storage", - storage: createJSONStorage(() => sessionStorage), - partialize: (state) => ({ selectedCategory: state.selectedCategory }), - }, - ), -); - -export default useRestaurantStore; From 6b878d6ba7dba1f7a6e77d5580d51f74ff3b2bb8 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 17:35:07 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20TanStack=20Query=EB=A1=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AddRestaurantModal.jsx | 17 +++++++++-------- src/components/RestaurantList.jsx | 20 ++++++-------------- src/main.jsx | 11 +++++++++-- src/queries/useAddRestaurantMutation.js | 13 +++++++++++++ src/queries/useRestaurantsQuery.js | 9 +++++++++ 5 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 src/queries/useAddRestaurantMutation.js create mode 100644 src/queries/useRestaurantsQuery.js diff --git a/src/components/AddRestaurantModal.jsx b/src/components/AddRestaurantModal.jsx index 2d9e4c5..e263aed 100644 --- a/src/components/AddRestaurantModal.jsx +++ b/src/components/AddRestaurantModal.jsx @@ -2,24 +2,25 @@ import { useState } from "react"; import Modal from "./Modal.jsx"; import { CATEGORIES } from "../constants/categories.js"; import styled from "styled-components"; -import useRestaurantStore from "../store/useRestaurantStore.js"; +import { useAddRestaurantMutation } from "../queries/useAddRestaurantMutation.js"; export default function AddRestaurantModal({ onClose }) { const [category, setCategory] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); - const addRestaurant = useRestaurantStore((state) => state.addRestaurant); + const mutation = useAddRestaurantMutation(); async function handleFormSubmit(e) { e.preventDefault(); - try { - await addRestaurant({ category, name, description }); - onClose(); - } catch { - alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); - } + mutation.mutate( + { category, name, description }, + { + onSuccess: () => onClose(), + onError: () => alert("음식점 추가에 실패했습니다. 다시 시도해주세요."), + }, + ); } return ( diff --git a/src/components/RestaurantList.jsx b/src/components/RestaurantList.jsx index 1e4a969..cdd2534 100644 --- a/src/components/RestaurantList.jsx +++ b/src/components/RestaurantList.jsx @@ -1,30 +1,22 @@ import { CATEGORY_IMAGES } from "../constants/categoryImages.js"; import styled from "styled-components"; import { filterRestaurants } from "../utils/filterRestaurants.js"; -import useRestaurantStore from "../store/useRestaurantStore.js"; -import { useEffect } from "react"; +import { useRestaurantsQuery } from "../queries/useRestaurantsQuery.js"; export default function RestaurantList({ selectedCategory, onRestaurantClick, }) { - const restaurants = useRestaurantStore((state) => state.restaurants); - const isLoading = useRestaurantStore((state) => state.isLoading); - const error = useRestaurantStore((state) => state.error); - const fetchRestaurants = useRestaurantStore( - (state) => state.fetchRestaurants, + const { data: restaurants, isLoading, error } = useRestaurantsQuery(); + const filteredRestaurants = filterRestaurants( + restaurants ?? [], + selectedCategory, ); - const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); - - useEffect(() => { - fetchRestaurants(); - }, [fetchRestaurants]); - return ( <> {isLoading &&

불러오는 중...

} - {error &&

{error}

} + {error &&

{error.message}

} {filteredRestaurants.map((restaurant) => { return ( diff --git a/src/main.jsx b/src/main.jsx index 569fdf2..1bed3a5 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,9 +1,16 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.jsx"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; + +const queryClient = new QueryClient(); ReactDOM.createRoot(document.getElementById("root")).render( - - + + + + + , ); diff --git a/src/queries/useAddRestaurantMutation.js b/src/queries/useAddRestaurantMutation.js new file mode 100644 index 0000000..f2d2b3b --- /dev/null +++ b/src/queries/useAddRestaurantMutation.js @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createRestaurant } from "../api"; + +export function useAddRestaurantMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createRestaurant, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["restaurants"] }); + }, + }); +} diff --git a/src/queries/useRestaurantsQuery.js b/src/queries/useRestaurantsQuery.js new file mode 100644 index 0000000..326b40f --- /dev/null +++ b/src/queries/useRestaurantsQuery.js @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getRestaurants } from "../api"; + +export function useRestaurantsQuery() { + return useQuery({ + queryKey: ["restaurants"], + queryFn: getRestaurants, + }); +} From 57b16063eb4e29ba4fa6065dc4e36e97bee94868 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 18:39:43 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20Optimistic=20Update=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/queries/useAddRestaurantMutation.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/queries/useAddRestaurantMutation.js b/src/queries/useAddRestaurantMutation.js index f2d2b3b..11b4f68 100644 --- a/src/queries/useAddRestaurantMutation.js +++ b/src/queries/useAddRestaurantMutation.js @@ -6,7 +6,22 @@ export function useAddRestaurantMutation() { return useMutation({ mutationFn: createRestaurant, - onSuccess: () => { + onMutate: async (newRestaurant) => { + await queryClient.cancelQueries({ queryKey: ["restaurants"] }); + + const previousRestaurants = queryClient.getQueryData(["restaurants"]); + + queryClient.setQueryData(["restaurants"], (old) => [ + ...old, + { ...newRestaurant, id: crypto.randomUUID() }, + ]); + + return { previousRestaurants }; + }, + onError: (_err, _newRestaurant, context) => { + queryClient.setQueryData(["restaurants"], context.previousRestaurants); + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ["restaurants"] }); }, }); From 8cf29597cbcdd1882d7625eb70f3b1a0837b5984 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 19:04:59 +0900 Subject: [PATCH 6/9] =?UTF-8?q?docs:=20=EB=A6=AC=EB=93=9C=EB=AF=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 224 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 133 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 76b832a..f78a66a 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,197 @@ -# Zustand를 사용해서 전역 상태 관리하기 +# TanStack Query를 사용해서 서버 상태 관리하기 ## 🎯 개인 목표 및 목표 달성을 위한 행동 가이드 이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 한다. -1. Context API로 구현된 앱을 Zustand로 마이그레이션하면서 두 방식의 차이를 체감한다. -2. `create`, `set`, `get`, selector 개념을 직접 사용하며 Zustand 스토어 구조를 익힌다. -3. 전역 상태로 관리할 것과 로컬 상태로 유지할 것을 스스로 판단하는 능력을 기른다. +1. 서버 상태와 클라이언트 상태를 구분하고, TanStack Query로 서버 상태를 분리한다. +2. `useQuery`, `useMutation`, `QueryClient` 개념을 직접 사용하며 TanStack Query 구조를 익힌다. +3. Optimistic Update를 구현하며 UX와 데이터 정합성 사이의 트레이드오프를 체감한다. --- ## 📝 기능 구현 목록 -- [x] `useRestaurantStore` 생성 — `restaurants`, `addRestaurant`, `isLoading`, `error`, `fetchRestaurants` 포함 -- [x] `RestaurantList`에서 selector로 스토어 구독, `useEffect`로 초기 데이터 fetch -- [x] `AddRestaurantModal`에서 스토어의 `addRestaurant` 직접 호출 -- [x] `App.jsx`에서 `RestaurantsProvider` 제거 -- [x] `selectedCategory`를 스토어로 이동 및 `persist` 미들웨어로 새로고침 후에도 유지 +- [x] `@tanstack/react-query` 설치 및 `QueryClient` + `QueryClientProvider` 설정 +- [x] `useRestaurantsQuery` 생성 — `useQuery`로 음식점 목록 조회 +- [x] `useAddRestaurantMutation` 생성 — `useMutation`으로 음식점 추가 +- [x] `RestaurantList`에서 `useRestaurantsQuery`로 교체 +- [x] `AddRestaurantModal`에서 `useAddRestaurantMutation`으로 교체 +- [x] `useRestaurantStore`를 `useFilterStore`로 분리 — `selectedCategory`만 관리 +- [x] Optimistic Update 적용 — 요청 즉시 UI 반영, 실패 시 롤백 +- [x] TanStack Query Devtools 추가 --- ## 📚 학습 내용 -### Zustand 핵심 개념 +### 서버 상태 vs 클라이언트 상태 + +| 구분 | 예시 | 특징 | +|---|---|---| +| 서버 상태 | `restaurants` | 서버가 소유, 다른 사용자가 변경 가능 | +| 클라이언트 상태 | `selectedCategory`, 모달 열림/닫힘 | 내 앱이 소유, 내가 바꿀 때만 바뀜 | + +Zustand로 `restaurants`를 관리하면 내 앱이 마지막으로 가져온 스냅샷을 들고 있는 것이다. 그 사이 다른 사용자가 추가한 음식점은 알 수 없다. TanStack Query는 캐싱, 자동 갱신, 로딩/에러 상태를 자동으로 관리해준다. + +### TanStack Query 핵심 개념 | 개념 | 설명 | |---|---| -| `create` | 스토어를 생성한다. 반환값이 훅이라 `useRestaurantStore()`로 바로 사용한다. | -| `set` | 상태를 업데이트한다. 얕은 병합(shallow merge)이라 바꾸지 않는 필드는 그대로 유지된다. | -| `get` | 액션 안에서 현재 스토어 상태를 읽거나 다른 액션을 호출할 때 사용한다. | -| selector | `useStore((state) => state.xxx)` 형태로 필요한 상태만 구독한다. 해당 값이 바뀔 때만 리렌더링된다. | +| `QueryClient` | 캐시 저장소. 앱 전체에서 하나만 생성한다. | +| `QueryKey` | 캐시를 식별하는 고유 키. 같은 키면 같은 캐시를 공유한다. | +| `useQuery` | 데이터 조회. `queryFn`이 반환한 데이터를 캐시에 저장한다. | +| `useMutation` | 데이터 변경(POST, PUT, DELETE). `onSuccess`, `onError`, `onSettled` 콜백을 제공한다. | ```js -const useRestaurantStore = create((set, get) => ({ - // 상태 - restaurants: [], - isLoading: false, - error: null, - - // 액션 - fetchRestaurants: async () => { - set({ isLoading: true, error: null }); - try { - const data = await getRestaurants(); - set({ restaurants: data }); - } catch { - set({ error: "음식점 목록을 불러오지 못했습니다." }); - } finally { - set({ isLoading: false }); - } - }, - addRestaurant: async (restaurant) => { - await createRestaurant(restaurant); - await get().fetchRestaurants(); // 다른 액션 호출 - }, -})); +// useRestaurantsQuery.js +export function useRestaurantsQuery() { + return useQuery({ + queryKey: ["restaurants"], + queryFn: getRestaurants, + }); +} + +// useAddRestaurantMutation.js +export function useAddRestaurantMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createRestaurant, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["restaurants"] }); + }, + }); +} ``` -### persist 미들웨어 +`useQuery`를 커스텀 훅으로 감싼 이유는 `queryKey`를 한 곳에서 관리하기 위해서다. `invalidateQueries`에서도 같은 키를 써야 하므로, 여러 곳에 문자열이 흩어지면 키가 바뀔 때 모든 곳을 찾아야 한다. -스토어의 상태를 localStorage에 자동으로 저장/복원해주는 Zustand 내장 미들웨어다. `partialize`로 저장할 상태만 선택할 수 있다. +### Zustand와의 역할 분리 + +TanStack Query 도입 후 `useRestaurantStore`에서 서버 상태(`restaurants`, `isLoading`, `error`, `fetchRestaurants`, `addRestaurant`)를 모두 제거했다. `selectedCategory`는 클라이언트 상태이므로 `useFilterStore`로 분리해서 유지했다. ```js -const useRestaurantStore = create( +// useFilterStore.js — selectedCategory만 관리 +const useFilterStore = create( persist( - (set, get) => ({ ... }), + (set) => ({ + selectedCategory: ALL_CATEGORY, + setSelectedCategory: (category) => set({ selectedCategory: category }), + }), { - name: "restaurant-storage", - partialize: (state) => ({ selectedCategory: state.selectedCategory }), - } - ) + name: "category-filter", + storage: createJSONStorage(() => sessionStorage), + }, + ), ); ``` -`restaurants`는 서버에서 매번 가져오므로 저장할 필요가 없고, `selectedCategory`만 persist 대상으로 지정했다. +`sessionStorage`를 선택한 이유는 카테고리 필터가 "브라우저를 닫아도 기억해야 하는 설정"이 아니라 "현재 탐색 중인 필터 상태"에 가깝기 때문이다. `localStorage`를 쓰면 오래된 필터 상태가 계속 남아있게 된다. + +### Optimistic Update + +서버 응답을 기다리지 않고 성공했다고 가정하고 UI를 먼저 업데이트하는 패턴이다. + +```js +return useMutation({ + mutationFn: createRestaurant, + onMutate: async (newRestaurant) => { + // 1. 진행 중인 refetch 취소 (낙관적 업데이트를 덮어쓰지 않도록) + await queryClient.cancelQueries({ queryKey: ["restaurants"] }); + // 2. 현재 캐시 저장 (실패 시 롤백용) + const previousRestaurants = queryClient.getQueryData(["restaurants"]); + // 3. 캐시에 낙관적으로 추가 + queryClient.setQueryData(["restaurants"], (old) => [ + ...old, + { ...newRestaurant, id: crypto.randomUUID() }, + ]); + return { previousRestaurants }; + }, + onError: (_err, _newRestaurant, context) => { + queryClient.setQueryData(["restaurants"], context.previousRestaurants); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["restaurants"] }); + }, +}); +``` + +`onMutate`의 반환값이 `onError`의 `context`로 전달된다. `onMutate`와 `onError`는 서로 다른 함수라 직접 변수를 공유할 수 없기 때문이다. + +`onSuccess` 대신 `onSettled`를 쓴 이유는, 실패 후 롤백된 상태에서도 서버 데이터와 동기화가 필요하기 때문이다. `onSuccess`는 성공 시에만 실행되지만 `onSettled`는 성공/실패 상관없이 항상 실행된다. --- -## 🤔 Zustand를 왜 사용하는가 — Context API와 비교 +## 🤔 TanStack Query를 왜 사용하는가 — Zustand와 비교 -### Context API는 전역 상태 관리 도구가 아니다 +### Zustand로 서버 상태를 관리할 때의 한계 -Context API는 원래 **prop drilling 해결 도구**다. 상태 관리를 하려면 `useState`/`useReducer`를 별도로 조합해야 하고, 그 결과물을 Provider로 감싸야 한다. 이번 마이그레이션에서 `useRestaurants` 훅 + `RestaurantsContext` + `useRestaurantsContext` 세 파일이 `useRestaurantStore` 하나로 줄어든 게 그 차이다. +Zustand에서 서버 데이터를 관리하려면 `isLoading`, `error`, `fetchRestaurants`를 직접 구현해야 한다. 또한 중복 요청 방지를 위한 guard 로직도 직접 작성해야 한다. TanStack Query는 이 모든 것을 자동으로 처리한다. ### 달랐던 점 -**Provider가 없다** +**`useEffect` + `fetchRestaurants`가 사라졌다** -Context는 ``로 트리를 감싸야 했지만, Zustand는 Provider 없이 어떤 컴포넌트에서든 스토어에 바로 접근한다. +Zustand에서는 컴포넌트 마운트 시 `useEffect`로 직접 fetch를 트리거했다. TanStack Query는 `useQuery`만 호출하면 자동으로 데이터를 가져온다. -**리렌더링 최적화** +**로딩/에러 상태를 직접 관리하지 않아도 된다** -Context는 value 안의 어떤 값이 바뀌어도 해당 Context를 구독하는 모든 컴포넌트가 리렌더링된다. Zustand는 selector로 필요한 상태만 구독하기 때문에, `restaurants`가 바뀌어도 `addRestaurant` 액션만 구독하는 컴포넌트는 리렌더링되지 않는다. +Zustand에서는 `isLoading`, `error`를 스토어에 직접 정의하고 `set`으로 관리했다. TanStack Query는 `useQuery`가 반환하는 `isLoading`, `error`를 그대로 쓰면 된다. -```js -// AddRestaurantModal — addRestaurant만 구독하므로 -// restaurants, isLoading, error가 바뀌어도 리렌더링되지 않는다 -const addRestaurant = useRestaurantStore((state) => state.addRestaurant); -``` +**캐싱이 자동이다** + +같은 `queryKey`로 여러 컴포넌트에서 `useQuery`를 호출해도 실제 서버 요청은 한 번만 간다. Zustand는 이런 중복 요청 방지를 직접 구현해야 한다. ### Trade-off -**Context가 나은 경우** +**TanStack Query가 불필요한 경우** -- 외부 라이브러리 없이 React만으로 해결 가능 -- 테마, 로케일처럼 변경이 거의 없는 정적 값은 Context가 오히려 적합 -- Provider 범위로 상태의 생명주기가 명확하게 제어되어야 할 때 +- 서버 상태가 없고 클라이언트 상태만 있는 앱 +- 데이터가 자주 바뀌지 않아 캐싱 이점이 없는 경우 -**Zustand의 단점** +**TanStack Query의 단점** -- 스토어가 전역이라 어디서든 접근 가능한 게 장점이지만, 반대로 상태가 어디서 변경되는지 추적하기 어려워질 수 있다 -- Context는 Provider 범위로 상태의 생명주기가 명확한 반면, Zustand 스토어는 앱 전체에서 살아있다 +- 학습 곡선이 있다. `queryKey`, `invalidateQueries`, `onMutate`, `onSettled` 등 새로운 개념을 익혀야 한다. +- 간단한 fetch에도 `QueryClient` 설정, 커스텀 훅 작성 등 초기 설정이 필요하다. --- ## 🤔 고민했던 문제와 해결 과정에서 배운 점 -### 무엇을 스토어에 넣을 것인가 +### 무엇을 TanStack Query로, 무엇을 Zustand로 관리할 것인가 -스토어에 모든 상태를 넣는 게 아니라, **전역 상태가 필요한 조건**을 기준으로 판단했다. +| 구분 | 상태 | 도구 | 이유 | +|---|---|---|---| +| 서버 상태 | `restaurants` | TanStack Query | 서버가 소유, 캐싱/동기화 필요 | +| 클라이언트 상태 (persist) | `selectedCategory` | Zustand | UI 상태이지만 새로고침 후 유지 필요 | +| 로컬 상태 | `clickedRestaurant`, `isAddRestaurantModalOpen` | useState | 해당 컴포넌트에서만 쓰이는 인터랙션 상태 | -| 구분 | 상태 | 이유 | -|---|---|---| -| 스토어 (데이터 도메인) | `restaurants`, `addRestaurant`, `isLoading`, `error` | 여러 컴포넌트에서 공유되는 서버 데이터 | -| 스토어 (persist 목적) | `selectedCategory` | UI 상태이지만 새로고침 후 유지를 위해 스토어로 이동 | -| 로컬 state (UI 상태) | `clickedRestaurant`, `isAddRestaurantModalOpen` | 해당 컴포넌트에서만 쓰이는 인터랙션 상태 | - -`selectedCategory`는 본래 UI 상태이므로 `useState`가 자연스럽다. 다만 새로고침 후 유지(`persist`)는 Zustand 스토어에만 적용할 수 있기 때문에 기술적인 이유로 스토어로 이동했다. 이 판단은 목적이 명확하므로 정당하지만, persist 요구사항이 없었다면 로컬 state로 유지하는 것이 맞다. +### mutate 콜백 역할 분리 -### fetchRestaurants 호출 위치 - -Zustand 스토어는 React 컴포넌트가 아니라 `useEffect`를 쓸 수 없다. 초기 데이터 fetch는 데이터를 보여주는 컴포넌트(`RestaurantList`)가 마운트될 때 `useEffect`로 호출하는 방식으로 해결했다. +`useMutation`의 `onSettled`에서 캐시 무효화를 처리하고, `mutate` 호출 시 넘기는 콜백에서 UI 처리(모달 닫기, 에러 알림)를 담당했다. ```js -const fetchRestaurants = useRestaurantStore((state) => state.fetchRestaurants); +// useAddRestaurantMutation.js — 서버 상태 동기화 +onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["restaurants"] }); +} -useEffect(() => { - fetchRestaurants(); -}, [fetchRestaurants]); +// AddRestaurantModal.jsx — UI 처리 +mutation.mutate(data, { + onSuccess: () => onClose(), + onError: () => alert("음식점 추가에 실패했습니다. 다시 시도해주세요."), +}); ``` -### 이벤트 핸들러와 스토어 액션의 분리 +### Optimistic Update에서 어떤 상황에서 효과적인지 -스토어 액션은 순수한 값만 받도록 하고, 이벤트 객체 처리는 컴포넌트에 남겼다. +- 네트워크가 느린 환경에서 사용자가 즉각적인 피드백을 기대할 때 +- 서버 요청의 성공 가능성이 높아 롤백이 거의 발생하지 않을 때 +- 좋아요, 체크리스트처럼 사용자가 빠르게 반복 인터랙션하는 UI -```js -// 스토어 액션 — 값만 받는다 -setSelectedCategory: (category) => set({ selectedCategory: category }), +### Optimistic Update 주의해야 할 점 -// 컴포넌트 — 이벤트에서 값을 꺼내는 건 UI 로직 -function handleCategoryChange(e) { - setSelectedCategory(e.target.value); -} -``` +- 롤백 처리가 필수다. 실패 시 낙관적으로 반영한 UI를 반드시 이전 상태로 되돌려야 한다. +- 임시 ID를 사용하므로 `onSettled`에서 `invalidateQueries`로 서버 실제 데이터와 반드시 동기화해야 한다. +- 실패 후 롤백되는 경험(추가한 항목이 갑자기 사라짐)이 사용자에게 혼란을 줄 수 있으므로, 실패 가능성이 높은 요청에는 적합하지 않다. From 8a598a7c8d6c345b52b26bc6135b6969c7570c8d Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 19:22:03 +0900 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20TanStack=20Query=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20README=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index f78a66a..0278614 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,29 @@ return useMutation({ `onSuccess` 대신 `onSettled`를 쓴 이유는, 실패 후 롤백된 상태에서도 서버 데이터와 동기화가 필요하기 때문이다. `onSuccess`는 성공 시에만 실행되지만 `onSettled`는 성공/실패 상관없이 항상 실행된다. +### useOptimistic (React 19) + +React 19에서 정식 출시된 훅으로, `useMutation`의 `onMutate` 없이 낙관적 업데이트를 더 간결하게 구현할 수 있다. + +```js +const [optimisticRestaurants, addOptimistic] = useOptimistic( + restaurants, + (current, newItem) => [...current, newItem] +); +``` + +TanStack Query의 Optimistic Update와 비교하면: + +| | TanStack Query | useOptimistic | +|---|---|---| +| 업데이트 대상 | 캐시(`queryClient`) | 로컬 UI 상태 | +| 롤백 | `onError`에서 직접 처리 | 자동 (액션 완료/실패 시) | +| 최적 환경 | fetch 기반 REST API | Next.js Server Actions | + +`useOptimistic`은 Next.js Server Actions와 함께 쓸 때 빛을 발한다. Server Action이 완료되면 Next.js가 자동으로 페이지를 재검증하고, `useOptimistic`이 자동으로 임시 상태를 해제한다. 롤백 코드를 직접 짤 필요가 없다. + +반면 현재 미션처럼 별도 백엔드 API 서버와 fetch로 통신하는 구조에서는 TanStack Query의 Optimistic Update가 더 자연스럽다. 두 도구를 함께 섞으면 서로 다른 상태를 들고 있어서 오히려 복잡해진다. + --- ## 🤔 TanStack Query를 왜 사용하는가 — Zustand와 비교 From ceb344ed4259607be00d10bf2bfe88004f55dd85 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 21:03:43 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20Query=20Key=20Factory=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/queries/queryKeys.js | 3 +++ src/queries/useAddRestaurantMutation.js | 11 ++++++----- src/queries/useRestaurantsQuery.js | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 src/queries/queryKeys.js diff --git a/src/queries/queryKeys.js b/src/queries/queryKeys.js new file mode 100644 index 0000000..4fe55fb --- /dev/null +++ b/src/queries/queryKeys.js @@ -0,0 +1,3 @@ +export const restaurantKeys = { + all: () => ["restaurants"], +}; diff --git a/src/queries/useAddRestaurantMutation.js b/src/queries/useAddRestaurantMutation.js index 11b4f68..072aa2c 100644 --- a/src/queries/useAddRestaurantMutation.js +++ b/src/queries/useAddRestaurantMutation.js @@ -1,5 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createRestaurant } from "../api"; +import { restaurantKeys } from "./queryKeys"; export function useAddRestaurantMutation() { const queryClient = useQueryClient(); @@ -7,11 +8,11 @@ export function useAddRestaurantMutation() { return useMutation({ mutationFn: createRestaurant, onMutate: async (newRestaurant) => { - await queryClient.cancelQueries({ queryKey: ["restaurants"] }); + await queryClient.cancelQueries({ queryKey: restaurantKeys.all() }); - const previousRestaurants = queryClient.getQueryData(["restaurants"]); + const previousRestaurants = queryClient.getQueryData(restaurantKeys.all()); - queryClient.setQueryData(["restaurants"], (old) => [ + queryClient.setQueryData(restaurantKeys.all(), (old) => [ ...old, { ...newRestaurant, id: crypto.randomUUID() }, ]); @@ -19,10 +20,10 @@ export function useAddRestaurantMutation() { return { previousRestaurants }; }, onError: (_err, _newRestaurant, context) => { - queryClient.setQueryData(["restaurants"], context.previousRestaurants); + queryClient.setQueryData(restaurantKeys.all(), context.previousRestaurants); }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["restaurants"] }); + queryClient.invalidateQueries({ queryKey: restaurantKeys.all() }); }, }); } diff --git a/src/queries/useRestaurantsQuery.js b/src/queries/useRestaurantsQuery.js index 326b40f..d20ab00 100644 --- a/src/queries/useRestaurantsQuery.js +++ b/src/queries/useRestaurantsQuery.js @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { getRestaurants } from "../api"; +import { restaurantKeys } from "./queryKeys"; export function useRestaurantsQuery() { return useQuery({ - queryKey: ["restaurants"], + queryKey: restaurantKeys.all(), queryFn: getRestaurants, }); } From 4d2d5672dab440b4001d36b75e9c72e8429e3563 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sun, 28 Jun 2026 21:03:53 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20RestaurantList=20early=20return?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RestaurantList.jsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/RestaurantList.jsx b/src/components/RestaurantList.jsx index cdd2534..0c15bbb 100644 --- a/src/components/RestaurantList.jsx +++ b/src/components/RestaurantList.jsx @@ -8,15 +8,14 @@ export default function RestaurantList({ onRestaurantClick, }) { const { data: restaurants, isLoading, error } = useRestaurantsQuery(); - const filteredRestaurants = filterRestaurants( - restaurants ?? [], - selectedCategory, - ); + + if (isLoading) return

불러오는 중...

; + if (error) return

{error.message}

; + + const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); return ( <> - {isLoading &&

불러오는 중...

} - {error &&

{error.message}

} {filteredRestaurants.map((restaurant) => { return (