본문으로 건너뛰기
DEVKBS
개발 노트
상태관리읽는 데 약 5분

이 값의 주인이 누구인지부터 정한다

서버 상태와 클라이언트 상태를 Zustand 스토어 하나에 섞어 놨다가, 어떤 값을 언제 새로 받아야 하는지 아무도 답하지 못하게 됐습니다. 그 뒤로 정한 기준을 적었습니다.

#Zustand#TanStack Query#상태관리#아키텍처

상태 관리 라이브러리를 고르는 글은 많은데, 실무에서 더 자주 부딪히는 질문은 '이 값을 어디에 둘 것인가'입니다. 라이브러리를 뭘로 정하든 이 질문에 답하지 못하면 같은 문제가 생깁니다.

한 스토어에 다 넣었더니

금 매입 서비스를 만들 때의 일입니다. 시세, 접수 내역, 입력 중인 폼 값, 현재 단계를 전부 하나의 Zustand 스토어에 넣었습니다. 처음에는 편했습니다. 어디서든 useStore()로 꺼내 쓸 수 있으니 코드가 짧았습니다.

그런데 화면이 늘어나면서 답하기 어려운 질문들이 나오기 시작했습니다.

  • 접수 내역을 스토어에 넣어 뒀는데, 다른 탭에서 접수를 취소했다면 이 값은 언제 갱신되는가?
  • 시세를 스토어에 복사해 뒀는데, 원본이 바뀌면 누가 이 복사본을 지우는가?
  • 화면을 나갔다 들어오면 스토어의 값은 남아 있어야 하는가, 지워져야 하는가?

이 질문들에 매번 다르게 답하고 있었습니다. 어떤 값은 화면 진입 시 다시 받아 왔고, 어떤 값은 그냥 남아 있었습니다. 규칙이 없으니 새 화면을 만들 때마다 다시 고민해야 했고, 결국 '이 화면에서는 왜 옛날 데이터가 보이지'라는 버그가 반복됐습니다.

나누는 기준은 누가 바꿀 수 있는 값인가

다시 보니 성격이 다른 두 종류를 한 통에 넣은 게 문제였습니다.

서버 상태클라이언트 상태
기준이 되는 곳서버이 브라우저
예시세, 접수 내역, 사용자 정보입력 중인 값, 모달 열림, 선택된 탭
다른 사람도 바꾸는가그렇다아니다
오래되면 틀리는가그렇다. 무효화가 필요하다아니다
새로고침하면다시 받아야 한다초기화되어도 대체로 괜찮다
담당TanStack Query · SWRZustand · useState

기준은 다른 사람이 바꿀 수 있는 값인지 하나입니다. 시세는 제가 보고 있는 동안에도 바뀌고, 접수 내역은 다른 기기에서 취소될 수 있습니다. 이런 값은 제 브라우저가 최신 값을 알 수 없으니 언제나 '마지막으로 본 값'일 뿐이고, 코드 구조도 그 사실이 드러나게 짜야 합니다.

서버 응답은 스토어에 복사하지 않는다

가장 흔한 실수이고 저도 했던 실수입니다. 쿼리로 받아 온 데이터를 useEffect로 스토어에 넣어 두는 패턴입니다.

tsx하지 말 것: 서버 데이터의 복사본이 스토어에 생긴다
const { data } = useQuery({ queryKey: ["rate"], queryFn: fetchRate });
const setRate = useStore((s) => s.setRate);

useEffect(() => {
  if (data) setRate(data); // ← 서버 데이터의 복사본이 스토어에 생긴다
}, [data, setRate]);

이렇게 하면 같은 데이터가 쿼리 캐시와 스토어 두 곳에 존재하게 됩니다. 쿼리 캐시는 스스로 무효화되지만 스토어의 복사본은 그렇지 않습니다. 어느 시점부터는 두 값이 다르고, 어느 쪽을 읽었느냐에 따라 화면이 달라집니다.

tsx서버 데이터는 쿼리에서 바로 읽는다
// 여러 컴포넌트가 같은 키로 호출해도 요청은 한 번이다
const { data: rate } = useQuery({
  queryKey: ["rate"],
  queryFn: fetchRate,
  refetchInterval: 5_000,
});

// 클라이언트 상태만 스토어에서
const weight = useStore((s) => s.weight);

실시간 구독은 한 곳에서만 한다

입찰 플랫폼에서는 시세가 헤더, 입찰 폼, 목록 행, 요약 카드에 동시에 나옵니다. 처음에는 각 컴포넌트가 알아서 구독했는데, 연결이 여러 개 생기고 갱신 타이밍이 미묘하게 어긋나 화면 안에서 서로 다른 숫자가 잠깐씩 보였습니다.

구독은 최상위에서 한 번만 하고, 받은 값을 스토어에 넣어 아래로 내려주는 구조로 바꿨습니다. 앞에서 복사하지 말라고 한 것과 모순처럼 보이지만, 여기서 스토어는 캐시 역할이 아니라 구독 결과를 받아 두는 창구입니다. 쿼리 캐시가 맡지 않는 영역이기도 하고요.

tsx구독은 하나, 소비는 selector로
// 최상위에서 한 번만
useRateSubscription();

// 소비하는 쪽 — 필요한 필드만 구독한다
const gold = useRateStore((s) => s.gold);   // silver가 바뀌어도 리렌더되지 않는다
const silver = useRateStore((s) => s.silver);

selector로 리렌더 범위를 좁힌다

스토어 전체를 구독하면 관계없는 필드가 바뀌어도 리렌더됩니다. 계산기 화면에서 이걸 제대로 겪었습니다. 입력 한 글자마다 폼 아래 모든 컴포넌트가 다시 그려져 타이핑이 밀렸습니다.

tsx객체를 새로 만들어 반환하면 매번 리렌더된다
// 나쁨 — 매 렌더마다 새 객체라 항상 다르다고 판단된다
const { weight, purity } = useStore((s) => ({ weight: s.weight, purity: s.purity }));

// 좋음 — 원시값을 각각 구독
const weight = useStore((s) => s.weight);
const purity = useStore((s) => s.purity);

// 여러 값을 묶어야 한다면 shallow 비교를 명시한다
import { useShallow } from "zustand/react/shallow";
const { weight, purity } = useStore(useShallow((s) => ({ weight: s.weight, purity: s.purity })));

경계를 정하고 나서 달라진 것

기준을 세우고 나니 새 화면을 만들 때 상태를 어디에 둘지 고민하는 시간이 거의 없어졌습니다. 질문이 하나로 줄었기 때문입니다. 이 값을 다른 사람이 바꿀 수 있는가. 그렇다면 서버가 주인입니다.

코드 리뷰에서 나오는 얘기도 달라졌습니다. 전에는 "이거 스토어에 넣을까요 말까요" 같은 취향 논쟁이었는데, 이제는 "이 값은 서버 데이터인데 스토어에 복사돼 있네요" 하고 사실을 확인하는 정도로 끝납니다.

이 글이 나온 작업