React Query와 Zustand를 함께 쓸 때 상태의 역할 나누기
목차
시작한 상황 #
WYDT의 일정 화면은 작은 UI 목업에서 시작했습니다. 이후 사용자 조회, 일정 목록, 필터, 태그, 생성·수정 모달이 차례로 붙었습니다.
처음에는 상태 관리 도구를 하나만 정하면 구조도 자연스럽게 정리될 것이라고 생각했습니다. 실제로는 어떤 도구를 쓰는지보다 각 상태의 원본이 어디에 있는지를 먼저 정해야 했습니다.
일정 목록은 서버가 원본입니다. 다른 화면이나 사용자가 데이터를 바꾸면 현재 브라우저가 가진 값은 오래된 값이 될 수 있습니다. 반대로 현재 선택한 필터와 모달의 열림 여부는 서버가 알 필요가 없습니다.
그래서 다음 기준으로 역할을 나눴습니다.
- 서버 응답이 원본인 데이터는 React Query가 관리합니다.
- 현재 브라우저에서만 의미가 있는 UI 상태는 Zustand가 관리합니다.
- 같은 일정 목록을 두 저장소에 복사하지 않습니다.
React 문서에서는 상태마다 하나의 소유자를 정하는 방식을 single source of truth라고 설명합니다. 모든 상태를 한곳에 모으라는 뜻은 아닙니다. 같은 상태의 원본을 여러 곳에 만들지 말라는 기준에 가깝습니다.
서버에서 온 일정은 React Query에 둡니다 #
일정 목록에는 조회 결과만 있는 것이 아닙니다. loading과 error 상태가 있고, 이전 응답을 cache할 기간과 다시 조회할 시점도 필요합니다. 생성이나 수정 이후에는 현재 목록이 더 이상 최신이 아니라는 사실도 표현해야 합니다.
TanStack Query 공식 문서는 이 역할을 서버와 클라이언트 사이의 비동기 상태 관리로 구분합니다. Query는 조회와 cache를 담당하고, Mutation은 서버 상태 변경을 담당합니다.
아래 코드는 당시 파일을 그대로 옮긴 것이 아닙니다. 일정 조회의 책임만 남겨 현재 TanStack Query v5 문법으로 줄인 예제입니다.
const schedulesQuery = useQuery({
queryKey: ["schedules", userId],
queryFn: () => getSchedules(userId),
enabled: Boolean(userId),
})
일정을 수정한 뒤에는 Zustand의 일정 배열을 직접 고치지 않습니다. 서버 변경이 성공하면 관련 Query를 stale 상태로 만들고 다시 동기화합니다.
const queryClient = useQueryClient()
const updateScheduleMutation = useMutation({
mutationFn: updateSchedule,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["schedules", userId],
})
},
})
invalidateQueries는 관련 Query를 stale 상태로 표시하고, 화면에서 사용 중인 Query를 다시 가져올 수 있게 합니다. 이 흐름을 사용하면 서버 데이터와 별도의 전역 배열을 수동으로 맞출 필요가 줄어듭니다.
서버가 수정된 일정 전체를 응답한다면 setQueryData로 cache를 바로 갱신할 수도 있습니다. 어느 방식을 선택하든 일정 데이터의 소유자는 Query cache 하나로 유지하는 것이 중요했습니다.
필터와 모달은 Zustand에 둡니다 #
필터와 모달 상태는 성격이 다릅니다. 사용자가 work 태그를 선택하거나 편집 모달을 닫았다고 해서 서버 데이터를 다시 가져올 필요는 없습니다.
이런 값은 여러 일정 컴포넌트가 함께 사용하지만 현재 화면 안에서만 의미가 있습니다. Zustand store에는 이 상태와 상태를 바꾸는 action만 두었습니다.
type ScheduleUiState = {
selectedTag: string | null
editingScheduleId: string | null
setSelectedTag: (tag: string | null) => void
openEditor: (scheduleId: string) => void
closeEditor: () => void
}
export const useScheduleUiStore = create<ScheduleUiState>()((set) => ({
selectedTag: null,
editingScheduleId: null,
setSelectedTag: (selectedTag) => set({ selectedTag }),
openEditor: (editingScheduleId) => set({ editingScheduleId }),
closeEditor: () => set({ editingScheduleId: null }),
}))
컴포넌트에서는 store 전체가 아니라 필요한 값만 선택합니다.
const selectedTag = useScheduleUiStore((state) => state.selectedTag)
const schedules = schedulesQuery.data ?? []
const visibleSchedules = selectedTag
? schedules.filter((schedule) => schedule.tags.includes(selectedTag))
: schedules
Zustand 문서는 selector로 필요한 상태만 구독하는 방식을 기본 사용법으로 설명합니다. 일정 목록은 React Query에서 읽고, 필터 조건만 Zustand에서 읽으면 두 상태의 경계도 코드에 그대로 드러납니다.
두 저장소에 같은 데이터를 넣지 않습니다 #
가장 피하고 싶었던 구조는 React Query로 받은 일정 목록을 다시 Zustand에 저장하는 방식이었습니다.
// 피한 구조
const { data } = useQuery({ ... })
useEffect(() => {
setSchedules(data ?? [])
}, [data, setSchedules])
이 구조에서는 Query cache와 Zustand 중 어느 값이 최신인지 다시 판단해야 합니다. 수정 성공, background refetch, 화면 재진입 같은 시점마다 두 값을 맞추는 코드도 필요합니다.
화면에서는 두 저장소의 값을 복사하지 않고 조합했습니다. 서버가 가진 일정과 브라우저가 가진 필터는 서로 다른 상태입니다. 렌더링할 때 두 값을 결합하면 충분했습니다.
다만 모든 필터를 Zustand에 넣어야 하는 것은 아닙니다. URL로 공유하거나 새로고침 뒤에도 유지해야 하는 필터라면 search params가 더 적절할 수 있습니다. 저는 먼저 상태가 유지되어야 하는 범위를 정하고, 그다음 저장 위치를 선택하는 편이 낫다고 생각합니다.
정리 #
React Query와 Zustand를 함께 사용하는 것 자체는 복잡하지 않았습니다. 어려웠던 부분은 각 상태의 소유권을 정하는 일이었습니다.
서버가 원본이면 React Query에 두고, 현재 화면의 상호작용이면 Zustand에 뒀습니다. 두 도구 사이에 같은 데이터를 복사하지 않으니 생성·수정 이후의 동기화 흐름도 단순해졌습니다.