상태 관리 라이브러리를 고르는 이야기는 많은데, 정작 실무에서 더 자주 마주치는 질문은 다른 것입니다. 이 값을 어디에 둘 것인가. 라이브러리를 무엇으로 정하든 이 질문에 답하지 못하면 같은 문제가 생깁니다.
잘못 그은 경계가 만든 문제
금 매입 서비스를 만들 때의 일입니다. 시세, 접수 내역, 입력 중인 폼 값, 현재 단계를 전부 하나의 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 })));경계를 정하고 나서 달라진 것
규칙을 세우고 나니 새 화면을 만들 때 상태 위치를 고민하는 시간이 거의 사라졌습니다. 질문이 하나로 줄었기 때문입니다.
이 값을 다른 사람이 바꿀 수 있는가? 그렇다면 서버가 주인입니다.
그리고 코드 리뷰에서 이야기하는 내용도 달라졌습니다. 그전에는 "이거 스토어에 넣을까요 말까요" 같은 취향 논쟁이었는데, 이제는 "이 값의 주인이 서버인데 스토어에 복사되어 있다"는 사실 확인이 됩니다. 판단 기준이 공유되면 논의가 짧아집니다.
정리
- 서버가 주인인 데이터와 클라이언트가 주인인 데이터를 한 통에 넣지 않습니다.
- 서버 응답을 스토어에 복사하지 않습니다. 복사하는 순간 무효화 책임이 생깁니다.
- 실시간 구독은 한 곳에서만 하고, 스토어는 그 결과를 흘려보내는 창구로만 씁니다.
- selector는 원시값 단위로. 객체를 새로 만들어 반환하면 리렌더 억제가 무의미해집니다.
- '이 값의 주인은 누구인가'에 답할 수 없는 상태는 만들지 않습니다.