상태 관리 라이브러리를 고르는 글은 많은데, 실무에서 더 자주 부딪히는 질문은 '이 값을 어디에 둘 것인가'입니다. 라이브러리를 뭘로 정하든 이 질문에 답하지 못하면 같은 문제가 생깁니다.
한 스토어에 다 넣었더니
금 매입 서비스를 만들 때의 일입니다. 시세, 접수 내역, 입력 중인 폼 값, 현재 단계를 전부 하나의 Zustand 스토어에 넣었습니다. 처음에는 편했습니다. 어디서든 useStore()로 꺼내 쓸 수 있으니 코드가 짧았습니다.
그런데 화면이 늘어나면서 답하기 어려운 질문들이 나오기 시작했습니다.
- 접수 내역을 스토어에 넣어 뒀는데, 다른 탭에서 접수를 취소했다면 이 값은 언제 갱신되는가?
- 시세를 스토어에 복사해 뒀는데, 원본이 바뀌면 누가 이 복사본을 지우는가?
- 화면을 나갔다 들어오면 스토어의 값은 남아 있어야 하는가, 지워져야 하는가?
이 질문들에 매번 다르게 답하고 있었습니다. 어떤 값은 화면 진입 시 다시 받아 왔고, 어떤 값은 그냥 남아 있었습니다. 규칙이 없으니 새 화면을 만들 때마다 다시 고민해야 했고, 결국 '이 화면에서는 왜 옛날 데이터가 보이지'라는 버그가 반복됐습니다.
나누는 기준은 누가 바꿀 수 있는 값인가
다시 보니 성격이 다른 두 종류를 한 통에 넣은 게 문제였습니다.
| 서버 상태 | 클라이언트 상태 | |
|---|---|---|
| 기준이 되는 곳 | 서버 | 이 브라우저 |
| 예 | 시세, 접수 내역, 사용자 정보 | 입력 중인 값, 모달 열림, 선택된 탭 |
| 다른 사람도 바꾸는가 | 그렇다 | 아니다 |
| 오래되면 틀리는가 | 그렇다. 무효화가 필요하다 | 아니다 |
| 새로고침하면 | 다시 받아야 한다 | 초기화되어도 대체로 괜찮다 |
| 담당 | TanStack Query · SWR | Zustand · useState |
기준은 다른 사람이 바꿀 수 있는 값인지 하나입니다. 시세는 제가 보고 있는 동안에도 바뀌고, 접수 내역은 다른 기기에서 취소될 수 있습니다. 이런 값은 제 브라우저가 최신 값을 알 수 없으니 언제나 '마지막으로 본 값'일 뿐이고, 코드 구조도 그 사실이 드러나게 짜야 합니다.
서버 응답은 스토어에 복사하지 않는다
가장 흔한 실수이고 저도 했던 실수입니다. 쿼리로 받아 온 데이터를 useEffect로 스토어에 넣어 두는 패턴입니다.
const { data } = useQuery({ queryKey: ["rate"], queryFn: fetchRate });
const setRate = useStore((s) => s.setRate);
useEffect(() => {
if (data) setRate(data); // ← 서버 데이터의 복사본이 스토어에 생긴다
}, [data, setRate]);이렇게 하면 같은 데이터가 쿼리 캐시와 스토어 두 곳에 존재하게 됩니다. 쿼리 캐시는 스스로 무효화되지만 스토어의 복사본은 그렇지 않습니다. 어느 시점부터는 두 값이 다르고, 어느 쪽을 읽었느냐에 따라 화면이 달라집니다.
// 여러 컴포넌트가 같은 키로 호출해도 요청은 한 번이다
const { data: rate } = useQuery({
queryKey: ["rate"],
queryFn: fetchRate,
refetchInterval: 5_000,
});
// 클라이언트 상태만 스토어에서
const weight = useStore((s) => s.weight);실시간 구독은 한 곳에서만 한다
입찰 플랫폼에서는 시세가 헤더, 입찰 폼, 목록 행, 요약 카드에 동시에 나옵니다. 처음에는 각 컴포넌트가 알아서 구독했는데, 연결이 여러 개 생기고 갱신 타이밍이 미묘하게 어긋나 화면 안에서 서로 다른 숫자가 잠깐씩 보였습니다.
구독은 최상위에서 한 번만 하고, 받은 값을 스토어에 넣어 아래로 내려주는 구조로 바꿨습니다. 앞에서 복사하지 말라고 한 것과 모순처럼 보이지만, 여기서 스토어는 캐시 역할이 아니라 구독 결과를 받아 두는 창구입니다. 쿼리 캐시가 맡지 않는 영역이기도 하고요.
// 최상위에서 한 번만
useRateSubscription();
// 소비하는 쪽 — 필요한 필드만 구독한다
const gold = useRateStore((s) => s.gold); // silver가 바뀌어도 리렌더되지 않는다
const silver = useRateStore((s) => s.silver);selector로 리렌더 범위를 좁힌다
스토어 전체를 구독하면 관계없는 필드가 바뀌어도 리렌더됩니다. 계산기 화면에서 이걸 제대로 겪었습니다. 입력 한 글자마다 폼 아래 모든 컴포넌트가 다시 그려져 타이핑이 밀렸습니다.
// 나쁨 — 매 렌더마다 새 객체라 항상 다르다고 판단된다
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 })));경계를 정하고 나서 달라진 것
기준을 세우고 나니 새 화면을 만들 때 상태를 어디에 둘지 고민하는 시간이 거의 없어졌습니다. 질문이 하나로 줄었기 때문입니다. 이 값을 다른 사람이 바꿀 수 있는가. 그렇다면 서버가 주인입니다.
코드 리뷰에서 나오는 얘기도 달라졌습니다. 전에는 "이거 스토어에 넣을까요 말까요" 같은 취향 논쟁이었는데, 이제는 "이 값은 서버 데이터인데 스토어에 복사돼 있네요" 하고 사실을 확인하는 정도로 끝납니다.