React-query & Zustand 사용하여 복잡한 상태 관리하기
목차
개요 및 목적
상황 예시
문제 설명
기존 해결책 소개
문제 해결 방식
FLUX 패턴 & ZUSTAND
결론
예시 코드
개요 및 목적
이 문서는 프로젝트 구축 시 복잡한 상태관리가 필요한 경우 리액트 쿼리의 캐싱 전략에 의한 개발 이슈를 공유하고,
어떤 방식으로 그 이슈들을 해결하였는지 설명하기 위해 작성되었습니다.
상황 예시
댓글: 사용자가 댓글을 작성하거나 삭제한 후에는 해당 작업이 즉시 반영되어야 합니다. 새로 작성된 댓글은 즉시 목록에 나타나야 하고, 삭제된 댓글은 화면에서 사라져야 합니다.
프로필 변경: 사용자가 프로필을 변경한 경우, 변경된 정보는 모든 관련된 부분에서 실시간으로 업데이트되어야 합니다. 예를 들어, 프로필 이미지를 변경한 경우 해당 이미지는 사용자의 글, 댓글, 또는 다른 곳에서 즉시 반영되어야 합니다.
알림 기능: 사용자가 알림을 확인하거나 읽은 후에는 이를 바로 반영해야 합니다. 읽은 알림은 새로고침 없이 화면에서 사라져야 하며, 읽지 않은 알림은 읽은 상태로 표시되어야 합니다.
상태 업데이트: 어플리케이션의 다양한 상태를 관리하는 경우, 예를 들어 장바구니 내 상품 추가 또는 삭제, 주문 상태 변경 등에 대한 실시간 업데이트가 필요합니다. 사용자가 상태를 변경하면 화면에 즉시 반영되어야 합니다.
문제 설명
위의 상황과 같이 최신 정보를 즉각적으로 반영하기 위해서는 리액트 쿼리의 캐시를 적절하게 관리해주어야 합니다.
하지만 리액트 쿼리의 캐시 관리 방법은 엔드포인트마다 쿼리키로 관리를 하기 때문에 변경된 상태를 반영해야하는 곳이 여러곳일 때 관리하기가 어려워집니다.
간단한 예로 게시글 목록과 게시글 상세와 나의 게시글 목록 등 3- 5개의 게시글의 좋아요를 반영해야할 경우 최소 3개의query-key를 찾아서 초기화 시켜야합니다. 많아질수록 해당query-key들을 찾아내고 초기화하고 또 수정사항이 발생했을때도 어디에query-key가 묶여있는지 파악하기가 쉽지 않다는 문제점도 같이 존재합니다.
정리하자면[리액트 쿼리의 캐싱 전략은 복잡한 상태를 관리해야하는 경우 사용하기 불편하다. ]입니다.
기존 해결책 소개
queryClient?.invalidateQueries({ queryKey: ['useGetBasicUserInfo'], refetchType: 'all', }); queryClient?.invalidateQueries({ queryKey: ['useQueryGetWalletInfo'], refetchType: 'all', }); queryClient?.invalidateQueries({ queryKey: ['useTokenExchangeHistories', 0], refetchType: 'all', });


기존에는 업데이트를 할 때 마다 최신 정보를 가져오기 위해query-key들을 찾아서 초기화를 시켰습니다.
이 방식의 가장 큰 불편함은[ 초기화 시켜야되는 키값이 어디에 묶여있는지, 너무 많은 키들이 흩어져있다. ]입니다.
개발이 진행될수록 키들은 수십개가 넘어가고 개발 당시에는 키를 관리하는 전략도 따로 없었기 때문에 효율적으로 관리하는것도 불가능했습니다.
문제 해결 방식
이러한 불편함을 해소하기 위해 flux 패턴을 도입하는것을 고려했습니다.
flux는 페이스북에서 개발한 애플리케이션 아키텍처 패턴 중 하나이며, 데이터 흐름을 단방향으로 관리하여 예측 가능하고 유지보수에 용이합니다.
FLUX 패턴의 구조와 원칙을 동일하게 가져간 것은 아니지만 개념적인 부분을 사용했으며, 간단한 설명은 아래를 확대하시면 보실 수 있습니다.

FLUX 패턴의 구조
Actions (액션):
애플리케이션에서 발생하는 모든 작업 또는 이벤트를 나타냅니다.
주로 사용자 상호 작용, 서버에서의 데이터 수신, 라우터 업데이트 등을 포함합니다.
각 액션은 유형(type)과 필요에 따라 추가적인 데이터(payload)를 가집니다.
Dispatcher (디스패처):
Flux 아키텍처의 핵심 요소 중 하나로, 액션을 받아서 등록된 모든 스토어에 알리는 역할을 합니다.
단방향 데이터 흐름을 유지하기 위해 중앙 집중식으로 애플리케이션의 상태를 관리합니다.
Stores (스토어):
애플리케이션의 상태와 데이터를 보관하고 관리합니다.
스토어는 Dispatcher로부터 액션을 받아 상태를 업데이트하고, 변경 사항을 View에 알리는 역할을 합니다.
여러 개의 스토어가 있을 수 있으며, 각 스토어는 특정 데이터 도메인을 관리합니다.
Views (뷰):
사용자 인터페이스를 나타내며, 상태를 표시하고 사용자 입력에 반응합니다.
스토어의 상태를 구독하여 변경 사항을 감지하고, 필요한 경우 상태를 업데이트하기 위해 액션을 디스패치합니다.

FLUX 패턴의 원칙
단방향 데이터 흐름: 데이터는 한 방향으로만 흐릅니다. 액션 → 디스패처 → 스토어 → 뷰 → 사용자 입력 및 다시 액션.
상태 변화는 예측 가능하고 불변성을 유지합니다.
뷰와 상태를 분리하여 상호 의존성을 최소화합니다.
중앙 집중식 데이터 흐름으로 애플리케이션의 복잡성을 관리합니다.
이러한 단방향 데이터 플로우 덕분에 복잡한 상태관리의 경우 불편함을 느꼈던query-key에서 벗어날 수 있었습니다.
FLUX 패턴 & ZUSTAND
우선,FLUX패턴을 도입하기 위해zustand라이브러리를 함께 사용했습니다.zustand는 상태관리를 쉽게 할 수 있도록 도와주는 라이브러리입니다. 자세한 설명은 이 문서에서는 다루지 않겠습니다.
이제 앞으로 최신 데이터를 반영하기 위해query-key를 초기화하는 경우 힘들게 각각 흩어진 키들을 찾아 드래곤볼을 모을 필요가 없습니다. 모든 데이터는zustand의 스토어를 바라보고 반영하게 될 것이기 때문입니다.
데이터 플로우
FLUX 패턴 구조를 적용하여 데이터를 일방향으로 전달합니다.
상태 변경 시 주스탠드의 데이터를 업데이트하고, 이를 바라보고 있는 뷰들이 상태를 갱신합니다.
데이터 패칭 후 뷰모델 변환 구간


기존 방식
네트워크 요청 데이터를 뷰모델로 가공한 후 뷰에 전달합니다.
플럭스 패턴 방식
네트워크 요청 데이터를 스토어에 저장하고, 컨버터에도 전달합니다. 이후 컨버터에서는 요청 데이터의 키값들만 추출하여 스토어에 있는 모델을 조회한 뒤 뷰모델로 가공합니다.
뷰의 이벤트 처리 구간


기존 방식
뷰에서 액션이 일어나면 초기화하고자 하는query-key를 찾아 캐시를 무효화 처리합니다. 무효화 처리는 해당 쿼리를 오래된 데이터로 판단하도록 하여 네트워크에 다시 요청하도록 합니다.
플럭스 패턴 방식
뷰에서 액션이 일어나면 업데이트 요청을 보내지만 조회 쿼리를 무효화 처리하지는 않습니다. 대신 뷰에서 참조하고 있는 스토어의 값을 업데이트하도록 합니다. 스토어가 업데이트되면 컨버터에서 스토어를 구독하고 있기 때문에 뷰모델이 업데이트되고, 업데이트된 뷰모델이 뷰에 반영됩니다.
결론
네크워크를 통해 가져온 데이터는 항상 최신 데이터 상태를 반영한다는 전제하에 Zustand 스토어에 데이터를 업데이트하고 컴포넌트에서 해당 데이터만을 바라보도록 구조를 변경하였습니다.
그러나, 모든 상황에서FLUX 패턴을 적용할 필요는 없으며, 개발 시 필요한 구조에 맞춰서 진행하시는게 좋습니다.
(주로 포스트, 상품, 좋아요, 팔로우 행위 같이 같이 자주 빈번하게 상태 변경이 발생하는 경우 사용하는게 좋습니다)
쿼리키를 무효화 처리하는 경우 [query-invalidate]
플럭스 패턴을 사용하는 경우 [store]
개발 시 어떤 방식을 선택할 것인지에 대한 논의를 충분히 하신 후에 설계 및 개발을 진행하시는것을 권유드립니다.
예시 코드
export const getSampleList = (): Promise<SampleResponse[]> => { return request({ url: endpoints.sample.list, method: 'get', }).then(updateStore); };
|> 데이터를 패칭받은 후 스토어를 업데이트
export const usePostsConverter = () => { const getFeedList = useStore(state => state.feedSlice.findList); const getInfinitePostsVM = (res: InfiniteData<Page<PostCardResponse>>) => { const flatRes = infinityDataToFlatArray(res); const feeds = getFeedList(flatRes.map(value => value.id) as number[]); return feeds; }; const getPostsVM = (res?: PostCardResponse[]) => { if (res) { const feeds = getFeedList(res.map(value => value.id) as number[]); return feeds; } }; return { getInfinitePostsVM, getPostsVM }; };
|> 패칭받은 데이터에서 ID를 추출하여 스토어에 있는 모델을 요청 → 패칭 된 직후라 최신 데이터를 리턴합니다.
export const useDeletePostMutate = ({ channelId }: { channelId?: string }) => { const queryClient = useQueryClient(); const { mutateAsync: deletePostAsync } = useMutation( ['useDeletePostMutate'], (postId: string) => deletePost(postId), { onSuccess: (res, req) => { useStore.getState().feedSlice.update({ id: +req, isDeleted: true }); }, } ); return { deletePostAsync, }; };