Skip to content

Conversation

@INSANE-P
Copy link

@INSANE-P INSANE-P commented Oct 4, 2025

안녕하세요. 😎프론트엔드 리드 여러분 리뷰 받게 되어 정말 영광입니다. 🙇‍♂️

이번 미션은 기존 Context API 기반 전역 상태Zustand로 마이그레이션하는 미션이였습니다.
추가적으로 선택된 카테고리(selectedCategory)가 새로고침 이후에도 유지시켜야 하는 선택 요구사항도 middlewarepersist을 활용하여 sessionStorage 저장되도록 구현하였습니다.

이번 미션이 Zustand 상태 관리를 처음 사용하여 본 것인데 Provider를 사용하지 않아도 된다는 점과 stateactionstore 내부에서 한번에 선언이 가능하였던 점이 새롭게 느껴졌고, 상태관리 코드의 양도 많이 줄어들었던 것 같습니다.

🛠️구현 방식

🚀스토어 구현

저는 크게 UI 부분의 상태를 담당하는 modalSlice와 데이터 부분을 담당하는 restaurantSlice로 나누어서 구현을 하였습니다.(도메인 별 slice 분리)

store/appStore에서 도메인 별 slice를 합성하여 스토어 생성해 주었습니다.

📦비동기 처리 단순화

thunk나 별도 리듀서 없이 액션 내부에서 직접 비동기 처리를 수행하도록 구현하였습니다.

📨세션 영속화

partialize를 활용하여 selectedCategory 상태만을 영속화 하도록 구현하였고, 저장소는 sessionStorage를 사용하였습니다.

식당 목록 필터인 selectedCategory는 페이지 세션 동안만 유지되면 충분하다고 판단하였고 다른 탭들과 공유되는 것이 자연스럽지 않다고 판단하여 localStorage보다는 sessionStorage를 활용하여 구현하였습니다.

🗂️상태 구독 (Hooks & Selector)

useAppStore(selector)를 활용하여 부분 구독 커스텀 훅을 구현하여 컴포넌트에서 필요한 상태 값만을 구독하도록 구현하였습니다.

⏳Zustand 방식의 trade-off

➕Zustand의 장점

  • Provider 불필요: 훅만으로 전역 상태 사용 가능. 초기 셋업이 단순함
  • 정밀 구독: selector로 필요한 상태만을 구독하여 불필요한 리렌더링를 줄일 수 있음
  • 간단한 비동기 처리: 액션 내부에서 async/await로 직접 구현 가능. 보일러플레이트 적음

➖Zustand의 단점

  • 한정된 도구 생태계: DevTools·미들웨어가 Redux 대비 제한적
  • 비동기 설계 부담: 서버 상태용 내장 기능(캐시/무효화·재시도 등)이 약해 로딩/에러, 중복요청 가드, 경쟁상태 같은 비동기 설계를 개발자가 직접 구현하여야 함

-> Zustand& TanStack Query(React Query)을 함께 사용하여 이를 보안함

🗃️구조도

zustand 모식도

🎬구현 영상

zustand.mp4

❓질문사항

  1. 기존 Recoil의 전역 상태 관리 미션을 Zustand로 마이그레이션하였습니다.
    Readme 파일의 초안의 내용을 다른 상태관리 미션과 최대한 같은 느낌으로 만들고자 노력하였는데 해당 수정의 방향성이 적절하였는지 확인 부탁드립니다.

  2. 숙련도 편차로 인해 기본 요구사항이 빠르게 완료되는 경우를 보완하고, Zustand 미들웨어(persist) 경험과 영속화 범위 설계(partialize, sessionStorage) 학습을 유도하기 위해, 선택 요구사항으로 selectedCategorypersist로 세션 영속화하는 요구사항을 추가하였는데 해당 요구사항에 대해서 어떻게 생각하시는지 궁금합니다.

항상 감사합니다.🌟

그리디 x zustand

참고자료

Zustand 공식 문서
Zustand 핵심 정리 블로그
slice pattern 관련 공식 문서
퍼시스트 관련 공식 문서
Zustand ProviderLess인 이유에 대한 블로그
프론트엔드 상태관리 실전 편 with React Query & Zustand 영상
Zustand action 관련 권장 사항 문서

INSANE-P added 23 commits May 30, 2025 13:41
Copy link
Member

@Indigochi1d Indigochi1d left a comment

Choose a reason for hiding this comment

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

우선 그리디의 학습자료에 기여해주셔서 너무 감사하다는 말씀 먼저 드립니다 👍🏻 👍🏻

세션영속화와 추가 요구사항의 생성에 대해서 LGTM이에요. 찬빈님이 설계하신 방식이 이 미션을 접할 멤버분들에게 좋은 작용을 줄 수 있을 것 같습니다.

[식당 목록 필터인 selectedCategory는 페이지 세션 동안만 유지되면 충분하다고 판단하였고 다른 탭들과 공유되는 것이 자연스럽지 않다고 판단하여 localStorage보다는 sessionStorage를 활용하여 구현하였습니다.]

특히 이 부분이 멤버분들이 서비스 의도에 맞는 코드를 작성하고 있는지, 생각하며 코드를 만드는지 확인할 수 있다는 점에서 더 좋았던 지점이었던 것 같습니다.

대부분 코드들이 저에게는 좋아보였고 다만 몇가지 공식문서와 대비되는 지점들이 있어 그 부분을 위주로 리뷰를 남겨보았어요. 혹시 제가 착각하거나 찬빈님의 의도를 제가 이해하지 못한 것 같으시다면 티키타카하며 같이 싱크 맞춰보면 좋을 것 같습니다.

그리디 프론트엔드 파트에 너무 큰 도움이 되어주셔서 다시 한번 감사해요. 이 PR은 Approve로 두고 가겠습니다. 바로 머지하셔도 좋고 제가 남긴 부분들에 수정 ➡️ 리뷰의 과정을 한번 더 거쳐도 좋을 것 같아요. 수고하셨습니다 🙇🏻

Comment on lines 35 to 40
- Zustand
- create
- selector pattern
- set / get
- slice pattern
- middleware (persist, createJSONStorage)
Copy link
Member

Choose a reason for hiding this comment

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

이것들을 빼는 건 어떤가요? Zustand에 대해 너무 많은 것을 명시하여 제공해주는 것은 오히려 독이 될거 같다는 의견인데 찬빈님은 어떻게 생각하시나요?

Copy link
Author

@INSANE-P INSANE-P Oct 28, 2025

Choose a reason for hiding this comment

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

네 너무 많은 내용을 명시하여 스스로 학습해보는 과정을 뺏길 수 있을 것 같습니다. 아래와 같이 기본적인 키워드로 수정하겠습니다.

##  키워드

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


1. #### Context API
2. #### Recoil
2. #### Zustand
Copy link
Member

Choose a reason for hiding this comment

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

💯

Comment on lines +10 to +40
restaurantActions: {
setSelectedCategory: (category) => set({ selectedCategory: category }),

setSelectedRestaurant: (restaurant) =>
set({ selectedRestaurant: restaurant }),

fetchRestaurants: async () => {
if (get().getStatus === 'loading') return;

set({ getStatus: 'loading' });
try {
const list = await getRestaurants();
set({ restaurants: list, getStatus: 'succeeded' });
} catch {
set({ getStatus: 'failed' });
}
},

postNewRestaurant: async (newRestaurant) => {
if (get().postStatus === 'loading') return;

set({ postStatus: 'loading' });
try {
await addNewRestaurant(newRestaurant);
set({ postStatus: 'succeeded' });
} catch {
set({ postStatus: 'failed' });
}
},
},
});
Copy link
Member

Choose a reason for hiding this comment

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

Actions를 객체로 묶은 특별한 이유가 있나요? 공식문서 를 읽어보았을 때 best practice는 state와 actions가 같은 레벨에서 flat하게 위치하게 하는 것이라고 해요!

Copy link
Member

Choose a reason for hiding this comment

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

set()을 통한 원자적 업데이트는 좋아보이네요 👍🏻

Copy link
Author

@INSANE-P INSANE-P Oct 28, 2025

Choose a reason for hiding this comment

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

공식 문서에서 말하고자 하는 바를 상태와 액션을 하나의 스토어 안에서 정의는 하지만 이를 동일한 스코프로 다룬다고는 이해하지는 않았습니다.

상태의 경우 여러 값을 하나의 객체로 묶으면, 다른 상태 값이 변경될 때 참조 안정성이 깨져 불필요한 리렌더링이 발생할 수 있습니다.

반면 액션은 참조가 변하지 않는 함수이기 때문에 하나의 actions 객체로 묶어 관리하더라도 참조 안정성이 유지됩니다.

따라서 이번 구현에서는 직접 선택자(샐렉터)를 매번 작성하지 않고 단일 훅을 통해 액션을 재사용할 수 있도록 구조화하여 가독성과 유지보수성을 높이고자 하였습니다.

export const useModalActions = () => useAppStore((store) => store.modalActions);

---

  const { openRestaurantAddModal } = useModalActions();

관련 블로그

Comment on lines +20 to +35
export const useRestaurantActions = () =>
useAppStore((store) => store.restaurantActions);
export const useModalActions = () => useAppStore((store) => store.modalActions);

export const useRestaurants = () => useAppStore((store) => store.restaurants);
export const useGetStatus = () => useAppStore((store) => store.getStatus);
export const usePostStatus = () => useAppStore((store) => store.postStatus);
export const useSelectedCategory = () =>
useAppStore((store) => store.selectedCategory);
export const useSelectedRestaurant = () =>
useAppStore((store) => store.selectedRestaurant);

export const useIsAddModalOpen = () =>
useAppStore((store) => store.isRestaurantAddModalOpen);
export const useIsDetailModalOpen = () =>
useAppStore((store) => store.isRestaurantDetailModalOpen);
Copy link
Member

Choose a reason for hiding this comment

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

이렇게 했을 경우에 state별로 1:1 훅을 매번 만들어야할 것 같아요. 공식문서에서는 선택자 훅을 별도로 만들지 않고 컴포넌트에서 직접선택자를 사용하도록 하는 것을 권장하더라구요. 제가 착각한 부분이 있다면 공유해주세요!

Copy link
Author

@INSANE-P INSANE-P Oct 28, 2025

Choose a reason for hiding this comment

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

공식 문서에서 스토어 값을 사용할 때 직접 선택자(셀렉터)를 사용하라고 하는 이유는 참조 안정성을 확보해 불필요한 리렌더링을 방지하기 위함으로 이해하였습니다.

제가 커스텀 훅을 사용한 이유는 해당 값을 사용하는 각 컴포넌트마다 동일한 셀렉터를 반복 작성하지 않음으로써 재사용성을 높이며 선택자 작성 시 발생할 수 있는 실수를 줄이기 위함이었습니다.

현재 프로젝트 규모에서는 특정 값을 많은 컴포넌트에서 공유하는 상황이 많이 없어서 다소 과한 구조처럼 보일 수 있지만 추후 스토어 구조가 확장되거나 상태 접근이 많아질 경우 유지보수성 측면에서 도움이 될 수 있을 것이라고 판단했습니다.

즉 특정 값 접근에 직접 선택자를 사용하면서 재사용성을 높이고자 하였습니다.

tkdodo의 Zustand 관련 참고 자료

Comment on lines +6 to +18
export const useAppStore = create(
persist(
(set, get) => ({
...createRestaurantSlice(set, get),
...createModalSlice(set),
}),
{
name: 'selectedCategory-storage',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({ selectedCategory: state.selectedCategory }),
}
)
);
Copy link
Member

Choose a reason for hiding this comment

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

공식문서의 best practice를 잘 따른 코드 같아요👍🏻


### 😗구현 예시

- 컴포넌트의 이름이나 구조는 마음대로 변경해도 좋습니다.
Copy link
Member

Choose a reason for hiding this comment

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

이거 제가 문서를 만들 때 처음 썼던 문구인 거 같은데 "마음대로 변경해도 좋다" 라는게 마치 막지어도 된다는 느낌을 줄 수 있지도 않을까?라는 걱정이 드네요😢

Suggested change
- 컴포넌트의 이름이나 구조는 마음대로 변경해도 좋습니다.
- 컴포넌트의 이름이나 구조를 정한 이유가 명확해야하며 타인에게 설명할 수 있어야합니다.

위의 제안처럼 하는 것이 어떤지 검토해주시면 감사하겠읍니다 😄 02-state-management에 모든 md파일에 적용해주시면 더 좋을 것 같아요!

Copy link
Author

Choose a reason for hiding this comment

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

좋은 것 같습니다. 수정하겠습니다.🪄

@Indigochi1d Indigochi1d added the enhancement New feature or request label Oct 25, 2025
Copy link
Author

@INSANE-P INSANE-P left a comment

Choose a reason for hiding this comment

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

범수님 바쁘실텐데 리뷰 남겨주셔서 감사합니다. 🚀

이번 마이그레이션을 하면서 저도 Zustand가 조금 익숙해진 것 같아서 좋았습니다.
범수님이 남겨주신 리뷰에 대해서 제 의견을 남겨보았는데 추가적으로 수정해야 할 부분이 있다면 말씀해주시면 감사하겠습니다.😏🛠️

Comment on lines 35 to 40
- Zustand
- create
- selector pattern
- set / get
- slice pattern
- middleware (persist, createJSONStorage)
Copy link
Author

@INSANE-P INSANE-P Oct 28, 2025

Choose a reason for hiding this comment

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

네 너무 많은 내용을 명시하여 스스로 학습해보는 과정을 뺏길 수 있을 것 같습니다. 아래와 같이 기본적인 키워드로 수정하겠습니다.

##  키워드

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

Comment on lines +20 to +35
export const useRestaurantActions = () =>
useAppStore((store) => store.restaurantActions);
export const useModalActions = () => useAppStore((store) => store.modalActions);

export const useRestaurants = () => useAppStore((store) => store.restaurants);
export const useGetStatus = () => useAppStore((store) => store.getStatus);
export const usePostStatus = () => useAppStore((store) => store.postStatus);
export const useSelectedCategory = () =>
useAppStore((store) => store.selectedCategory);
export const useSelectedRestaurant = () =>
useAppStore((store) => store.selectedRestaurant);

export const useIsAddModalOpen = () =>
useAppStore((store) => store.isRestaurantAddModalOpen);
export const useIsDetailModalOpen = () =>
useAppStore((store) => store.isRestaurantDetailModalOpen);
Copy link
Author

@INSANE-P INSANE-P Oct 28, 2025

Choose a reason for hiding this comment

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

공식 문서에서 스토어 값을 사용할 때 직접 선택자(셀렉터)를 사용하라고 하는 이유는 참조 안정성을 확보해 불필요한 리렌더링을 방지하기 위함으로 이해하였습니다.

제가 커스텀 훅을 사용한 이유는 해당 값을 사용하는 각 컴포넌트마다 동일한 셀렉터를 반복 작성하지 않음으로써 재사용성을 높이며 선택자 작성 시 발생할 수 있는 실수를 줄이기 위함이었습니다.

현재 프로젝트 규모에서는 특정 값을 많은 컴포넌트에서 공유하는 상황이 많이 없어서 다소 과한 구조처럼 보일 수 있지만 추후 스토어 구조가 확장되거나 상태 접근이 많아질 경우 유지보수성 측면에서 도움이 될 수 있을 것이라고 판단했습니다.

즉 특정 값 접근에 직접 선택자를 사용하면서 재사용성을 높이고자 하였습니다.

tkdodo의 Zustand 관련 참고 자료

Comment on lines +10 to +40
restaurantActions: {
setSelectedCategory: (category) => set({ selectedCategory: category }),

setSelectedRestaurant: (restaurant) =>
set({ selectedRestaurant: restaurant }),

fetchRestaurants: async () => {
if (get().getStatus === 'loading') return;

set({ getStatus: 'loading' });
try {
const list = await getRestaurants();
set({ restaurants: list, getStatus: 'succeeded' });
} catch {
set({ getStatus: 'failed' });
}
},

postNewRestaurant: async (newRestaurant) => {
if (get().postStatus === 'loading') return;

set({ postStatus: 'loading' });
try {
await addNewRestaurant(newRestaurant);
set({ postStatus: 'succeeded' });
} catch {
set({ postStatus: 'failed' });
}
},
},
});
Copy link
Author

@INSANE-P INSANE-P Oct 28, 2025

Choose a reason for hiding this comment

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

공식 문서에서 말하고자 하는 바를 상태와 액션을 하나의 스토어 안에서 정의는 하지만 이를 동일한 스코프로 다룬다고는 이해하지는 않았습니다.

상태의 경우 여러 값을 하나의 객체로 묶으면, 다른 상태 값이 변경될 때 참조 안정성이 깨져 불필요한 리렌더링이 발생할 수 있습니다.

반면 액션은 참조가 변하지 않는 함수이기 때문에 하나의 actions 객체로 묶어 관리하더라도 참조 안정성이 유지됩니다.

따라서 이번 구현에서는 직접 선택자(샐렉터)를 매번 작성하지 않고 단일 훅을 통해 액션을 재사용할 수 있도록 구조화하여 가독성과 유지보수성을 높이고자 하였습니다.

export const useModalActions = () => useAppStore((store) => store.modalActions);

---

  const { openRestaurantAddModal } = useModalActions();

관련 블로그


### 😗구현 예시

- 컴포넌트의 이름이나 구조는 마음대로 변경해도 좋습니다.
Copy link
Author

Choose a reason for hiding this comment

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

좋은 것 같습니다. 수정하겠습니다.🪄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants