From 6e1ac4304afadc888727d6fcc9f7ad88e1bacd01 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 16:59:30 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20TanStack=20Query=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=20=EB=B0=8F=20QueryClientProvider=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 55 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ src/main.jsx | 7 +++++- 3 files changed, 63 insertions(+), 1 deletion(-) 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", diff --git a/src/main.jsx b/src/main.jsx index 1141dbf..d840327 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,9 +1,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.jsx"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); ReactDOM.createRoot(document.getElementById("root")).render( - + + + , ); From 50dc8c2a54559bc0c6736793e73d9b5644923462 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 17:19:28 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20RestaurantList=20useQuery?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RestaurantList/RestaurantList.jsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/RestaurantList/RestaurantList.jsx b/src/components/RestaurantList/RestaurantList.jsx index f8ed8dd..a8002da 100644 --- a/src/components/RestaurantList/RestaurantList.jsx +++ b/src/components/RestaurantList/RestaurantList.jsx @@ -1,21 +1,28 @@ import { CATEGORY_IMAGES } from "../../constants/categoryImages"; import styled from "styled-components"; import { textSubtitle, textBody } from "../../styles/typography"; -import useRestaurantStore from "../../store/useRestaurantStore"; import { ALL_CATEGORY } from "../../constants/categories"; +import { useQuery } from "@tanstack/react-query"; +import { getRestaurants } from "../../api"; export default function RestaurantList({ selectedCategory, onRestaurantClick, }) { - const newRestaurants = useRestaurantStore((state) => state.newRestaurants); - const isLoading = useRestaurantStore((state) => state.isLoading); - const error = useRestaurantStore((state) => state.error); + const { + data: newRestaurants, + isLoading, + error, + } = useQuery({ + queryKey: ["restaurants"], + queryFn: getRestaurants, + // queryFn: () => getRestaurants(인자가 있을 경우) + }); const filteredRestaurants = selectedCategory === ALL_CATEGORY - ? newRestaurants - : newRestaurants.filter((r) => r.category === selectedCategory); + ? (newRestaurants ?? []) + : (newRestaurants ?? []).filter((r) => r.category === selectedCategory); return ( From 7ead06554d6926383f44b0b483465f991af46762 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 17:39:52 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20AddRestaurantModal=20useMutation?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal/AddRestaurantModal.jsx | 31 +++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/Modal/AddRestaurantModal.jsx b/src/components/Modal/AddRestaurantModal.jsx index e58f9bd..4f1606a 100644 --- a/src/components/Modal/AddRestaurantModal.jsx +++ b/src/components/Modal/AddRestaurantModal.jsx @@ -3,27 +3,30 @@ import { useState } from "react"; import Modal from "./Modal"; import styled from "styled-components"; import { textCaption } from "../../styles/typography"; -import useRestaurantStore from "../../store/useRestaurantStore"; +import { addRestaurant } from "../../api"; +import { useQueryClient, useMutation } from "@tanstack/react-query"; export default function AddRestaurantModal({ onClose }) { - const registerRestaurant = useRestaurantStore((state) => state.registerRestaurant); + const queryClient = useQueryClient(); + + const { mutate } = useMutation({ + mutationFn: (newRestaurant) => addRestaurant(newRestaurant), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["restaurants"] }); + onClose(); + }, + onError: () => { + alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); + }, + }); + const [category, setCategory] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); - const handleSubmit = async (e) => { + const handleSubmit = (e) => { e.preventDefault(); - try { - await registerRestaurant({ - id: crypto.randomUUID(), - category, - name, - description, - }); - onClose(); - } catch { - alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); - } + mutate({ id: crypto.randomUUID(), category, name, description }); }; return ( From 8b920e993e2752fbe75727264d0a85c9bf27e54c Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 17:45:30 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20App.jsx=EC=97=90=EC=84=9C=20f?= =?UTF-8?q?etchRestaurants=20useEffect=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 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index d08b4ec..a2ab794 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,11 +1,10 @@ import "./App.css"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Header from "./components/Header/Header"; import CategoryFilter from "./components/CategoryFilter/CategoryFilter"; import RestaurantList from "./components/RestaurantList/RestaurantList"; import RestaurantDetailModal from "./components/Modal/RestaurantDetailModal"; import AddRestaurantModal from "./components/Modal/AddRestaurantModal"; -import useRestaurantStore from "./store/useRestaurantStore"; import useFilterStore from "./store/useFilterStore"; export default function App() { @@ -23,11 +22,6 @@ export default function App() { const handleAddModalOpen = () => setIsAddModalOpen(true); const handleAddModalClose = () => setIsAddModalOpen(false); - const fetchRestaurants = useRestaurantStore((state) => state.fetchRestaurants); - useEffect(() => { - fetchRestaurants(); - }, [fetchRestaurants]); - return ( <>
From f1714fa64fdd8f2bce64586408a4e88d76e5f514 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 17:47:25 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20useRestaurantStore=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/useRestaurantStore.js | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 src/store/useRestaurantStore.js diff --git a/src/store/useRestaurantStore.js b/src/store/useRestaurantStore.js deleted file mode 100644 index 87e5047..0000000 --- a/src/store/useRestaurantStore.js +++ /dev/null @@ -1,27 +0,0 @@ -import { create } from "zustand"; -import { getRestaurants, addRestaurant } from "../api"; - -const useRestaurantStore = create((set, get) => ({ - newRestaurants: [], - error: null, - isLoading: false, - - fetchRestaurants: async () => { - set({ isLoading: true, error: null }); - try { - const data = await getRestaurants(); - set({ newRestaurants: data }); - } catch { - set({ error: "음식점 목록을 불러오지 못했습니다." }); - } finally { - set({ isLoading: false }); - } - }, - - registerRestaurant: async (newRestaurant) => { - await addRestaurant(newRestaurant); - await get().fetchRestaurants(); - }, -})); - -export default useRestaurantStore; From 99080d555d04b5913279d0b856393668cf5658ed Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 18:35:00 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20AddRestaurantModal=20=EB=82=99?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal/AddRestaurantModal.jsx | 22 +++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/Modal/AddRestaurantModal.jsx b/src/components/Modal/AddRestaurantModal.jsx index 4f1606a..1b19b82 100644 --- a/src/components/Modal/AddRestaurantModal.jsx +++ b/src/components/Modal/AddRestaurantModal.jsx @@ -11,13 +11,27 @@ export default function AddRestaurantModal({ onClose }) { const { mutate } = useMutation({ mutationFn: (newRestaurant) => addRestaurant(newRestaurant), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["restaurants"] }); - onClose(); + onMutate: async (newRestaurant) => { + // 1. 진행 중인 refetch 취소 + await queryClient.cancelQueries({ queryKey: ["restaurants"] }); + // 2. 현재 캐시 저장 (롤백용) + const previous = queryClient.getQueryData(["restaurants"]); + // 3. 캐시에 새 식당 먼저 추가 + queryClient.setQueryData(["restaurants"], (old) => [ + ...old, + newRestaurant, + ]); + onClose(); // 모달 즉시 닫기 + + return { previous }; // onError로 넘김 }, - onError: () => { + onError: (err, _, context) => { + queryClient.setQueryData(["restaurants"], context.previous); alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["restaurants"] }); + }, }); const [category, setCategory] = useState(""); From 47f75be282527bab27ae54ab02e71ba9d7ba3da5 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 18:44:47 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20TanStack=20Query=20Devtools=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main.jsx b/src/main.jsx index d840327..b7d57e5 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,6 +2,7 @@ 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(); @@ -9,6 +10,7 @@ ReactDOM.createRoot(document.getElementById("root")).render( + , ); From 815e57e5bd7261a04dc969502a0048f7a4f73bcd Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 19:43:02 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20=EA=B3=BC=EA=B1=B0=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?(QUERY=5FKEY=20=EC=83=81=EC=88=98=ED=99=94,=20optimistic=20ID,?= =?UTF-8?q?=20onError=20=EB=B0=A9=EC=96=B4,=20early=20return)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal/AddRestaurantModal.jsx | 31 ++++++++++--------- .../RestaurantList/RestaurantList.jsx | 18 +++++++---- src/constants/queryKeys.js | 1 + 3 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 src/constants/queryKeys.js diff --git a/src/components/Modal/AddRestaurantModal.jsx b/src/components/Modal/AddRestaurantModal.jsx index 1b19b82..b0eb56a 100644 --- a/src/components/Modal/AddRestaurantModal.jsx +++ b/src/components/Modal/AddRestaurantModal.jsx @@ -5,6 +5,7 @@ import styled from "styled-components"; import { textCaption } from "../../styles/typography"; import { addRestaurant } from "../../api"; import { useQueryClient, useMutation } from "@tanstack/react-query"; +import { RESTAURANTS_QUERY_KEY } from "../../constants/queryKeys"; export default function AddRestaurantModal({ onClose }) { const queryClient = useQueryClient(); @@ -12,25 +13,27 @@ export default function AddRestaurantModal({ onClose }) { const { mutate } = useMutation({ mutationFn: (newRestaurant) => addRestaurant(newRestaurant), onMutate: async (newRestaurant) => { - // 1. 진행 중인 refetch 취소 - await queryClient.cancelQueries({ queryKey: ["restaurants"] }); - // 2. 현재 캐시 저장 (롤백용) - const previous = queryClient.getQueryData(["restaurants"]); - // 3. 캐시에 새 식당 먼저 추가 - queryClient.setQueryData(["restaurants"], (old) => [ - ...old, - newRestaurant, - ]); - onClose(); // 모달 즉시 닫기 - - return { previous }; // onError로 넘김 + await queryClient.cancelQueries({ queryKey: RESTAURANTS_QUERY_KEY }); + const previous = queryClient.getQueryData(RESTAURANTS_QUERY_KEY); + const optimisticItem = { + id: `optimistic-${Date.now()}`, + ...newRestaurant, + }; + queryClient.setQueryData(RESTAURANTS_QUERY_KEY, (old) => { + const current = Array.isArray(old) ? old : []; + return [...current, optimisticItem]; + }); + onClose(); + return { previous }; }, onError: (err, _, context) => { - queryClient.setQueryData(["restaurants"], context.previous); + if (context?.previous) { + queryClient.setQueryData(RESTAURANTS_QUERY_KEY, context.previous); + } alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["restaurants"] }); + queryClient.invalidateQueries({ queryKey: RESTAURANTS_QUERY_KEY }); }, }); diff --git a/src/components/RestaurantList/RestaurantList.jsx b/src/components/RestaurantList/RestaurantList.jsx index a8002da..33707b4 100644 --- a/src/components/RestaurantList/RestaurantList.jsx +++ b/src/components/RestaurantList/RestaurantList.jsx @@ -4,6 +4,7 @@ import { textSubtitle, textBody } from "../../styles/typography"; import { ALL_CATEGORY } from "../../constants/categories"; import { useQuery } from "@tanstack/react-query"; import { getRestaurants } from "../../api"; +import { RESTAURANTS_QUERY_KEY } from "../../constants/queryKeys"; export default function RestaurantList({ selectedCategory, @@ -14,20 +15,20 @@ export default function RestaurantList({ isLoading, error, } = useQuery({ - queryKey: ["restaurants"], + queryKey: RESTAURANTS_QUERY_KEY, queryFn: getRestaurants, - // queryFn: () => getRestaurants(인자가 있을 경우) }); + if (isLoading) return 로딩중입니다.; + if (error) return {error.message}; + const filteredRestaurants = selectedCategory === ALL_CATEGORY - ? (newRestaurants ?? []) - : (newRestaurants ?? []).filter((r) => r.category === selectedCategory); + ? newRestaurants + : newRestaurants.filter((r) => r.category === selectedCategory); return ( - {isLoading &&

로딩중입니다.

} - {error &&

{error}

} {filteredRestaurants.map((restaurant) => ( @@ -52,6 +53,11 @@ export default function RestaurantList({ ); } +const StatusText = styled.p` + padding: 16px 8px; + color: var(--grey-300); +`; + const ListContainer = styled.section` display: flex; flex-direction: column; diff --git a/src/constants/queryKeys.js b/src/constants/queryKeys.js new file mode 100644 index 0000000..1cd82a7 --- /dev/null +++ b/src/constants/queryKeys.js @@ -0,0 +1 @@ +export const RESTAURANTS_QUERY_KEY = ["restaurants"]; From 8674c36d8c89be9255db6e6bae734058231b67de Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 19:43:18 +0900 Subject: [PATCH 09/11] =?UTF-8?q?docs:=20adv-2.3=20README=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 405 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 242 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 8ba2dc5..f9946e0 100644 --- a/README.md +++ b/README.md @@ -1,269 +1,348 @@ -# 전역상태관리 - Zustand +# 서버상태관리 - TanStack Query ## 🎯 개인 목표 및 목표 달성을 위한 행동 가이드 -- Zustand의 핵심 개념(`create`, `set`, `get`, selector)을 직접 마이그레이션하며 손에 익힌다. -- Context API와 Zustand가 각각 어떤 문제를 해결하는지, 언제 어떤 도구를 쓸지 판단 기준을 세운다. -- 단순히 문법 변환에 그치지 않고, 두 도구의 구조적 차이가 왜 생기는지 설명할 수 있는 수준으로 이해한다. +- 서버 상태와 클라이언트 상태를 명확히 구분하고, 각각에 맞는 도구를 선택하는 기준을 세운다. +- `useQuery`, `useMutation`을 직접 마이그레이션하며 TanStack Query의 핵심 동작 방식을 손에 익힌다. +- 낙관적 업데이트를 구현하며 UX와 데이터 정합성 사이의 트레이드오프를 이해한다. ## 📝 기능 구현 목록 -- `useRestaurantStore` 생성 — `newRestaurants`, `isLoading`, `error`, `fetchRestaurants`, `registerRestaurant` -- Context, Provider, `useRestaurantContext`, `useRestaurants` 제거 -- `main.jsx` — Provider 제거 -- `RestaurantList`, `AddRestaurantModal` — selector로 필요한 상태만 구독 -- `App.jsx` — `useEffect`로 초기 데이터 fetch -- (선택) `useFilterStore` 생성 — `persist` 미들웨어로 카테고리 필터 새로고침 후 유지 +- TanStack Query 설치 및 `QueryClientProvider` 설정 +- `RestaurantList` — `useQuery`로 서버 데이터 조회 +- `AddRestaurantModal` — `useMutation` + 낙관적 업데이트 적용 +- `App.jsx` — `fetchRestaurants` `useEffect` 제거 +- `useRestaurantStore` 제거 +- TanStack Query Devtools 추가 ## 📚 학습 내용 -### 1. Zustand 기본 개념 +### 1. 서버 상태 vs 클라이언트 상태 -`create`로 store를 만들고, `set`으로 상태를 업데이트하고, 컴포넌트에서 selector로 필요한 값만 꺼낸다. +| | 클라이언트 상태 | 서버 상태 | +| --- | --- | --- | +| 예시 | 모달 열림, 선택된 카테고리 | 식당 목록, 유저 정보 | +| 소유자 | 내 앱 | 서버 | +| 특징 | 내가 바꾸면 바뀜 | 다른 사용자가 바꿀 수 있음 | +| 관리 도구 | useState, Zustand | TanStack Query | + +Zustand로 `fetchRestaurants`를 관리한 건 클라이언트 상태 도구로 서버 상태를 억지로 다룬 것이다. TanStack Query는 서버 상태만을 위해 만들어진 도구다. + +### 2. QueryClient + +TanStack Query의 캐시 저장소. `new QueryClient()`로 인스턴스를 한 번 생성하고, `QueryClientProvider`로 앱 전체에 공유한다. 컴포넌트에서는 `useQueryClient()` 훅으로 그 인스턴스를 참조한다. + +| 메서드 | 역할 | +| --- | --- | +| `cancelQueries` | 진행 중인 fetch 요청 취소 | +| `getQueryData` | 현재 캐시에서 데이터 읽기 | +| `setQueryData` | 캐시 데이터를 직접 덮어쓰기 | +| `invalidateQueries` | 캐시를 낡았다고 표시 → 자동 refetch | + +### 3. Query Key + +캐시의 주소. 어떤 데이터인지 식별하는 배열이다. ```js -const useRestaurantStore = create((set, get) => ({ - // 상태 — 초기값이 있음 - restaurants: [], - isLoading: false, - - // 액션 — 함수, 초기값 없음 - fetchRestaurants: async () => { - const data = await getRestaurants(); - set({ restaurants: data }); - }, -})); - -// 컴포넌트에서 — selector로 필요한 것만 구독 -const restaurants = useRestaurantStore((state) => state.restaurants); +["restaurants"] // 식당 전체 목록 +["restaurants", id] // 특정 식당 +["restaurants", { category: "한식" }] // 필터된 목록 ``` -`set`에는 객체를 직접 넘기거나, 이전 상태를 기반으로 업데이트할 때는 함수를 넘긴다. +key가 같으면 같은 캐시를 본다. `invalidateQueries`에서 같은 key를 써야 캐시 무효화가 정확히 일어난다. + +### 4. useQuery + +데이터 읽기(GET). `queryFn`이 반환하는 값이 `data`에 들어온다. `queryKey`는 캐시 이름표일 뿐 `data`와 무관하다. ```js -// 객체 전달 — 단순 교체 -set({ isLoading: true }); +const { data, isLoading, error } = useQuery({ + queryKey: ["restaurants"], + queryFn: getRestaurants, +}); +``` -// 함수 전달 — 이전 상태를 참조해야 할 때 -set((state) => ({ restaurants: [...state.restaurants, newOne] })); -// state가 현재 store 전체 상태. get()으로도 같은 걸 할 수 있지만 이 방식이 더 일반적 +Zustand에서 직접 짰던 `isLoading`, `error`, try/catch를 자동으로 처리한다. 컴포넌트가 마운트되면 자동으로 fetch를 실행하므로 `App.jsx`의 `useEffect` 트리거가 필요 없어진다. + +### 5. useMutation + +데이터 변경(POST/PUT/DELETE). `mutate`는 요청을 보내는 트리거이고, 성공/실패 처리는 콜백 옵션으로 위임한다. + +``` +mutate({ ... }) → 서버에 요청을 보내는 트리거 + +onMutate: () => {} → 요청 보내기 직전 실행 +onError: () => {} → 요청 실패 시 실행 +onSettled: () => {} → 성공/실패 상관없이 끝나면 실행 ``` -`get`은 액션 안에서 현재 상태를 읽어야 할 때 쓴다. 컴포넌트가 아닌 곳에서는 훅을 호출할 수 없어서 별도로 제공된다. +`invalidateQueries`로 캐시를 무효화하면 `useQuery`가 자동으로 최신 데이터를 다시 가져온다. + +### 6. 낙관적 업데이트 + +서버 응답을 기다리지 않고 UI를 먼저 바꾸는 것. 성공할 거라고 낙관하고, 실패하면 롤백한다. + +``` +일반: 클릭 → 요청 → (대기) → 응답 → UI 업데이트 +낙관적 업데이트: 클릭 → UI 먼저 업데이트 → 요청 → 실패하면 롤백 +``` ```js -registerRestaurant: async (newRestaurant) => { - await addRestaurant(newRestaurant); - await get().fetchRestaurants(); // 다른 액션 호출 +onMutate: async (newRestaurant) => { + await queryClient.cancelQueries({ queryKey: ["restaurants"] }); // 진행 중인 refetch 취소 + const previous = queryClient.getQueryData(["restaurants"]); // 롤백용 현재 캐시 저장 + queryClient.setQueryData(["restaurants"], (old) => [...old, newRestaurant]); // 캐시 먼저 업데이트 + onClose(); + return { previous }; +}, +onError: (err, _, context) => { + queryClient.setQueryData(["restaurants"], context.previous); // 롤백 +}, +onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["restaurants"] }); // 서버 데이터로 최종 동기화 }, ``` -### 2. Context API vs Zustand +### 7. "여러 사용자, 주기적 업데이트 환경"에서 달라지는 것 -| | Context API | Zustand | -| -------------- | ----------------------------------------------------- | -------------------------------- | -| Provider | 필요 | 불필요 | -| 리렌더링 | value 전체가 바뀌면 구독 컴포넌트 전부 | selector로 구독한 값이 바뀔 때만 | -| 상태 위치 | 컴포넌트 트리 안 (`useState`가 실제 상태를 관리) | 컴포넌트 트리 바깥 (모듈 스코프) | -| 보일러플레이트 | `createContext` + Provider + `useContext` + 커스텀 훅 | `create` 하나 | +Zustand에서는 `fetchRestaurants`가 마운트 시 한 번만 실행된다. 다른 사용자가 식당을 추가해도 내 화면은 새로고침 전까지 변하지 않는다. -Context API는 "데이터를 트리에 흘려보내는 통로"다. 상태 자체는 Provider 안의 `useState`가 들고 있고, Context는 그 값을 아래로 전달하는 역할만 한다. 전역 상태 관리 라이브러리가 아니라 React 내장 데이터 전달 메커니즘이다. +TanStack Query는 기본적으로 탭 포커스, 네트워크 재연결 시 자동으로 최신 데이터를 가져온다(`refetchOnWindowFocus: true`). `refetchInterval`을 추가하면 주기적 폴링도 가능하다. -Zustand는 상태 저장, 업데이트, 구독을 모두 자체적으로 처리하는 독립적인 상태 컨테이너다. +## 🤔 고민했던 문제와 해결 과정에서 배운 점 -### 3. store 안에서 React 훅을 쓸 수 없는 이유 +### 1. Zustand store 파일이 사라지고 어디서 상태를 관리하는가 -React 훅(`useState`, `useEffect`, `useCallback`)은 React 함수 컴포넌트 또는 커스텀 훅 안에서만 호출 가능하다. Zustand의 `create` 콜백은 그냥 일반 JS 함수라 훅을 쓰면 에러가 난다. +Zustand는 상태와 액션을 store 파일에 모아두는 구조였다. TanStack Query로 전환하면서 store 파일이 없어지니, 데이터를 어디서 관리하는지 처음에 명확하지 않았다. -이 때문에 구조가 나뉜다. +`QueryClient`가 캐시 저장소 역할을 대신하고, 각 컴포넌트에서 `useQuery`/`useMutation`을 직접 쓰면 된다. `useRestaurantStore`에 있던 것들이 전부 서버 상태였기 때문에 파일 자체가 사라지는 것이 맞다. 클라이언트 상태(`clickedRestaurant`, `isAddModalOpen`)는 `useState`로, 카테고리 필터는 Zustand persist로 그대로 유지했다. -| 역할 | 위치 | -| -------------------- | ----------------------------- | -| 상태 + 액션 로직 | store (무엇을, 어떻게) | -| 언제 실행할지 타이밍 | 컴포넌트의 `useEffect` (언제) | +### 2. queryFn에 함수 참조를 넘기는 것과 호출 결과를 넘기는 것의 차이 -`fetchRestaurants`를 App.jsx에서 `useEffect`로 트리거하는 것도, `useCallback`이 store 액션에 필요 없는 것도 이 구조 때문이다. 컴포넌트 안의 함수는 렌더링마다 새로 만들어지지만, store 액션은 `create`가 실행될 때 딱 한 번 만들어지고 참조가 바뀌지 않는다. +```js +queryFn: getRestaurants // 함수 자체를 넘김 — TanStack Query가 내부적으로 호출 +queryFn: () => getRestaurants() // 화살표 함수로 감싸서 호출 +``` -### 4. create가 한 번만 실행되는 이유 +둘의 결과는 동일하다. 인자를 넘겨야 할 때는 `() => getRestaurants(param)` 형태가 필요하고, 그렇지 않으면 `queryFn: getRestaurants`가 더 간결하다. -JS 모듈 시스템은 같은 파일을 여러 번 `import`해도 처음 한 번만 실행하고 결과를 캐싱한다. `create()`도 앱이 시작될 때 딱 한 번 실행되고, 내부에 상태와 구독자 목록을 담은 객체가 만들어진다. 이후 컴포넌트가 렌더링될 때마다 store가 새로 만들어지는 게 아니라, 같은 객체를 계속 참조한다. +### 3. 로딩 중 data가 undefined일 때의 처리 -컴포넌트가 `useRestaurantStore((state) => state.xxx)`를 호출하면 "이 값이 바뀌면 나를 리렌더링해달라"고 store에 등록한다. `set()`이 호출되면 store가 등록된 컴포넌트들에게 알리고, selector로 선택한 값이 바뀐 컴포넌트만 리렌더링된다. +`useQuery`는 fetch가 완료되기 전까지 `data`가 `undefined`다. 바로 `.filter()`를 호출하면 에러가 난다. `??`로 방어한다. -store 자체는 컴포넌트 트리 바깥에 있어서 컴포넌트가 마운트/언마운트돼도 상태가 유지된다. +```js +(newRestaurants ?? []).filter((r) => r.category === selectedCategory) +// undefined이면 빈 배열, 값이 있으면 그대로 사용 +``` -### 5. persist 미들웨어 — 영속화 vs 캐싱 +### 4. mutate에 await를 쓸 수 없는 이유와 onMutate/onError/onSettled의 역할 -`persist`는 store 상태를 localStorage에 자동으로 저장하고 복원한다. `create`와 상태 정의 사이에 끼어드는 미들웨어 형태다. +Zustand 액션은 `async` 함수에 `await`를 쓸 수 있어서, `useMutation`의 `mutate`도 같은 방식으로 다루려 했다. 하지만 `mutate`는 Promise를 반환하지 않아서 `await`이 동작하지 않고, `try/catch`도 잡히지 않는다. -```js -const useFilterStore = create( - persist( - // create(상태정의) → create(persist(상태정의, 옵션)) - (set) => ({ - selectedCategory: ALL_CATEGORY, - setSelectedCategory: (category) => set({ selectedCategory: category }), - }), - { name: "self-paced-react-category" }, // localStorage 키 이름 - ), -); -``` +성공/실패 처리는 `useMutation`의 콜백 옵션으로 위임하는 것이 올바른 방식이다. 각 콜백은 실행 시점이 다르다. -영속화와 캐싱은 다른 개념이다. +``` +onMutate → 요청 보내기 직전 (낙관적 업데이트, 모달 닫기) +onError → 요청 실패 시 (롤백, 에러 알림) +onSettled → 성공/실패 상관없이 끝나면 (서버 데이터로 최종 동기화) +``` -| | 영속화 | 캐싱 | -| ---- | ---------------------------------- | --------------------------------------- | -| 목적 | 사용자 설정/선택값 기억 (UX) | 서버 요청 비용 절감 (성능) | -| 예시 | 다크모드, 언어 설정, 카테고리 필터 | API 응답 재사용, 이미지 재다운로드 방지 | +`queryClient`의 메서드(`cancelQueries`, `getQueryData` 등)는 Promise를 반환하므로 `await` 가능하다. `mutate`와 혼동하지 않아야 한다. -Context API도 localStorage에 직접 저장하는 코드를 짜면 영속화가 가능하다. `persist` 미들웨어는 그 작업을 자동으로 처리해준다. +### 5. new QueryClient()와 useQueryClient()의 차이 -`persist`의 두 번째 인자 옵션에는 `name`, `storage` 외에 `onRehydrateStorage`도 있다. 앱이 시작될 때 storage에서 값을 복원하는 시점에 실행되는 콜백으로, 복원된 값이 유효한지 검증하는 데 쓴다. +`main.jsx`에서 `useQueryClient()`를 쓰는 것처럼 보여서, `AddRestaurantModal`에서도 같은 것을 중복 선언하는 건지 헷갈렸다. -```js -persist( - (set) => ({ ... }), - { - name: "storage-key", - onRehydrateStorage: () => (state) => { - // 복원 완료 후 실행. state가 null이면 복원 실패 - if (!state) return; - if (!isValidCategory(state.selectedCategory)) { - state.setSelectedCategory(ALL_CATEGORY); - } - }, - } -) -``` +`new QueryClient()`는 인스턴스를 생성하는 것이고, `useQueryClient()`는 `QueryClientProvider`를 통해 공유된 그 인스턴스를 컴포넌트 안에서 참조하는 훅이다. Zustand의 `create()`로 store를 한 번 만들고, 컴포넌트에서 `useRestaurantStore()`로 참조하는 구조와 같은 원리다. -커링 구조(`() => (state) => {}`)인 이유는 바깥 함수가 복원 **시작 전**, 안쪽 함수가 복원 **완료 후**에 실행되기 때문이다. 복원된 `state`는 안쪽 함수에서만 접근할 수 있다. +### 6. cancelQueries에 await가 필요한 이유 -## 🤔 고민했던 문제와 해결 과정에서 배운 점 +낙관적 업데이트에서 `cancelQueries`가 필요한 이유는 이해했지만, 왜 `await`를 써야 하는지 처음에 명확하지 않았다. -### 1. Context → Zustand 마이그레이션 시 코드가 중복되는 건지 +취소 요청을 보내고 실제 취소가 완료될 때까지 기다려야 하기 때문이다. `await` 없이 바로 `setQueryData`로 넘어가면, 취소 안 된 refetch 응답이 나중에 도착해서 낙관적으로 업데이트한 캐시를 이전 값으로 덮어쓸 수 있다. -마이그레이션하면서 `useRestaurants` 훅의 코드를 store에 다시 써야 하는 건지 헷갈렸다. 결론은 중복 작성이 아니라 이동이다. `useRestaurants`에 있던 것들이 각자의 역할에 맞는 위치로 옮겨간 것이다. +### 7. setQueryData에 함수를 넘기는 문법 -| useRestaurants | Zustand store | -| -------------------------- | ------------------------------- | -| `useState([])` | 초기값 `newRestaurants: []` | -| `setNewRestaurants(data)` | `set({ newRestaurants: data })` | -| `fetchRestaurants` 함수 | 액션으로 이동 | -| `registerRestaurant` 함수 | 액션으로 이동 | -| `useEffect(() => fetch())` | App.jsx로 이동 | +`setQueryData`의 두 번째 인자로 함수를 넘기면 현재 캐시값을 인자로 받는다는 것을 처음에 몰랐다. -### 2. selectedCategory를 어디서 관리할지 +```js +queryClient.setQueryData(["restaurants"], (old) => [...old, newRestaurant]) +// old: 현재 캐시에 있는 식당 배열 +// 리턴값: 새로운 캐시값 (기존 배열 + 새 식당) +``` -`selectedCategory`는 원래 App.jsx 로컬 state였다. persist가 필요해지면서 store로 옮겨야 했는데, `useRestaurantStore`에 합칠지 `useFilterStore`로 분리할지 고민했다. +객체를 직접 넘길 수도 있지만, 이전 값을 참조해야 할 때는 함수 형태를 써야 한다. -서버 데이터(레스토랑 목록)와 UI 필터 상태(선택된 카테고리)는 관심사가 다르고, 합치면 persist 범위도 불필요하게 넓어진다. `useFilterStore`를 별도로 분리해 UI 상태만 영속화했다. +### 8. onError 콜백의 세 인자 -### 3. 전역 상태가 비대해지는 문제 — 도구가 아니라 기준의 문제 +`onError`가 세 개의 인자를 자동으로 받는다는 것을 처음에 몰랐고, 두 번째 인자를 `_`로 표시하는 이유도 이해가 안 됐다. -읽은 글에서 "팀원마다 전역 상태로 올릴 기준이 달라 결국 전역 상태가 비대해진다"는 얘기가 있었다. 이건 Zustand vs Context의 문제라기보다 팀 컨벤션의 문제다. 어떤 도구를 쓰든 기준이 없으면 전역 상태는 비대해진다. +```js +onError: (err, variables, context) => { + // err: 발생한 에러 객체 + // variables: mutate()에 넘긴 값 (여기서는 newRestaurant) + // context: onMutate가 return한 값 (여기서는 { previous }) +} +``` -| 전역 상태로 올릴 것 | 로컬/props로 둘 것 | -| ---------------------------------------- | --------------------------- | -| 부모-자식 관계 없는 여러 컴포넌트가 공유 | 한 컴포넌트만 씀 | -| 컴포넌트 트리를 벗어나도 유지돼야 함 | 공통 조상이 가까움 | -| 서버 데이터, 인증 정보 | UI 상태 (모달 열림, 선택값) | +`variables`가 필요 없을 때 `_`로 표시하는 것은 "이 자리 인자는 사용하지 않겠다"는 관례다. 자리를 건너뛸 수 없기 때문에 `_`로 명시해두는 것이다. -### 4. fetchRestaurants를 App.jsx에서 호출한 이유 +`_` 대신 `newRestaurant`로 써도 동작은 완전히 동일하다. `_`를 쓰는 이유는 해당 값을 사용하지 않는다는 의도를 명시적으로 표현하기 위해서다. `newRestaurant`로 쓰면 코드를 읽는 사람이 어디서 쓰이는지 찾아볼 수 있지만, `_`이면 바로 "사용하지 않는 인자"임을 알 수 있다. 실패 시 어떤 값을 추가하려다 실패했는지 에러 메시지에 담는 것처럼, 해당 값이 필요한 경우에는 명시적인 이름을 쓰면 된다. -초기 데이터 fetch를 `RestaurantList` 안 `useEffect`에서 할 수도 있고, `App.jsx`에서 할 수도 있다. co-location 원칙 기준으로는 데이터가 필요한 컴포넌트가 직접 선언하는 게 더 명시적이다. +### 9. 낙관적 업데이트에서 UI 반영과 서버 응답의 순서 -App.jsx에서 호출하는 방식을 선택한 이유는 두 가지다. +UI는 POST(201) 응답보다도 먼저 바뀐다. `onMutate`가 `mutationFn`보다 먼저 실행되고, `mutationFn` 안에서 실제 네트워크 요청이 일어나기 때문이다. -첫째, co-location의 장점이 가장 잘 드러나는 건 컴포넌트를 다른 곳에서 재사용할 수 있을 때다. `RestaurantList`는 이미 `useRestaurantData()`에 결합된 도메인 컴포넌트라 독립적으로 이동하는 상황이 없다. +``` +mutate 호출 + └─ onMutate → setQueryData → UI 즉시 반영 → 모달 닫힘 + └─ mutationFn → POST 요청 → 201 응답 대기 중... + ↓ (201 도착) + └─ onSettled → invalidateQueries → GET 요청 → 200 응답 → 캐시 최종 동기화 +``` -둘째, React Query는 중복 요청을 자동으로 처리해주지만, 순수 Zustand에서는 중복 fetch를 직접 관리해야 한다. `RestaurantList`에 fetch를 두면 마운트마다 호출되지 않도록 guard 로직을 별도로 추가해야 하는데, App에서 한 번만 호출하면 그 문제를 구조적으로 피할 수 있다. +### 10. useState 리렌더링 비용이 문제없는 이유 -React Query를 쓴다면 co-location이 더 자연스러운 선택이다. +React는 리렌더링과 실제 DOM 업데이트를 분리한다. `setState` 호출 시 Virtual DOM을 재계산하고, 이전 Virtual DOM과 비교(diffing)해서 실제로 바뀐 부분만 Real DOM에 반영한다. `clickedRestaurant`, `isAddModalOpen`처럼 사용자 액션에 반응하는 단순한 상태는 바뀔 때마다 리렌더링이 일어나는 게 맞고, 이 정도 빈도는 비용이 낮다. 리렌더링이 문제가 되는 건 무거운 계산이 있는 컴포넌트가 불필요하게 자주 리렌더링될 때다. ## 🛠 리팩토링 -### 1. 에러 상태 초기화 +### 1. QUERY_KEY 상수 파일 분리 -`fetchRestaurants` 시작 시 `error: null`을 함께 설정하지 않으면, 이전 실패 에러 메시지가 다음 성공 후에도 화면에 남는다. +`RestaurantList`와 `AddRestaurantModal` 두 곳에서 `["restaurants"]` 문자열을 직접 반복 사용하고 있었다. 문자열을 여러 곳에서 직접 쓰면 오타가 발생해도 런타임 에러가 나지 않아 캐시 미스매치를 찾기 어렵다. ```js -fetchRestaurants: async () => { - set({ isLoading: true, error: null }); - // ... +// src/constants/queryKeys.js +export const RESTAURANTS_QUERY_KEY = ["restaurants"]; +``` + +`categories.js`, `categoryImages.js` 같은 상수 파일이 이미 있는 구조라 한 줄짜리 상수도 파일로 분리하는 게 자연스러운 위치다. 두 컴포넌트가 같은 키를 참조하므로 키가 바뀌어도 한 곳만 수정하면 된다. + +### 2. 낙관적 업데이트 — optimistic ID 적용 + +기존에는 `mutate` 호출 시 `crypto.randomUUID()`로 생성한 ID를 넘겼다. 이 경우 캐시에 올라간 항목이 임시 데이터인지 서버에서 온 실제 데이터인지 구별이 안 됐다. + +```js +// 변경 전 — handleSubmit에서 ID 생성 +mutate({ id: crypto.randomUUID(), category, name, description }); + +// 변경 후 — onMutate에서 임시 ID로 캐시 항목 생성 +const optimisticItem = { + id: `optimistic-${Date.now()}`, + ...newRestaurant, }; ``` -### 2. localStorage 키 이름 구체화 +`optimistic-` 접두사를 붙이면 Devtools에서 임시 데이터임을 바로 확인할 수 있다. `onSettled`의 `invalidateQueries`가 실행되면 서버의 실제 ID로 교체된다. -`"categoryState"`처럼 일반적인 이름은 다른 앱과 충돌할 수 있다. `"self-paced-react-category"`로 프로젝트를 식별할 수 있게 변경했다. +### 3. setQueryData 방어 코드 추가 -### 3. sessionStorage로 전환 +기존 코드는 `old`가 항상 배열이라고 가정했다. 초기 로드 전이나 캐시가 비어있는 상태에서 `old`가 `undefined`이면 스프레드 연산자가 에러를 낸다. -`useFilterStore`에서 localStorage를 sessionStorage로 변경했다. 카테고리 필터는 브라우저를 닫아도 유지해야 할 설정값이 아니라 현재 탐색 중인 임시 상태이기 때문이다. +```js +// 변경 전 +queryClient.setQueryData(RESTAURANTS_QUERY_KEY, (old) => [...old, optimisticItem]); + +// 변경 후 +queryClient.setQueryData(RESTAURANTS_QUERY_KEY, (old) => { + const current = Array.isArray(old) ? old : []; + return [...current, optimisticItem]; +}); +``` -`persist`의 기본 storage는 localStorage다. sessionStorage로 바꾸려면 `storage` 옵션에 `createJSONStorage`를 넘긴다. `() => sessionStorage`처럼 함수로 감싸는 이유는 SSR 환경에서 `sessionStorage`가 없을 수 있어서 실제로 사용할 때 꺼내도록 하기 위함이다. +### 4. onError 롤백 방어 코드 추가 + +`onMutate` 자체가 예외를 던지면 `context`가 `undefined`가 되어 롤백 코드가 에러를 낸다. ```js -import { persist, createJSONStorage } from "zustand/middleware"; +// 변경 전 +onError: (err, _, context) => { + queryClient.setQueryData(RESTAURANTS_QUERY_KEY, context.previous); +}, -persist( - (set) => ({ ... }), - { - name: "self-paced-react-category", - storage: createJSONStorage(() => sessionStorage), +// 변경 후 +onError: (err, _, context) => { + if (context?.previous) { + queryClient.setQueryData(RESTAURANTS_QUERY_KEY, context.previous); } -) +}, ``` -### 4. useRestaurantData 커스텀 훅 추출 → 제거 +`context?.previous`로 `context`가 `undefined`이더라도 롤백 자체가 실패하지 않는다. + +### 5. isLoading/error 처리 — early return으로 전환 -컴포넌트가 `useRestaurantStore`를 직접 import하던 구조를 `useRestaurantData` 훅으로 감쌌다. store 구조가 바뀌어도 컴포넌트는 수정하지 않아도 되고, store에 접근하는 진입점이 한 곳으로 통일된다. +기존에는 인라인 조건부 렌더링으로 처리했다. ```js -// 변경 전 — 컴포넌트마다 store를 직접 참조 -const newRestaurants = useRestaurantStore((state) => state.newRestaurants); -const isLoading = useRestaurantStore((state) => state.isLoading); +// 변경 전 — 인라인 +return ( + + {isLoading &&

로딩중입니다.

} + {error &&

{error}

} + ... +
+); -// 변경 후 — 훅을 통해 접근 -const { newRestaurants, isLoading } = useRestaurantData(); +// 변경 후 — early return +if (isLoading) return 로딩중입니다.; +if (error) return {error.message}; ``` -훅 내부에서는 여전히 각 값을 개별 selector로 구독하고 있어서 리렌더링 최적화는 그대로 유지된다. 컴포넌트에서 구조분해로 받는 것처럼 보이지만, store 전체를 구독하는 것과는 다르다. - -그런데 각 컴포넌트가 실제로 사용하는 선택자가 달랐다. `RestaurantList`는 `newRestaurants`, `isLoading`, `error`를 쓰고, `AddRestaurantModal`은 `registerRestaurant`만 쓴다. 같은 선택자 묶음을 반복하는 게 아니라 그냥 모아두는 형태였고, 현재 규모에서는 추상화 레이어가 코드 추적 비용을 높인다고 판단해 다시 제거했다. +예외 상태를 먼저 처리하고 정상 흐름은 아래에 집중되어 가독성이 높아진다. early return 이후에는 `newRestaurants`가 반드시 존재하므로 `?? []` 방어 코드도 제거할 수 있다. ## 과거 코드와 비교 ### 달라진 점 -**커스텀 훅 래퍼 유무** +**useQuery/useMutation 위치** -과거 코드는 store selector를 컴포넌트에서 직접 쓰지 않고 `useRestaurantData`, `useRestaurantModal` 커스텀 훅으로 감쌌다. 현재 코드는 컴포넌트에서 store를 직접 참조한다. +과거 코드는 `useRestaurantData` 커스텀 훅 안에 `useQuery`와 `useMutation`을 모두 넣고, 클라이언트 상태(Zustand)와 서버 상태(TanStack Query)를 한 훅에서 같이 관리했다. 현재 코드는 `RestaurantList`와 `AddRestaurantModal`에서 각각 직접 사용한다. -컴포넌트가 store 구조를 알 필요 없이 훅만 import하면 되고, store 이름이나 구조가 바뀌어도 컴포넌트는 수정하지 않아도 되기 때문에 과거 방식이 더 나은 접근이라고 판단했다. 현재 방식은 store가 바뀌면 직접 import한 컴포넌트를 전부 찾아서 수정해야 한다. +훅으로 감싸는 방식은 서버 상태 접근 진입점을 통일해 구조 변경 시 컴포넌트를 수정하지 않아도 된다는 장점이 있다. 다만 이번 미션에서는 지난 미션(2.2)에서 `useRestaurantData`를 도입했다가 제거한 경험이 있다. 각 컴포넌트가 실제로 사용하는 값이 달라 같은 선택자를 반복하는 게 아니라 그냥 모아두는 형태가 됐고, 현재 규모에서는 추상화 레이어가 코드 추적 비용을 높인다고 판단했기 때문이다. 규모가 커져 여러 컴포넌트가 동일한 선택자 묶음을 반복하게 된다면 과거 방식이 더 적합하다. -**Store 분리 기준** +**QUERY_KEY 상수화** -과거 코드는 `useRestaurantStore`(서버 데이터 + 카테고리)와 `useRestaurantModalStore`(모달 UI 상태)로 나눴다. 현재 코드는 `useRestaurantStore`(서버 데이터)와 `useFilterStore`(카테고리), 모달은 App 로컬 state로 분리했다. +과거 코드는 `const QUERY_KEY = ["restaurants"]`로 상수를 분리해서 `useQuery`와 `invalidateQueries` 모두 이 상수를 참조했다. 현재 코드는 문자열을 직접 반복 사용한다. 문자열을 여러 곳에서 직접 쓰면 오타가 발생해도 런타임 에러가 나지 않아 캐시 미스매치를 찾기 어렵다. 과거 방식이 더 적합하다. -모달 열림/닫힘은 App 직계 자식에게만 영향을 주는 일시적인 UI 상태라 전역 store에 올릴 이유가 없고, 서버 데이터와 UI 필터 상태를 나누면 관심사 분리가 명확해지기 때문에 현재 방식이 더 나은 구조라고 판단했다. +**낙관적 업데이트 optimistic ID** -**sessionStorage vs localStorage** +과거 코드는 `onMutate`에서 캐시에 추가할 항목에 임시 ID를 부여했다. -현재 코드는 카테고리 필터를 localStorage에 저장한다. 과거 코드는 리뷰 피드백을 받고 sessionStorage로 변경했다. +```js +const optimisticItem = { + id: `optimistic-${Date.now()}`, + ...newRestaurant, +}; +``` -카테고리 필터는 "브라우저를 닫아도 기억해야 하는 설정"이 아니라 "현재 탐색 중인 필터 상태"에 가깝고, sessionStorage는 탭/브라우저를 닫으면 초기화되어 오래된 상태가 쌓이지 않기 때문에 이 경우에는 과거 방식(sessionStorage)이 더 적합하다고 판단했다. +현재 코드는 `mutate` 호출 시 `crypto.randomUUID()`로 ID를 생성해서 넘긴다. 임시 ID가 명확히 구별되면 Devtools 디버깅과 롤백 추적이 쉬워지므로, 과거 방식이 더 적합하다. -### 과거 코드에서 배운 점 +**onError 방어 코드** + +과거 코드는 `context?.previous`로 옵셔널 체이닝을 사용했다. `onMutate`가 예외를 던지면 `context`가 `undefined`가 되어 롤백 자체가 실패할 수 있기 때문이다. 현재 코드는 이 방어 처리가 없다. 낙관적 업데이트에서 롤백 실패는 데이터 불일치로 이어지므로 과거 방식이 더 안전하다. -**localStorage/sessionStorage에 저장된 값은 신뢰하지 않는다** +**mutate vs mutateAsync** -서버 데이터는 코드 로직으로만 바뀌지만, localStorage와 sessionStorage는 사용자가 브라우저 개발자 도구에서 직접 값을 수정하거나 이상한 값을 넣을 수 있다. `persist` 미들웨어는 저장/복원 과정의 기술적 오류(JSON 파싱 실패 등)는 처리해주지만, 저장된 값이 앱에서 허용하는 값인지까지는 검증하지 않는다. +과거 코드는 `mutateAsync`를 사용해 컴포넌트에서 `await`로 순차적인 흐름을 작성했다. 현재 코드는 `mutate`를 사용하고 모달 닫기를 `onMutate`에서 즉시 처리한다. 낙관적 업데이트의 핵심은 서버 응답을 기다리지 않는 것이므로, 이 미션 맥락에서는 현재 방식이 의도에 더 맞다. + +**isLoading/isError 처리 방식** + +과거 코드는 `RestaurantList`에서 early return으로 처리했다. + +```js +if (isLoading) return 불러오는 중...; +if (isError) return {error.message}; +``` + +현재 코드는 목록 위에 인라인으로 조건부 렌더링한다. early return은 예외 상태를 먼저 처리하고 정상 흐름은 아래에 집중시켜 가독성이 높으므로, 과거 방식이 더 적합하다. + +### 과거 코드에서 배운 점 -과거 코드는 `onRehydrateStorage` 옵션을 써서 sessionStorage에서 값을 꺼낸 직후 카테고리가 유효한 값인지 확인하고, 아니면 "전체"로 되돌리는 방어 로직을 추가했다. +**ErrorBoundary와 Suspense로 로딩/에러 처리 위임** -이번 미션에서는 구현하지 않았다. 카테고리 값이 오염되더라도 필터가 잘못 적용되는 수준이라 사용자 데이터나 보안에 영향이 없기 때문이다. 결제 정보나 인증 상태처럼 오염 시 심각한 문제가 생기는 값이라면 필수로 추가해야 한다. +리뷰에서 `isLoading`과 `isError`를 컴포넌트 내부에서 직접 처리하는 대신, `ErrorBoundary`나 `Suspense` 같은 컴포넌트에 위임하는 방법도 있다고 언급됐다. 이 방식을 쓰면 `RestaurantList`는 데이터 렌더링에만 집중하고, 로딩이나 에러 UI는 공통 컴포넌트에서 처리할 수 있어 구조적으로 더 깔끔해진다. 규모가 커질수록 고려할 수 있는 패턴이다. -**sessionStorage vs localStorage 선택 기준** +**Devtools는 devDependencies로 분리 가능** -세션 단위로만 유지되면 충분한 상태는 sessionStorage가 적합하다. localStorage는 탭 간에 공유되고 브라우저 종료 후에도 유지된다. sessionStorage는 탭별로 독립적이고 세션이 끝나면 초기화된다. +Devtools는 프로덕션 빌드에 포함되지 않도록 처리되어 있어 `dependencies`에 넣어도 실제 번들에는 영향이 없다. 하지만 개발 환경 의존성임을 명확히 하거나, Next.js App 디렉터리를 사용하는 경우에는 `devDependencies`에 위치시키는 것이 적합하다. From a8712bd8453d487bf839e2faf0e5111864abcb14 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 20:56:21 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20useQuery/useMutation=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(src/queries/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal/AddRestaurantModal.jsx | 39 ++++--------------- .../RestaurantList/RestaurantList.jsx | 13 +------ src/queries/useAddRestaurantMutation.js | 32 +++++++++++++++ src/queries/useRestaurantsQuery.js | 10 +++++ 4 files changed, 51 insertions(+), 43 deletions(-) create mode 100644 src/queries/useAddRestaurantMutation.js create mode 100644 src/queries/useRestaurantsQuery.js diff --git a/src/components/Modal/AddRestaurantModal.jsx b/src/components/Modal/AddRestaurantModal.jsx index b0eb56a..6395e98 100644 --- a/src/components/Modal/AddRestaurantModal.jsx +++ b/src/components/Modal/AddRestaurantModal.jsx @@ -3,39 +3,10 @@ import { useState } from "react"; import Modal from "./Modal"; import styled from "styled-components"; import { textCaption } from "../../styles/typography"; -import { addRestaurant } from "../../api"; -import { useQueryClient, useMutation } from "@tanstack/react-query"; -import { RESTAURANTS_QUERY_KEY } from "../../constants/queryKeys"; +import { useAddRestaurantMutation } from "../../queries/useAddRestaurantMutation"; export default function AddRestaurantModal({ onClose }) { - const queryClient = useQueryClient(); - - const { mutate } = useMutation({ - mutationFn: (newRestaurant) => addRestaurant(newRestaurant), - onMutate: async (newRestaurant) => { - await queryClient.cancelQueries({ queryKey: RESTAURANTS_QUERY_KEY }); - const previous = queryClient.getQueryData(RESTAURANTS_QUERY_KEY); - const optimisticItem = { - id: `optimistic-${Date.now()}`, - ...newRestaurant, - }; - queryClient.setQueryData(RESTAURANTS_QUERY_KEY, (old) => { - const current = Array.isArray(old) ? old : []; - return [...current, optimisticItem]; - }); - onClose(); - return { previous }; - }, - onError: (err, _, context) => { - if (context?.previous) { - queryClient.setQueryData(RESTAURANTS_QUERY_KEY, context.previous); - } - alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: RESTAURANTS_QUERY_KEY }); - }, - }); + const { mutate } = useAddRestaurantMutation(); const [category, setCategory] = useState(""); const [name, setName] = useState(""); @@ -43,7 +14,11 @@ export default function AddRestaurantModal({ onClose }) { const handleSubmit = (e) => { e.preventDefault(); - mutate({ id: crypto.randomUUID(), category, name, description }); + onClose(); + mutate( + { category, name, description }, + { onError: () => alert("음식점 추가에 실패했습니다. 다시 시도해주세요.") }, + ); }; return ( diff --git a/src/components/RestaurantList/RestaurantList.jsx b/src/components/RestaurantList/RestaurantList.jsx index 33707b4..81fdc32 100644 --- a/src/components/RestaurantList/RestaurantList.jsx +++ b/src/components/RestaurantList/RestaurantList.jsx @@ -2,22 +2,13 @@ import { CATEGORY_IMAGES } from "../../constants/categoryImages"; import styled from "styled-components"; import { textSubtitle, textBody } from "../../styles/typography"; import { ALL_CATEGORY } from "../../constants/categories"; -import { useQuery } from "@tanstack/react-query"; -import { getRestaurants } from "../../api"; -import { RESTAURANTS_QUERY_KEY } from "../../constants/queryKeys"; +import { useRestaurantsQuery } from "../../queries/useRestaurantsQuery"; export default function RestaurantList({ selectedCategory, onRestaurantClick, }) { - const { - data: newRestaurants, - isLoading, - error, - } = useQuery({ - queryKey: RESTAURANTS_QUERY_KEY, - queryFn: getRestaurants, - }); + const { data: newRestaurants, isLoading, error } = useRestaurantsQuery(); if (isLoading) return 로딩중입니다.; if (error) return {error.message}; diff --git a/src/queries/useAddRestaurantMutation.js b/src/queries/useAddRestaurantMutation.js new file mode 100644 index 0000000..fffccb1 --- /dev/null +++ b/src/queries/useAddRestaurantMutation.js @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { addRestaurant } from "../api"; +import { RESTAURANTS_QUERY_KEY } from "../constants/queryKeys"; + +export function useAddRestaurantMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: addRestaurant, + onMutate: async (newRestaurant) => { + await queryClient.cancelQueries({ queryKey: RESTAURANTS_QUERY_KEY }); + const previous = queryClient.getQueryData(RESTAURANTS_QUERY_KEY); + const optimisticItem = { + id: `optimistic-${Date.now()}`, + ...newRestaurant, + }; + queryClient.setQueryData(RESTAURANTS_QUERY_KEY, (old) => { + const current = Array.isArray(old) ? old : []; + return [...current, optimisticItem]; + }); + return { previous }; + }, + onError: (err, _, context) => { + if (context?.previous) { + queryClient.setQueryData(RESTAURANTS_QUERY_KEY, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: RESTAURANTS_QUERY_KEY }); + }, + }); +} diff --git a/src/queries/useRestaurantsQuery.js b/src/queries/useRestaurantsQuery.js new file mode 100644 index 0000000..c23427c --- /dev/null +++ b/src/queries/useRestaurantsQuery.js @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getRestaurants } from "../api"; +import { RESTAURANTS_QUERY_KEY } from "../constants/queryKeys"; + +export function useRestaurantsQuery() { + return useQuery({ + queryKey: RESTAURANTS_QUERY_KEY, + queryFn: getRestaurants, + }); +} From 4d34d4ec7295e07b1c67e3b770a0e981225f6bbb Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Sun, 28 Jun 2026 21:00:21 +0900 Subject: [PATCH 11/11] =?UTF-8?q?docs:=20README=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/README.md b/README.md index f9946e0..a25ae2b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,19 @@ TanStack Query의 캐시 저장소. `new QueryClient()`로 인스턴스를 한 key가 같으면 같은 캐시를 본다. `invalidateQueries`에서 같은 key를 써야 캐시 무효화가 정확히 일어난다. +쿼리 종류가 많아지면 팩토리 패턴으로 관리한다. 각 케이스를 함수로 뽑아두면 키를 일관성 있게 생성하고 `invalidateQueries`에서도 동일한 참조를 사용할 수 있다. + +```js +export const restaurantKeys = { + all: ["restaurants"], + detail: (id) => ["restaurants", id], + list: (filters) => ["restaurants", "list", filters], +}; + +// 사용 예 +queryClient.invalidateQueries({ queryKey: restaurantKeys.all }); // ["restaurants"]로 시작하는 캐시 전부 무효화 +``` + ### 4. useQuery 데이터 읽기(GET). `queryFn`이 반환하는 값이 `data`에 들어온다. `queryKey`는 캐시 이름표일 뿐 `data`와 무관하다. @@ -64,6 +77,8 @@ const { data, isLoading, error } = useQuery({ Zustand에서 직접 짰던 `isLoading`, `error`, try/catch를 자동으로 처리한다. 컴포넌트가 마운트되면 자동으로 fetch를 실행하므로 `App.jsx`의 `useEffect` 트리거가 필요 없어진다. +`error`는 문자열이 아닌 `Error` 객체다. Zustand에서는 `set({ error: "음식점 목록을 불러오지 못했습니다." })`로 문자열을 직접 저장했지만, TanStack Query는 던져진 예외 객체를 그대로 넘기므로 `error.message`로 메시지를 꺼내야 한다. + ### 5. useMutation 데이터 변경(POST/PUT/DELETE). `mutate`는 요청을 보내는 트리거이고, 성공/실패 처리는 콜백 옵션으로 위임한다. @@ -291,6 +306,33 @@ if (error) return {error.message}; 예외 상태를 먼저 처리하고 정상 흐름은 아래에 집중되어 가독성이 높아진다. early return 이후에는 `newRestaurants`가 반드시 존재하므로 `?? []` 방어 코드도 제거할 수 있다. +### 6. useQuery/useMutation 커스텀 훅으로 분리 + +`RestaurantList`와 `AddRestaurantModal`에서 `useQuery`, `useMutation`을 직접 사용하고 있었다. 지금은 한 곳에서만 쓰이지만, 다른 컴포넌트에서 같은 데이터를 써야 할 때 `queryKey`와 `queryFn`을 중복 작성해야 하고, `staleTime`이나 `select` 같은 옵션을 추가할 때 컴포넌트를 직접 수정해야 한다. + +``` +// 변경 전 — 컴포넌트에서 직접 사용 +RestaurantList.jsx → useQuery({ queryKey, queryFn }) +AddRestaurantModal.jsx → useMutation({ mutationFn, onMutate, onError, onSettled }) + +// 변경 후 — 커스텀 훅으로 분리 +src/queries/useRestaurantsQuery.js → useQuery 로직 +src/queries/useAddRestaurantMutation.js → useMutation 로직 (서버 상태만) +``` + +UI 처리(`onClose`, `alert`)는 서버 상태 로직과 분리해 컴포넌트에 남겼다. 훅은 서버 상태(취소, 백업, 낙관적 업데이트, 롤백, 캐시 무효화)만 담당하고, 컴포넌트는 UI 흐름(모달 닫기, 에러 알림)만 담당한다. + +```js +// useAddRestaurantMutation.js — 서버 상태만 +onError: (err, _, context) => { + if (context?.previous) queryClient.setQueryData(...); +} + +// AddRestaurantModal.jsx — UI만 +onClose(); +mutate(data, { onError: () => alert("...") }); +``` + ## 과거 코드와 비교 ### 달라진 점