React Query와 Zustand
2024-01 Vortex DAO 작업 중 사내에 공유한 기술 문서. React Query의 캐시 무효화만으로는 여러 화면에 걸친 상태 변경을 따라가기 어려워 FLUX 개념과 Zustand 스토어를 조합한 방법을 정리했다. 원문 PDF는 git 이력에 있다.
상황
최신 정보를 즉시 반영해야 하는 화면이 많았다.
- 댓글을 쓰거나 지우면 목록에 바로 반영돼야 한다.
- 프로필 이미지를 바꾸면 글, 댓글, 다른 화면의 이미지가 함께 바뀌어야 한다.
- 알림을 읽으면 새로고침 없이 읽음 상태가 돼야 한다.
- 장바구니, 주문 상태처럼 자주 바뀌는 상태가 여러 화면에 걸쳐 있다.
문제
React Query는 엔드포인트마다 query key로 캐시를 관리한다. 변경을 반영할 곳이 여러 화면이면 그만큼 query key를 찾아 무효화해야 한다. 게시글 목록, 게시글 상세, 내 게시글 목록에서 좋아요 하나를 반영하려면 최소 세 개의 key를 찾아 초기화해야 했고, 키가 수십 개로 늘어나자 어디에 어떤 키가 묶였는지 파악하기 어려워졌다.
queryClient?.invalidateQueries({ queryKey: ['useGetBasicUserInfo'], refetchType: 'all' })
queryClient?.invalidateQueries({ queryKey: ['useQueryGetWalletInfo'], refetchType: 'all' })
queryClient?.invalidateQueries({ queryKey: ['useTokenExchangeHistories', 0], refetchType: 'all' })
정리하면 React Query의 캐싱 전략은 복잡한 상태를 관리해야 할 때 불편하다.
해결
FLUX의 단방향 흐름 개념만 가져와 Zustand 스토어를 중앙에 두었다. 구조와 원칙을 그대로 옮긴 것은 아니다.
| 구간 | 기존 방식 | 스토어 방식 |
|---|---|---|
| 데이터 패칭 후 | 응답을 ViewModel로 가공해 뷰에 전달 | 응답을 스토어에 저장하고 converter에도 전달. converter는 응답의 id만 뽑아 스토어 모델을 조회한 뒤 ViewModel로 가공 |
| 뷰 이벤트 | 관련 query key를 찾아 캐시를 무효화하고 다시 요청 | 업데이트 요청만 보내고 조회 쿼리는 무효화하지 않음. 뷰가 참조하는 스토어 값을 갱신하면 converter가 스토어를 구독하고 있어 ViewModel이 따라 바뀜 |
네트워크로 받은 데이터는 항상 최신이라는 전제 아래 스토어를 갱신하고, 컴포넌트는 스토어만 바라본다.
export const getSampleList = (): Promise<SampleResponse[]> =>
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)
return getFeedList(flatRes.map((value) => value.id) as number[])
}
const getPostsVM = (res?: PostCardResponse[]) =>
res && getFeedList(res.map((value) => value.id) as number[])
return { getInfinitePostsVM, getPostsVM }
}
export const useDeletePostMutate = ({ channelId }: { channelId?: string }) => {
const { mutateAsync: deletePostAsync } = useMutation(
['useDeletePostMutate'],
(postId: string) => deletePost(postId),
{ onSuccess: (res, req) => useStore.getState().feedSlice.update({ id: +req, isDeleted: true }) },
)
return { deletePostAsync }
}
결론
모든 곳에 스토어 방식을 쓸 필요는 없다. 포스트, 상품, 좋아요, 팔로우처럼 상태 변경이 잦은 도메인에만 쓰고, 나머지는 query key 무효화로 충분하다. 어느 방식을 쓸지 설계 단계에서 먼저 정한다.