서버 상태와 클라이언트 상태를 섞어 쓰면 뭐가 깨지나?
답: 캐시 일관성이 깨진다. API 데이터가 오래된 채로 남거나, 다른 클라이언트의 변경을 반영하지 못하고, 로컬 UI 상태와 서버 소유 데이터가 엉켜 디버깅이 어려워진다. 지난 3개월간 전역 스토어(Redux)에 모든 상태를 집어넣었던 팀이 맞닥뜨린 일이다.
- 증상: 사용자가 주문을 취소했는데도 주문 목록에 남아있다. 새로고침하면 사라진다.
- 원인: Redux 리듀서에 API 응답을 저장한 후, 수정 API 호출 성공 직후에만 상태를 갱신했고, 다른 탭/창에서의 변경은 폴링이나 웹소켓 없이는 감지되지 않음.
- 영향: 고객 상담 건수 주당 15건 증가 (약 2주간).
API 응답 데이터를 전역 스토어에 두면 왜 일관성이 깨지나?
Redux나 Zustand 같은 전역 상태 관리는 동기적, 클라이언트 소유 데이터를 다루도록 설계됐다. 반면 API 응답은 비동기적이고 여러 클라이언트가 동시에 변경할 수 있는 서버 소유 데이터다.
우리 코드는 이렇게 작동했다:
// Redux 액션에서 API 호출
dispatch(fetchOrders()).then(orders => {
dispatch(setOrders(orders)); // 상태 저장, cacheTime 개념 없음
});
문제는 setOrders()가 호출된 순간부터 그 데이터가 "신선함"을 어떻게 보장하는가다. Redux는 시간 개념이 없다. 어제 저장한 데이터와 방금 저장한 데이터를 구분하지 않는다. 우리는 수동으로 언제 refetch 할지 정해야 했고, 그 로직이 분산되다 보니 일부 화면에서는 refetch가 빠졌다.
반면 TanStack Query는 기본값으로:
- staleTime: 0ms (즉시 stale 마킹)
- cacheTime: 5분 (inactive 후 캐시 유지 기간)
이 설정이 있으면, 탭을 닫았다가 다시 열 때나 포커스가 돌아올 때 자동으로 refetch 여부를 판단한다.
몇 시간 동안 "갱신 안 된" 데이터가 화면에 남아있었나?
우리 사례에서 최대 8시간이었다. 아침 9시에 주문을 취소했는데, 저녁 5시에 고객이 여전히 그 주문이 보인다고 신고했다. 그 사이 네트워크 오류나 서버 응답 지연은 없었다. 단순히 Redux 상태가 갱신되지 않았을 뿐이었다.
재현 절차:
- 주문 목록 페이지 열기 (09:00) → 주문 3건 표시
- 첫 번째 주문 취소 버튼 클릭 → API 호출 성공 (200 OK)
- Redux 리듀서: 취소된 주문의
status필드만cancelled로 수정 - 다른 탭으로 이동 → 주문 페이지 다시 방문 (17:00)
- 예상: 서버에서 최신 목록 refetch
- 실제: Redux 캐시 그대로, 주문 3건 여전히 표시
로그로 확인한 결과:
[09:00:15] GET /api/orders → 200 OK, 3 items
[09:05:22] POST /api/orders/123/cancel → 200 OK
[09:05:23] Redux: setOrderStatus(123, 'cancelled')
[17:00:08] 주문 페이지 재방문
[17:00:09] 네트워크 요청 X (Redux 캐시 재사용)
같은 시간에 다른 직원이 관리자 대시보드에서 그 주문을 조회하면 이미 취소 상태였다. 즉, 서버에는 올바른 상태가 있었는데 우리 클라이언트만 모르고 있던 것이다.
서버 상태와 클라이언트 상태를 어떻게 나눴나?
3주에 걸쳐 점진적으로 진행했다. 먼저 모든 Redux 상태를 분류했다:
| 상태 | 소유권 | 기존 Redux | 변경 후 | 이유 |
|---|---|---|---|---|
| 주문 목록, 사용자 프로필 | 서버 | ✓ | TanStack Query | 여러 클라이언트 변경, 동기화 필수 |
| 모달 열림/닫힘, 테마 선택 | 클라이언트 | ✓ | Zustand | 브라우저 로컬 전용, 서버 미반영 |
| 폼 입력값 (임시 저장 X) | 클라이언트 | ✓ | 로컬 state | 제출까지만 필요, 전역 불필요 |
| 페이지네이션, 정렬 필터 | URL | ✓ | next-usequerystate | 북마크/공유 가능해야 함 |
서버 상태 마이그레이션 (TanStack Query v5.30):
// Before: Redux
dispatch(fetchOrders());
const orders = useSelector(state => state.orders.data);
// After: TanStack Query
const { data: orders, isLoading, refetch } = useQuery({
queryKey: ['orders'],
queryFn: () => fetch('/api/orders').then(r => r.json()),
staleTime: 1000 * 60 * 5, // 5분
gcTime: 1000 * 60 * 10, // 10분 (구 cacheTime)
});
클라이언트 상태 마이그레이션 (Zustand):
// Before: Redux
const isModalOpen = useSelector(state => state.ui.isModalOpen);
dispatch(toggleModal());
// After: Zustand
const { isModalOpen, toggleModal } = useUIStore();
마이그레이션 후 어떤 지표가 개선됐나?
초기 로드 시간: 1,850ms → 1,200ms (-35%)
- 이유: TanStack Query의 자동 중복 요청 제거. 같은 쿼리를 2~3번 호출하던 부분이 1번으로.
재렌더링 횟수 (React DevTools Profiler):
- 주문 목록 화면에서 모달 열기: 이전 8회 → 이후 2회 (-75%)
- 이유: 클라이언트 상태(모달)와 서버 상태(주문 목록)가 분리되어, 모달 토글이 주문 컴포넌트를 다시 렌더링하지 않음.
메모리 사용량 (Chrome DevTools):
- 100개 쿼리 캐시 시: +2.3MB (TanStack Query 기본 설정)
- Redux 전체: +8.5MB (서버 상태 + 클라이언트 상태 혼합)
- 순 감소: -6.2MB
고객 상담 건수: 주당 15건(장애 당시) → 2건(1개월 후)
- 불일치로 인한 신고는 거의 없어짐. 남은 2건은 네트워크 지연 등 다른 원인.
같은 함정을 피하려면?
1. 상태 분류를 먼저 한다
데이터 출처가 어디인가? 서버/URL/폼/전역 UI 중 어디? 그것이 도구를 결정한다.
2. 서버 상태는 "소유권이 없다"는 전제로 설계한다
Redux나 Zustand에서 API 응답을 저장할 때, 그것이 언제까지 신선한지 명시한다. 수동 refetch 로직을 최소화한다.
3. URL 상태를 간과하지 않는다
검색 필터, 페이지 번호, 정렬 기준은 URL에 두면 사용자가 링크를 공유하고 북마크할 수 있다. 전역 스토어에만 두면 새로고침할 때마다 초기화된다.
4. 클라이언트 상태는 작게, 빠르게 유지한다
UI 흐름(모달, 드로다운, 테마)만 Zustand/Jotai에 두고, 서버 데이터는 TanStack Query에만 두면 상태 그래프가 단순해진다.
핵심 정리
서버 상태와 클라이언트 상태는 동기화 방식, 소유권, 도구가 모두 다르다. 전자는 여러 클라이언트가 변경 가능하고 비동기 접근이므로 캐시 만료 개념이 필요하고, 후자는 클라이언트 단독 소유이므로 즉시 반영이 가능하다.
Redux 같은 올인원 스토어에 API 응답을 저장하면 staleTime 개념이 없어 수동 refetch 로직이 분산되고, 결국 오래된 데이터가 남는 일관성 문제가 발생한다. 우리 사례에서는 최대 8시간 동안 취소된 주문이 표시되는 장애가 났다.
TanStack Query + Zustand(또는 Jotai) 조합이 2025
2026 산업 표준인 이유는, 각 도구가 자신의 책임(서버 동기화 vs 로컬 UI)만 맡으면서 중복 요청을 6080% 줄이고 재렌더링을 70% 이상 감소시킬 수 있기 때문이다.마이그레이션은 점진적으로, 상태 분류 → 도구 선택 → 테스트 업데이트 → 성능 검증 순서로 진행하면 위험을 최소화할 수 있다. 우리는 3주에 걸쳐 초기 로드 시간 35% 감소와 고객 신고 93% 감소를 얻었다.
"이 데이터가 서버 소유인가, 클라이언트 소유인가?"를 먼저 묻는 습관이 상태 관리 도구 선택의 출발점이다. 도구부터 고르지 말고, 데이터의 성질과 변경 가능성부터 판단하라.