-
Notifications
You must be signed in to change notification settings - Fork 0
[Step2.2] hippo: Zustand 적용하기 #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
meteorqz6
wants to merge
9
commits into
hippo-adv-2.1
Choose a base branch
from
hippo-adv-2.2
base: hippo-adv-2.1
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
656e067
docs: 미션 요구사항 추가
meteorqz6 2d22855
chore: zustand 패키지 설치
meteorqz6 7951feb
feat: Zustand 스토어 생성
meteorqz6 5421af2
refactor: Context API 제거 및 Provider 의존성 제거
meteorqz6 a4e3ea6
refactor: 컴포넌트에서 Zustand 스토어 구독으로 교체
meteorqz6 2726236
feat: selectedCategory 스토어로 이동 및 persist 적용
meteorqz6 061f77f
refactor: App에서 selectedCategory를 스토어로 교체
meteorqz6 734c152
docs: 리드미 업데이트
meteorqz6 8691f89
refactor: localStorage에서 sessionStorage로 전환
meteorqz6 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 제거 | ||
| ### 이벤트 핸들러와 스토어 액션의 분리 | ||
|
|
||
| 스토어 액션은 순수한 값만 받도록 하고, 이벤트 객체 처리는 컴포넌트에 남겼다. | ||
|
|
||
| ```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 선언을 컴포넌트 함수 아래로 이동했다. | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[배움]
저도 같은 구조로 구현했는데 명확하게 정리하지 못하고 넘어갔던 부분을 정리해주셔서 이해가 편해졌습니다!