diff --git a/README.md b/README.md
index 8ba2dc5..a25ae2b 100644
--- a/README.md
+++ b/README.md
@@ -1,269 +1,390 @@
-# 전역상태관리 - 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를 써야 캐시 무효화가 정확히 일어난다.
+
+쿼리 종류가 많아지면 팩토리 패턴으로 관리한다. 각 케이스를 함수로 뽑아두면 키를 일관성 있게 생성하고 `invalidateQueries`에서도 동일한 참조를 사용할 수 있다.
```js
-// 객체 전달 — 단순 교체
-set({ isLoading: true });
+export const restaurantKeys = {
+ all: ["restaurants"],
+ detail: (id) => ["restaurants", id],
+ list: (filters) => ["restaurants", "list", filters],
+};
-// 함수 전달 — 이전 상태를 참조해야 할 때
-set((state) => ({ restaurants: [...state.restaurants, newOne] }));
-// state가 현재 store 전체 상태. get()으로도 같은 걸 할 수 있지만 이 방식이 더 일반적
+// 사용 예
+queryClient.invalidateQueries({ queryKey: restaurantKeys.all }); // ["restaurants"]로 시작하는 캐시 전부 무효화
```
-`get`은 액션 안에서 현재 상태를 읽어야 할 때 쓴다. 컴포넌트가 아닌 곳에서는 훅을 호출할 수 없어서 별도로 제공된다.
+### 4. useQuery
+
+데이터 읽기(GET). `queryFn`이 반환하는 값이 `data`에 들어온다. `queryKey`는 캐시 이름표일 뿐 `data`와 무관하다.
```js
-registerRestaurant: async (newRestaurant) => {
- await addRestaurant(newRestaurant);
- await get().fetchRestaurants(); // 다른 액션 호출
-},
+const { data, isLoading, error } = useQuery({
+ queryKey: ["restaurants"],
+ queryFn: getRestaurants,
+});
```
-### 2. Context API vs Zustand
+Zustand에서 직접 짰던 `isLoading`, `error`, try/catch를 자동으로 처리한다. 컴포넌트가 마운트되면 자동으로 fetch를 실행하므로 `App.jsx`의 `useEffect` 트리거가 필요 없어진다.
-| | Context API | Zustand |
-| -------------- | ----------------------------------------------------- | -------------------------------- |
-| Provider | 필요 | 불필요 |
-| 리렌더링 | value 전체가 바뀌면 구독 컴포넌트 전부 | selector로 구독한 값이 바뀔 때만 |
-| 상태 위치 | 컴포넌트 트리 안 (`useState`가 실제 상태를 관리) | 컴포넌트 트리 바깥 (모듈 스코프) |
-| 보일러플레이트 | `createContext` + Provider + `useContext` + 커스텀 훅 | `create` 하나 |
+`error`는 문자열이 아닌 `Error` 객체다. Zustand에서는 `set({ error: "음식점 목록을 불러오지 못했습니다." })`로 문자열을 직접 저장했지만, TanStack Query는 던져진 예외 객체를 그대로 넘기므로 `error.message`로 메시지를 꺼내야 한다.
-Context API는 "데이터를 트리에 흘려보내는 통로"다. 상태 자체는 Provider 안의 `useState`가 들고 있고, Context는 그 값을 아래로 전달하는 역할만 한다. 전역 상태 관리 라이브러리가 아니라 React 내장 데이터 전달 메커니즘이다.
+### 5. useMutation
-Zustand는 상태 저장, 업데이트, 구독을 모두 자체적으로 처리하는 독립적인 상태 컨테이너다.
+데이터 변경(POST/PUT/DELETE). `mutate`는 요청을 보내는 트리거이고, 성공/실패 처리는 콜백 옵션으로 위임한다.
-### 3. store 안에서 React 훅을 쓸 수 없는 이유
+```
+mutate({ ... }) → 서버에 요청을 보내는 트리거
+
+onMutate: () => {} → 요청 보내기 직전 실행
+onError: () => {} → 요청 실패 시 실행
+onSettled: () => {} → 성공/실패 상관없이 끝나면 실행
+```
+
+`invalidateQueries`로 캐시를 무효화하면 `useQuery`가 자동으로 최신 데이터를 다시 가져온다.
+
+### 6. 낙관적 업데이트
-React 훅(`useState`, `useEffect`, `useCallback`)은 React 함수 컴포넌트 또는 커스텀 훅 안에서만 호출 가능하다. Zustand의 `create` 콜백은 그냥 일반 JS 함수라 훅을 쓰면 에러가 난다.
+서버 응답을 기다리지 않고 UI를 먼저 바꾸는 것. 성공할 거라고 낙관하고, 실패하면 롤백한다.
-이 때문에 구조가 나뉜다.
+```
+일반: 클릭 → 요청 → (대기) → 응답 → UI 업데이트
+낙관적 업데이트: 클릭 → UI 먼저 업데이트 → 요청 → 실패하면 롤백
+```
+
+```js
+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"] }); // 서버 데이터로 최종 동기화
+},
+```
-| 역할 | 위치 |
-| -------------------- | ----------------------------- |
-| 상태 + 액션 로직 | store (무엇을, 어떻게) |
-| 언제 실행할지 타이밍 | 컴포넌트의 `useEffect` (언제) |
+### 7. "여러 사용자, 주기적 업데이트 환경"에서 달라지는 것
-`fetchRestaurants`를 App.jsx에서 `useEffect`로 트리거하는 것도, `useCallback`이 store 액션에 필요 없는 것도 이 구조 때문이다. 컴포넌트 안의 함수는 렌더링마다 새로 만들어지지만, store 액션은 `create`가 실행될 때 딱 한 번 만들어지고 참조가 바뀌지 않는다.
+Zustand에서는 `fetchRestaurants`가 마운트 시 한 번만 실행된다. 다른 사용자가 식당을 추가해도 내 화면은 새로고침 전까지 변하지 않는다.
-### 4. create가 한 번만 실행되는 이유
+TanStack Query는 기본적으로 탭 포커스, 네트워크 재연결 시 자동으로 최신 데이터를 가져온다(`refetchOnWindowFocus: true`). `refetchInterval`을 추가하면 주기적 폴링도 가능하다.
-JS 모듈 시스템은 같은 파일을 여러 번 `import`해도 처음 한 번만 실행하고 결과를 캐싱한다. `create()`도 앱이 시작될 때 딱 한 번 실행되고, 내부에 상태와 구독자 목록을 담은 객체가 만들어진다. 이후 컴포넌트가 렌더링될 때마다 store가 새로 만들어지는 게 아니라, 같은 객체를 계속 참조한다.
+## 🤔 고민했던 문제와 해결 과정에서 배운 점
-컴포넌트가 `useRestaurantStore((state) => state.xxx)`를 호출하면 "이 값이 바뀌면 나를 리렌더링해달라"고 store에 등록한다. `set()`이 호출되면 store가 등록된 컴포넌트들에게 알리고, selector로 선택한 값이 바뀐 컴포넌트만 리렌더링된다.
+### 1. Zustand store 파일이 사라지고 어디서 상태를 관리하는가
-store 자체는 컴포넌트 트리 바깥에 있어서 컴포넌트가 마운트/언마운트돼도 상태가 유지된다.
+Zustand는 상태와 액션을 store 파일에 모아두는 구조였다. TanStack Query로 전환하면서 store 파일이 없어지니, 데이터를 어디서 관리하는지 처음에 명확하지 않았다.
-### 5. persist 미들웨어 — 영속화 vs 캐싱
+`QueryClient`가 캐시 저장소 역할을 대신하고, 각 컴포넌트에서 `useQuery`/`useMutation`을 직접 쓰면 된다. `useRestaurantStore`에 있던 것들이 전부 서버 상태였기 때문에 파일 자체가 사라지는 것이 맞다. 클라이언트 상태(`clickedRestaurant`, `isAddModalOpen`)는 `useState`로, 카테고리 필터는 Zustand persist로 그대로 유지했다.
-`persist`는 store 상태를 localStorage에 자동으로 저장하고 복원한다. `create`와 상태 정의 사이에 끼어드는 미들웨어 형태다.
+### 2. queryFn에 함수 참조를 넘기는 것과 호출 결과를 넘기는 것의 차이
```js
-const useFilterStore = create(
- persist(
- // create(상태정의) → create(persist(상태정의, 옵션))
- (set) => ({
- selectedCategory: ALL_CATEGORY,
- setSelectedCategory: (category) => set({ selectedCategory: category }),
- }),
- { name: "self-paced-react-category" }, // localStorage 키 이름
- ),
-);
+queryFn: getRestaurants // 함수 자체를 넘김 — TanStack Query가 내부적으로 호출
+queryFn: () => getRestaurants() // 화살표 함수로 감싸서 호출
```
-영속화와 캐싱은 다른 개념이다.
+둘의 결과는 동일하다. 인자를 넘겨야 할 때는 `() => getRestaurants(param)` 형태가 필요하고, 그렇지 않으면 `queryFn: getRestaurants`가 더 간결하다.
-| | 영속화 | 캐싱 |
-| ---- | ---------------------------------- | --------------------------------------- |
-| 목적 | 사용자 설정/선택값 기억 (UX) | 서버 요청 비용 절감 (성능) |
-| 예시 | 다크모드, 언어 설정, 카테고리 필터 | API 응답 재사용, 이미지 재다운로드 방지 |
+### 3. 로딩 중 data가 undefined일 때의 처리
-Context API도 localStorage에 직접 저장하는 코드를 짜면 영속화가 가능하다. `persist` 미들웨어는 그 작업을 자동으로 처리해준다.
-
-`persist`의 두 번째 인자 옵션에는 `name`, `storage` 외에 `onRehydrateStorage`도 있다. 앱이 시작될 때 storage에서 값을 복원하는 시점에 실행되는 콜백으로, 복원된 값이 유효한지 검증하는 데 쓴다.
+`useQuery`는 fetch가 완료되기 전까지 `data`가 `undefined`다. 바로 `.filter()`를 호출하면 에러가 난다. `??`로 방어한다.
```js
-persist(
- (set) => ({ ... }),
- {
- name: "storage-key",
- onRehydrateStorage: () => (state) => {
- // 복원 완료 후 실행. state가 null이면 복원 실패
- if (!state) return;
- if (!isValidCategory(state.selectedCategory)) {
- state.setSelectedCategory(ALL_CATEGORY);
- }
- },
- }
-)
+(newRestaurants ?? []).filter((r) => r.category === selectedCategory)
+// undefined이면 빈 배열, 값이 있으면 그대로 사용
```
-커링 구조(`() => (state) => {}`)인 이유는 바깥 함수가 복원 **시작 전**, 안쪽 함수가 복원 **완료 후**에 실행되기 때문이다. 복원된 `state`는 안쪽 함수에서만 접근할 수 있다.
+### 4. mutate에 await를 쓸 수 없는 이유와 onMutate/onError/onSettled의 역할
-## 🤔 고민했던 문제와 해결 과정에서 배운 점
+Zustand 액션은 `async` 함수에 `await`를 쓸 수 있어서, `useMutation`의 `mutate`도 같은 방식으로 다루려 했다. 하지만 `mutate`는 Promise를 반환하지 않아서 `await`이 동작하지 않고, `try/catch`도 잡히지 않는다.
+
+성공/실패 처리는 `useMutation`의 콜백 옵션으로 위임하는 것이 올바른 방식이다. 각 콜백은 실행 시점이 다르다.
+
+```
+onMutate → 요청 보내기 직전 (낙관적 업데이트, 모달 닫기)
+onError → 요청 실패 시 (롤백, 에러 알림)
+onSettled → 성공/실패 상관없이 끝나면 (서버 데이터로 최종 동기화)
+```
+
+`queryClient`의 메서드(`cancelQueries`, `getQueryData` 등)는 Promise를 반환하므로 `await` 가능하다. `mutate`와 혼동하지 않아야 한다.
+
+### 5. new QueryClient()와 useQueryClient()의 차이
+
+`main.jsx`에서 `useQueryClient()`를 쓰는 것처럼 보여서, `AddRestaurantModal`에서도 같은 것을 중복 선언하는 건지 헷갈렸다.
-### 1. Context → Zustand 마이그레이션 시 코드가 중복되는 건지
+`new QueryClient()`는 인스턴스를 생성하는 것이고, `useQueryClient()`는 `QueryClientProvider`를 통해 공유된 그 인스턴스를 컴포넌트 안에서 참조하는 훅이다. Zustand의 `create()`로 store를 한 번 만들고, 컴포넌트에서 `useRestaurantStore()`로 참조하는 구조와 같은 원리다.
-마이그레이션하면서 `useRestaurants` 훅의 코드를 store에 다시 써야 하는 건지 헷갈렸다. 결론은 중복 작성이 아니라 이동이다. `useRestaurants`에 있던 것들이 각자의 역할에 맞는 위치로 옮겨간 것이다.
+### 6. cancelQueries에 await가 필요한 이유
-| useRestaurants | Zustand store |
-| -------------------------- | ------------------------------- |
-| `useState([])` | 초기값 `newRestaurants: []` |
-| `setNewRestaurants(data)` | `set({ newRestaurants: data })` |
-| `fetchRestaurants` 함수 | 액션으로 이동 |
-| `registerRestaurant` 함수 | 액션으로 이동 |
-| `useEffect(() => fetch())` | App.jsx로 이동 |
+낙관적 업데이트에서 `cancelQueries`가 필요한 이유는 이해했지만, 왜 `await`를 써야 하는지 처음에 명확하지 않았다.
-### 2. selectedCategory를 어디서 관리할지
+취소 요청을 보내고 실제 취소가 완료될 때까지 기다려야 하기 때문이다. `await` 없이 바로 `setQueryData`로 넘어가면, 취소 안 된 refetch 응답이 나중에 도착해서 낙관적으로 업데이트한 캐시를 이전 값으로 덮어쓸 수 있다.
+
+### 7. setQueryData에 함수를 넘기는 문법
+
+`setQueryData`의 두 번째 인자로 함수를 넘기면 현재 캐시값을 인자로 받는다는 것을 처음에 몰랐다.
+
+```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 상수 파일 분리
+
+`RestaurantList`와 `AddRestaurantModal` 두 곳에서 `["restaurants"]` 문자열을 직접 반복 사용하고 있었다. 문자열을 여러 곳에서 직접 쓰면 오타가 발생해도 런타임 에러가 나지 않아 캐시 미스매치를 찾기 어렵다.
+
+```js
+// src/constants/queryKeys.js
+export const RESTAURANTS_QUERY_KEY = ["restaurants"];
+```
+
+`categories.js`, `categoryImages.js` 같은 상수 파일이 이미 있는 구조라 한 줄짜리 상수도 파일로 분리하는 게 자연스러운 위치다. 두 컴포넌트가 같은 키를 참조하므로 키가 바뀌어도 한 곳만 수정하면 된다.
+
+### 2. 낙관적 업데이트 — optimistic ID 적용
-`fetchRestaurants` 시작 시 `error: null`을 함께 설정하지 않으면, 이전 실패 에러 메시지가 다음 성공 후에도 화면에 남는다.
+기존에는 `mutate` 호출 시 `crypto.randomUUID()`로 생성한 ID를 넘겼다. 이 경우 캐시에 올라간 항목이 임시 데이터인지 서버에서 온 실제 데이터인지 구별이 안 됐다.
```js
-fetchRestaurants: async () => {
- set({ isLoading: true, error: null });
- // ...
+// 변경 전 — 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}
}
+ ...
+
+);
+
+// 변경 후 — early return
+if (isLoading) return 로딩중입니다.;
+if (error) return {error.message};
+```
-// 변경 후 — 훅을 통해 접근
-const { newRestaurants, isLoading } = useRestaurantData();
+예외 상태를 먼저 처리하고 정상 흐름은 아래에 집중되어 가독성이 높아진다. 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 로직 (서버 상태만)
```
-훅 내부에서는 여전히 각 값을 개별 selector로 구독하고 있어서 리렌더링 최적화는 그대로 유지된다. 컴포넌트에서 구조분해로 받는 것처럼 보이지만, store 전체를 구독하는 것과는 다르다.
+UI 처리(`onClose`, `alert`)는 서버 상태 로직과 분리해 컴포넌트에 남겼다. 훅은 서버 상태(취소, 백업, 낙관적 업데이트, 롤백, 캐시 무효화)만 담당하고, 컴포넌트는 UI 흐름(모달 닫기, 에러 알림)만 담당한다.
-그런데 각 컴포넌트가 실제로 사용하는 선택자가 달랐다. `RestaurantList`는 `newRestaurants`, `isLoading`, `error`를 쓰고, `AddRestaurantModal`은 `registerRestaurant`만 쓴다. 같은 선택자 묶음을 반복하는 게 아니라 그냥 모아두는 형태였고, 현재 규모에서는 추상화 레이어가 코드 추적 비용을 높인다고 판단해 다시 제거했다.
+```js
+// useAddRestaurantMutation.js — 서버 상태만
+onError: (err, _, context) => {
+ if (context?.previous) queryClient.setQueryData(...);
+}
+
+// AddRestaurantModal.jsx — UI만
+onClose();
+mutate(data, { onError: () => alert("...") });
+```
## 과거 코드와 비교
### 달라진 점
-**커스텀 훅 래퍼 유무**
+**useQuery/useMutation 위치**
+
+과거 코드는 `useRestaurantData` 커스텀 훅 안에 `useQuery`와 `useMutation`을 모두 넣고, 클라이언트 상태(Zustand)와 서버 상태(TanStack Query)를 한 훅에서 같이 관리했다. 현재 코드는 `RestaurantList`와 `AddRestaurantModal`에서 각각 직접 사용한다.
+
+훅으로 감싸는 방식은 서버 상태 접근 진입점을 통일해 구조 변경 시 컴포넌트를 수정하지 않아도 된다는 장점이 있다. 다만 이번 미션에서는 지난 미션(2.2)에서 `useRestaurantData`를 도입했다가 제거한 경험이 있다. 각 컴포넌트가 실제로 사용하는 값이 달라 같은 선택자를 반복하는 게 아니라 그냥 모아두는 형태가 됐고, 현재 규모에서는 추상화 레이어가 코드 추적 비용을 높인다고 판단했기 때문이다. 규모가 커져 여러 컴포넌트가 동일한 선택자 묶음을 반복하게 된다면 과거 방식이 더 적합하다.
+
+**QUERY_KEY 상수화**
-과거 코드는 store selector를 컴포넌트에서 직접 쓰지 않고 `useRestaurantData`, `useRestaurantModal` 커스텀 훅으로 감쌌다. 현재 코드는 컴포넌트에서 store를 직접 참조한다.
+과거 코드는 `const QUERY_KEY = ["restaurants"]`로 상수를 분리해서 `useQuery`와 `invalidateQueries` 모두 이 상수를 참조했다. 현재 코드는 문자열을 직접 반복 사용한다. 문자열을 여러 곳에서 직접 쓰면 오타가 발생해도 런타임 에러가 나지 않아 캐시 미스매치를 찾기 어렵다. 과거 방식이 더 적합하다.
-컴포넌트가 store 구조를 알 필요 없이 훅만 import하면 되고, store 이름이나 구조가 바뀌어도 컴포넌트는 수정하지 않아도 되기 때문에 과거 방식이 더 나은 접근이라고 판단했다. 현재 방식은 store가 바뀌면 직접 import한 컴포넌트를 전부 찾아서 수정해야 한다.
+**낙관적 업데이트 optimistic ID**
-**Store 분리 기준**
+과거 코드는 `onMutate`에서 캐시에 추가할 항목에 임시 ID를 부여했다.
-과거 코드는 `useRestaurantStore`(서버 데이터 + 카테고리)와 `useRestaurantModalStore`(모달 UI 상태)로 나눴다. 현재 코드는 `useRestaurantStore`(서버 데이터)와 `useFilterStore`(카테고리), 모달은 App 로컬 state로 분리했다.
+```js
+const optimisticItem = {
+ id: `optimistic-${Date.now()}`,
+ ...newRestaurant,
+};
+```
-모달 열림/닫힘은 App 직계 자식에게만 영향을 주는 일시적인 UI 상태라 전역 store에 올릴 이유가 없고, 서버 데이터와 UI 필터 상태를 나누면 관심사 분리가 명확해지기 때문에 현재 방식이 더 나은 구조라고 판단했다.
+현재 코드는 `mutate` 호출 시 `crypto.randomUUID()`로 ID를 생성해서 넘긴다. 임시 ID가 명확히 구별되면 Devtools 디버깅과 롤백 추적이 쉬워지므로, 과거 방식이 더 적합하다.
-**sessionStorage vs localStorage**
+**onError 방어 코드**
-현재 코드는 카테고리 필터를 localStorage에 저장한다. 과거 코드는 리뷰 피드백을 받고 sessionStorage로 변경했다.
+과거 코드는 `context?.previous`로 옵셔널 체이닝을 사용했다. `onMutate`가 예외를 던지면 `context`가 `undefined`가 되어 롤백 자체가 실패할 수 있기 때문이다. 현재 코드는 이 방어 처리가 없다. 낙관적 업데이트에서 롤백 실패는 데이터 불일치로 이어지므로 과거 방식이 더 안전하다.
-카테고리 필터는 "브라우저를 닫아도 기억해야 하는 설정"이 아니라 "현재 탐색 중인 필터 상태"에 가깝고, sessionStorage는 탭/브라우저를 닫으면 초기화되어 오래된 상태가 쌓이지 않기 때문에 이 경우에는 과거 방식(sessionStorage)이 더 적합하다고 판단했다.
+**mutate vs mutateAsync**
-### 과거 코드에서 배운 점
+과거 코드는 `mutateAsync`를 사용해 컴포넌트에서 `await`로 순차적인 흐름을 작성했다. 현재 코드는 `mutate`를 사용하고 모달 닫기를 `onMutate`에서 즉시 처리한다. 낙관적 업데이트의 핵심은 서버 응답을 기다리지 않는 것이므로, 이 미션 맥락에서는 현재 방식이 의도에 더 맞다.
+
+**isLoading/isError 처리 방식**
-**localStorage/sessionStorage에 저장된 값은 신뢰하지 않는다**
+과거 코드는 `RestaurantList`에서 early return으로 처리했다.
-서버 데이터는 코드 로직으로만 바뀌지만, localStorage와 sessionStorage는 사용자가 브라우저 개발자 도구에서 직접 값을 수정하거나 이상한 값을 넣을 수 있다. `persist` 미들웨어는 저장/복원 과정의 기술적 오류(JSON 파싱 실패 등)는 처리해주지만, 저장된 값이 앱에서 허용하는 값인지까지는 검증하지 않는다.
+```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`에 위치시키는 것이 적합하다.
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/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 (
<>
diff --git a/src/components/Modal/AddRestaurantModal.jsx b/src/components/Modal/AddRestaurantModal.jsx
index e58f9bd..6395e98 100644
--- a/src/components/Modal/AddRestaurantModal.jsx
+++ b/src/components/Modal/AddRestaurantModal.jsx
@@ -3,27 +3,22 @@ import { useState } from "react";
import Modal from "./Modal";
import styled from "styled-components";
import { textCaption } from "../../styles/typography";
-import useRestaurantStore from "../../store/useRestaurantStore";
+import { useAddRestaurantMutation } from "../../queries/useAddRestaurantMutation";
export default function AddRestaurantModal({ onClose }) {
- const registerRestaurant = useRestaurantStore((state) => state.registerRestaurant);
+ const { mutate } = useAddRestaurantMutation();
+
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("음식점 추가에 실패했습니다. 다시 시도해주세요.");
- }
+ onClose();
+ mutate(
+ { category, name, description },
+ { onError: () => alert("음식점 추가에 실패했습니다. 다시 시도해주세요.") },
+ );
};
return (
diff --git a/src/components/RestaurantList/RestaurantList.jsx b/src/components/RestaurantList/RestaurantList.jsx
index f8ed8dd..81fdc32 100644
--- a/src/components/RestaurantList/RestaurantList.jsx
+++ b/src/components/RestaurantList/RestaurantList.jsx
@@ -1,16 +1,17 @@
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 { useRestaurantsQuery } from "../../queries/useRestaurantsQuery";
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 } = useRestaurantsQuery();
+
+ if (isLoading) return 로딩중입니다.;
+ if (error) return {error.message};
const filteredRestaurants =
selectedCategory === ALL_CATEGORY
@@ -19,8 +20,6 @@ export default function RestaurantList({
return (
- {isLoading && 로딩중입니다.
}
- {error && {error}
}
{filteredRestaurants.map((restaurant) => (
@@ -45,6 +44,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"];
diff --git a/src/main.jsx b/src/main.jsx
index 1141dbf..b7d57e5 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..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,
+ });
+}
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;