Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions 02-state-management-tools/2.2-Zustand/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 02-2. 전역상태관리 - Zustand

## 🎯 요구사항

- Context API로 구성한 애플리케이션을 Zustand 기반 전역 상태로 마이그레이션하세요.
- props에 대한 요구사항은 2-1 요구사항과 같습니다.
- Zustand를 **왜** 사용하는지, Context API와 비교했을때 어떤 점이 달랐는지, 또 trade-off가 있는지 적어주세요.
- 기술적인 것도 좋고 개발자의 경험 측면에서도 좋습니다.
- (선택): 카테고리 필터의 선택된 카테고리가 새로고침 후에도 유지되도록 구현해보세요

### 😗구현 예시

- 컴포넌트의 이름이나 구조를 정한 이유가 명확해야하며 타인에게 설명할 수 있어야합니다.
- 아래 코드는 Zustand 스토어를 설정하는 예시입니다.

```javascript
import { create } from "zustand";

// Zustand 스토어 예시
export const useBear = create((set) => ({
// state
bears: 0,

// actions
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}));
```

## ✅ 키워드

- props drilling
- 전역상태관리
- Zustand
- create
- set / get

## 🧙‍♀️ 진행 가이드

- 진행시간 : 2시간 내에 완료하는 것을 목표로 합니다.

## 🔗 참고 문서

- [Zustand 공식문서](https://recoiljs.org/docs/introduction/installation/)
- [Zustand와 React Context](https://tkdodo.eu/blog/zustand-and-react-context)
214 changes: 100 additions & 114 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,169 +1,155 @@
# Context API를 사용해서 전역상태관리하기
# Zustand를 사용해서 전역 상태 관리하기

## 🎯 개인 목표 및 목표 달성을 위한 행동 가이드

이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 한다.

1. `createContext`, `Provider`, `useContext` 세 가지 개념의 역할을 명확히 이해하고 직접 구현한다.
2. props drilling이 발생하는 지점을 코드에서 직접 파악하고 Context로 해결하는 경험을 쌓는다.
3. UI 상태와 데이터 상태를 구분해서 Context 적용 범위를 스스로 판단하는 능력을 기른다.
1. Context API로 구현된 앱을 Zustand로 마이그레이션하면서 두 방식의 차이를 체감한다.
2. `create`, `set`, `get`, selector 개념을 직접 사용하며 Zustand 스토어 구조를 익힌다.
3. 전역 상태로 관리할 것과 로컬 상태로 유지할 것을 스스로 판단하는 능력을 기른다.

---

## 📝 기능 구현 목록

- [x] `RestaurantsContext` 생성 및 `RestaurantsProvider` 구현
- [x] `useRestaurants` 훅의 데이터(`restaurants`, `addRestaurant`, `isLoading`, `error`)를 Context로 관리
- [x] `RestaurantList`에서 `useContext`로 `restaurants`, `isLoading`, `error` 직접 구독
- [x] `AddRestaurantModal`에서 `useContext`로 `addRestaurant` 직접 호출 (`onSubmit` prop 제거)
- [x] `App.jsx`에서 음식점 관련 state/handler 제거, UI 상태(`selectedCategory`, `clickedRestaurant`, `isAddRestaurantModalOpen`)만 유지
- [x] `useRestaurantStore` 생성 — `restaurants`, `addRestaurant`, `isLoading`, `error`, `fetchRestaurants` 포함
- [x] `RestaurantList`에서 selector로 스토어 구독, `useEffect`로 초기 데이터 fetch
- [x] `AddRestaurantModal`에서 스토어의 `addRestaurant` 직접 호출
- [x] `App.jsx`에서 `RestaurantsProvider` 제거
- [x] `selectedCategory`를 스토어로 이동 및 `persist` 미들웨어로 새로고침 후에도 유지

---

## 📚 학습 내용

### Context API란?
### Zustand 핵심 개념

컴포넌트 트리 전체에 데이터를 전달할 수 있는 React 내장 기능이다. props를 통해 중간 컴포넌트를 거치지 않고, 필요한 컴포넌트가 데이터를 직접 꺼내 쓸 수 있게 한다.

### createContext / Provider / useContext

세 가지 API가 각각 다른 역할을 담당한다.

| 개념 | 역할 | 설명 |
|---|---|---|
| `createContext` | Context 객체 생성 | 데이터를 담을 컨테이너를 정의한다. 초기값을 인자로 받으며, Provider 없이 `useContext`를 호출했을 때 이 초기값이 반환된다. |
| `Provider` | Context 값 공급 | `value` prop으로 전달한 데이터를 하위 컴포넌트 트리 전체에 주입한다. `value`가 변경되면 해당 Context를 구독 중인 모든 컴포넌트가 리렌더링된다. |
| `useContext` | Context 값 구독 | 가장 가까운 상위 Provider의 `value`를 반환한다. Provider가 없으면 `createContext`의 초기값을 반환한다. |
| 개념 | 설명 |
|---|---|
| `create` | 스토어를 생성한다. 반환값이 훅이라 `useRestaurantStore()`로 바로 사용한다. |
| `set` | 상태를 업데이트한다. 얕은 병합(shallow merge)이라 바꾸지 않는 필드는 그대로 유지된다. |
| `get` | 액션 안에서 현재 스토어 상태를 읽거나 다른 액션을 호출할 때 사용한다. |
| selector | `useStore((state) => state.xxx)` 형태로 필요한 상태만 구독한다. 해당 값이 바뀔 때만 리렌더링된다. |

```js
// 1. createContext — 공간 생성
export const RestaurantsContext = createContext(null);

// 2. Provider — 데이터를 채워서 하위 컴포넌트에 공급
export function RestaurantsProvider({ children }) {
const { restaurants, addRestaurant, isLoading, error } = useRestaurants();
return (
<RestaurantsContext.Provider value={{ restaurants, addRestaurant, isLoading, error }}>
{children}
</RestaurantsContext.Provider>
);
}

// 3. useContext — 필요한 컴포넌트에서 직접 꺼냄
const { restaurants } = useContext(RestaurantsContext);
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(); // 다른 액션 호출
},
}));
```

### React 18 vs React 19 Provider 문법 차이
### persist 미들웨어

React 19부터는 Context 객체 자체를 Provider로 사용할 수 있다. 공식문서가 React 19 기준으로 업데이트되었으므로 버전 확인이 필요하다.
스토어의 상태를 localStorage에 자동으로 저장/복원해주는 Zustand 내장 미들웨어다. `partialize`로 저장할 상태만 선택할 수 있다.

```jsx
// React 18 — .Provider 필요
<RestaurantsContext.Provider value={...}>

// React 19 — Context 자체를 Provider로 사용 가능
<RestaurantsContext value={...}>
```js
const useRestaurantStore = create(
persist(
(set, get) => ({ ... }),
{
name: "restaurant-storage",
partialize: (state) => ({ selectedCategory: state.selectedCategory }),
}
)
);
```

`restaurants`는 서버에서 매번 가져오므로 저장할 필요가 없고, `selectedCategory`만 persist 대상으로 지정했다.

---

## 🤔 고민했던 문제와 해결 과정에서 배운 점
## 🤔 Zustand를 왜 사용하는가 — Context API와 비교

### 무엇을 Context에 넣을 것인가
### Context API는 전역 상태 관리 도구가 아니다

Context에 모든 state를 넣는 게 아니라, **데이터 도메인**과 **UI 상태**를 구분하는 것이 핵심이다.
Context API는 원래 **prop drilling 해결 도구**다. 상태 관리를 하려면 `useState`/`useReducer`를 별도로 조합해야 하고, 그 결과물을 Provider로 감싸야 한다. 이번 마이그레이션에서 `useRestaurants` 훅 + `RestaurantsContext` + `useRestaurantsContext` 세 파일이 `useRestaurantStore` 하나로 줄어든 게 그 차이다.

| 구분 | 상태 | 이유 |
|---|---|---|
| Context (데이터 도메인) | `restaurants`, `addRestaurant`, `isLoading`, `error` | 여러 컴포넌트에서 공유되는 서버 데이터 |
| props / 로컬 state (UI 상태) | `selectedCategory`, `clickedRestaurant`, `isAddRestaurantModalOpen` | 특정 화면의 인터랙션 상태로, App이 관리하는 게 자연스러움 |
### 달랐던 점

판단 기준은 "그 데이터가 App 고유의 책임인가?"다. `addRestaurant`는 음식점 데이터 도메인의 책임이므로 Context가 적합하고, `selectedCategory`는 화면 UI의 책임이므로 로컬 state가 적합하다.
**Provider가 없다**

### Provider 위치 결정
Context는 `<RestaurantsProvider>`로 트리를 감싸야 했지만, Zustand는 Provider 없이 어떤 컴포넌트에서든 스토어에 바로 접근한다.

처음에는 `RestaurantsProvider`를 App의 return 안에 배치했다. 이 경우 App 자신이 `useContext`로 Context 데이터를 꺼낼 수 없다는 문제가 생긴다. `useContext`는 자신보다 **상위에 있는** Provider를 찾기 때문이다.
**리렌더링 최적화**

해결책은 두 가지였다.
Context는 value 안의 어떤 값이 바뀌어도 해당 Context를 구독하는 모든 컴포넌트가 리렌더링된다. Zustand는 selector로 필요한 상태만 구독하기 때문에, `restaurants`가 바뀌어도 `addRestaurant` 액션만 구독하는 컴포넌트는 리렌더링되지 않는다.

1. Provider를 `main.jsx`로 올려서 App도 Context에 접근 가능하게 만들기
2. App이 Context를 쓸 필요가 없도록 구조를 바꾸기
```js
// AddRestaurantModal — addRestaurant만 구독하므로
// restaurants, isLoading, error가 바뀌어도 리렌더링되지 않는다
const addRestaurant = useRestaurantStore((state) => state.addRestaurant);
```

`AddRestaurantModal`이 `addRestaurant`를 Context에서 직접 꺼내도록 하면 App은 `addRestaurant`를 전혀 알 필요가 없어진다. `isLoading`, `error`도 `RestaurantList`가 직접 보여주면 된다. 결과적으로 App이 Context를 쓰지 않아도 되는 구조가 만들어졌고, Provider 위치 문제도 자연스럽게 해결됐다.
### Trade-off

### filteredRestaurants를 어디서 처리할 것인가
**Context가 나은 경우**

기존에는 `App`이 `filteredRestaurants`를 계산해서 `RestaurantList`에 내려줬다. Context 도입 후 `restaurants`는 Context에서 오고, `selectedCategory`는 UI 상태로 props로 전달하게 됐다.
- 외부 라이브러리 없이 React만으로 해결 가능
- 테마, 로케일처럼 변경이 거의 없는 정적 값은 Context가 오히려 적합
- Provider 범위로 상태의 생명주기가 명확하게 제어되어야 할 때

```
// 변경 후
RestaurantList에서 Context로 restaurants를 꺼내고,
props로 받은 selectedCategory로 필터링을 직접 처리
```
**Zustand의 단점**

`selectedCategory`를 props로 유지한 이유: UI 상태이므로 Context보다 props가 더 자연스럽다. 미션 요구사항에도 "props를 쓴다면 그 이유를 PR에 적어주세요"라고 명시되어 있으므로, 이 판단 근거를 기록한다.
- 스토어가 전역이라 어디서든 접근 가능한 게 장점이지만, 반대로 상태가 어디서 변경되는지 추적하기 어려워질 수 있다
- Context는 Provider 범위로 상태의 생명주기가 명확한 반면, Zustand 스토어는 앱 전체에서 살아있다

---

## 🛠 리팩토링
## 🤔 고민했던 문제와 해결 과정에서 배운 점

### App.jsx — 음식점 데이터 책임 제거
### 무엇을 스토어에 넣을 것인가

Context 도입 전 App은 `useRestaurants`를 직접 호출하고, 모든 handler를 가지고 있었다. 리팩토링 후 App은 UI 상태만 관리한다.
스토어에 모든 상태를 넣는 게 아니라, **전역 상태가 필요한 조건**을 기준으로 판단했다.

```js
// before — App이 데이터와 UI 상태를 모두 관리
const { restaurants, addRestaurant, isLoading, error } = useRestaurants();
const filteredRestaurants = filterRestaurants(restaurants, selectedCategory);
async function handleRestaurantSubmit(restaurant) { ... }

// after — UI 상태만 남음
const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY);
const [clickedRestaurant, setClickedRestaurant] = useState(null);
const [isAddRestaurantModalOpen, setIsAddRestaurantModalOpen] = useState(false);
```
| 구분 | 상태 | 이유 |
|---|---|---|
| 스토어 (데이터 도메인) | `restaurants`, `addRestaurant`, `isLoading`, `error` | 여러 컴포넌트에서 공유되는 서버 데이터 |
| 스토어 (persist 목적) | `selectedCategory` | UI 상태이지만 새로고침 후 유지를 위해 스토어로 이동 |
| 로컬 state (UI 상태) | `clickedRestaurant`, `isAddRestaurantModalOpen` | 해당 컴포넌트에서만 쓰이는 인터랙션 상태 |

`selectedCategory`는 본래 UI 상태이므로 `useState`가 자연스럽다. 다만 새로고침 후 유지(`persist`)는 Zustand 스토어에만 적용할 수 있기 때문에 기술적인 이유로 스토어로 이동했다. 이 판단은 목적이 명확하므로 정당하지만, persist 요구사항이 없었다면 로컬 state로 유지하는 것이 맞다.

### fetchRestaurants 호출 위치

### RestaurantList — Context 구독 및 필터링 내부화
Zustand 스토어는 React 컴포넌트가 아니라 `useEffect`를 쓸 수 없다. 초기 데이터 fetch는 데이터를 보여주는 컴포넌트(`RestaurantList`)가 마운트될 때 `useEffect`로 호출하는 방식으로 해결했다.

```js
// before
export default function RestaurantList({ restaurants, onRestaurantClick }) { ... }

// after
export default function RestaurantList({ selectedCategory, onRestaurantClick }) {
const { restaurants, isLoading, error } = useContext(RestaurantsContext);
const filteredRestaurants = filterRestaurants(restaurants, selectedCategory);
...
}
const fetchRestaurants = useRestaurantStore((state) => state.fetchRestaurants);

useEffect(() => {
fetchRestaurants();
}, [fetchRestaurants]);
```

### AddRestaurantModal — onSubmit prop 제거
### 이벤트 핸들러와 스토어 액션의 분리

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[배움]
저도 같은 구조로 구현했는데 명확하게 정리하지 못하고 넘어갔던 부분을 정리해주셔서 이해가 편해졌습니다!


스토어 액션은 순수한 값만 받도록 하고, 이벤트 객체 처리는 컴포넌트에 남겼다.

```js
// before — App으로부터 onSubmit을 받아서 호출
export default function AddRestaurantModal({ onSubmit, onClose }) {
function handleFormSubmit(e) {
e.preventDefault();
onSubmit({ category, name, description });
}
}
// 스토어 액션 — 값만 받는다
setSelectedCategory: (category) => set({ selectedCategory: category }),

// after — Context에서 addRestaurant를 직접 꺼내서 호출
export default function AddRestaurantModal({ onClose }) {
const { addRestaurant } = useContext(RestaurantsContext);
async function handleFormSubmit(e) {
e.preventDefault();
try {
await addRestaurant({ category, name, description });
onClose();
} catch {
alert("음식점 추가에 실패했습니다. 다시 시도해주세요.");
}
}
// 컴포넌트 — 이벤트에서 값을 꺼내는 건 UI 로직
function handleCategoryChange(e) {
setSelectedCategory(e.target.value);
}
```

### styled-components 선언 위치 — 컴포넌트 하단으로 이동

파일을 열었을 때 가장 먼저 보고 싶은 건 컴포넌트 로직이지, 스타일 세부사항이 아니다. styled-components 선언이 상단에 있으면 컴포넌트 함수가 한참 아래로 밀려 가독성이 떨어진다. 모든 컴포넌트 파일에서 styled-components 선언을 컴포넌트 함수 아래로 이동했다.
36 changes: 33 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"styled-components": "^6.4.2"
"styled-components": "^6.4.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "^18.2.66",
Expand Down
Loading