Navigation

React Query and Zustand

2024년 1월 Vortex DAO 작업을 정리하면서 같은 게시물이 여러 화면에 나타날 때 변경을 어떻게 반영할지 기록했습니다. 댓글 삭제, 좋아요, 프로필 변경을 목록과 상세에 함께 보여 주는 일이 출발점이었습니다. 당시 React Query v4와 Zustand v4를 사용했습니다.

당시 선택은 게시물 같은 데이터를 id로 모은 스토어였습니다. 이번에 원문과 코드를 다시 읽으며 그 구조가 해결한 문제와 새로 맡아야 했던 책임을 함께 정리했습니다. React Query 자체의 한계로 일반화했던 설명도 고쳤습니다.

query key가 늘어날 때 놓친 것

게시물 목록, 상세와 내 게시물 목록은 서로 다른 query key를 사용했습니다. 삭제 하나를 반영할 때 관련 목록을 찾아 다시 요청하는 코드가 여러 mutation에 흩어졌습니다. 프로필 사진처럼 여러 종류의 응답에 같은 정보가 들어가는 경우도 있었습니다.

문제는 무효화 기능의 존재 여부보다 변경의 영향 범위를 어디에 적어 두느냐였습니다. 관련 key를 빠뜨리면 일부 화면에 이전 값이 남았습니다. 필요 이상으로 다시 요청하면 응답을 기다려야 했습니다.

React Query만으로도 대응할 수 있습니다. mutation 응답으로 setQueryData를 갱신하거나 낙관적 갱신 뒤 실패 시 되돌리는 방법이 있습니다. 이 기능을 쓸 수 없어서 스토어를 도입한 것은 아닙니다. mutation 응답 반영, 낙관적 갱신

응답의 목록과 항목의 값을 나눴습니다

원문에서 정리한 구조는 목록 응답의 id와 순서를 유지하고 항목의 값은 스토어에서 읽는 방식이었습니다. 응답을 ViewModel로 바꾸는 converter도 스토어를 구독했습니다. 삭제한 게시물의 상태를 id로 갱신하면 같은 항목을 참조하는 화면이 그 변경을 읽게 했습니다.

flowchart LR
    Response["조회 응답"] --> Membership["목록별 id와 순서"]
    Response --> Entity["id별 항목 스토어"]
    Mutation["변경 요청의 결과"] --> Entity
    Membership --> Converter["converter"]
    Entity --> Converter
    Converter --> VM["ViewModel"]
    VM --> View["목록과 상세"]

다음은 당시 코드의 역할을 단순화한 예시입니다. 실제 API 이름과 전체 스토어 구현은 생략했습니다.

id를 기준으로 조회 결과와 화면 값을 연결ts
const acceptPosts = (posts: PostResponse[]) => {
  entityStore.getState().upsertPosts(posts)
  return posts.map((post) => post.id)
}

const usePostCards = (ids: number[]) => {
  const posts = useEntityStore((state) => state.posts)
  return ids.map((id) => toPostCard(posts[id]))
}

const markDeleted = (id: number) => {
  entityStore.getState().updatePost(id, { isDeleted: true })
}

이 예시는 목록의 순서까지 전역 스토어 하나에 합친다는 뜻은 아닙니다. 검색 조건과 페이지에 따른 목록 구성을 보존하면서 같은 id의 표시 값을 모으는 구분입니다. 항목이 없을 때의 처리와 스토어 selector의 참조 안정성은 별도로 구현해야 합니다.

저장소가 하나 더 생기면 책임도 늘어납니다

원문은 “네트워크 응답은 항상 최신”이라고 전제했습니다. 지금은 이 전제를 쓰지 않습니다. 먼저 보낸 조회가 늦게 도착해 방금 반영한 변경을 덮을 수 있고 서버가 아직 변경 전 데이터를 반환할 수도 있습니다.

상황함께 정할 규칙
조회와 변경의 응답 순서가 뒤집힘진행 중 조회 취소, 서버 버전 비교 또는 다시 조회할 시점
낙관적으로 바꾼 뒤 요청 실패되돌릴 값, 동시 변경과 충돌할 때의 처리
목록에서 삭제되거나 순서가 바뀜항목 값과 목록의 포함 관계를 각각 갱신
React Query 캐시는 비고 스토어만 남음두 저장소의 만료와 재진입 정책
로그아웃 또는 계정 전환사용자별 데이터와 목록 상태를 함께 초기화

따라서 “화면은 스토어만 본다”는 규칙을 선택한다면 조회 응답을 누가 병합하고 변경 응답을 누가 반영하는지까지 정해야 합니다. 캐시와 스토어를 모두 수정하면서 어느 쪽이 기준인지 모르는 구조는 피하고 싶습니다.

지금 같은 문제를 만난다면

목록과 상세 몇 곳을 갱신하는 정도라면 React Query 안에서 key를 모아 관리하는 방법부터 검토하겠습니다. mutation이 반환한 항목을 캐시에 반영하고 필요한 조회를 무효화하면 별도 항목 스토어 없이 끝낼 수 있습니다. 낙관적 갱신을 한다면 진행 중 조회 취소와 이전 값 보관, 실패 시 복원도 함께 다룹니다.

여러 목록이 같은 항목을 반복해서 참조하고 그 항목을 id로 갱신하는 작업이 많다면 별도 스토어를 검토할 수 있습니다. 다만 그 선택의 비용에는 병합, 만료, 충돌과 목록 포함 관계가 들어갑니다. 어느 라이브러리를 골랐는지보다 하나의 변경이 어느 데이터에 영향을 주는지 설명할 수 있는지가 기준입니다.

목록을 떠났다가 돌아오는 문제는 데이터 갱신과 별개로 가상 스크롤 복원에서 다뤘습니다. 항목이 같아도 조회 조건이나 높이가 달라지면 예전 위치를 그대로 사용할 수 없습니다.