Zustand를 쓰기 시작하면 상태를 한곳에 모아두는 경험이 꽤 편하다.
컴포넌트 어디서든 꺼내 쓸 수 있고, API도 단순해서 빠르게 전역 상태를 만들 수 있다.
그래서 어느 순간 이런 고민이 생긴다.
"이 값도 store에 넣어도 될까?"
예를 들어 아래 같은 값들이다.
- 로그인한 사용자 정보
- 상품 목록 API 응답
- 장바구니 개수
- 현재 열려 있는 모달 상태
- 검색 결과
- 로딩 상태
겉으로 보면 모두 "여러 컴포넌트에서 같이 써야 하는 값"처럼 보인다.
하지만 이들을 전부 같은 종류의 상태로 보면 점점 구조가 꼬이기 시작한다.
이번 글에서는 Zustand를 사용할 때 자주 헷갈리는 전역 상태와 서버 상태의 차이를 정리하고,
어떤 값은 store에 두는 것이 맞고 어떤 값은 서버 상태 도구로 관리하는 편이 좋은지 기준을 정리해보려고 한다.
먼저 결론부터
아주 단순하게 정리하면 이렇게 볼 수 있다.
- 전역 상태: 클라이언트가 직접 만들고 제어하는 상태
- 서버 상태: 서버에서 가져오고, 다시 동기화해야 하는 상태
즉, 둘 다 "여러 컴포넌트에서 같이 쓰는 값"일 수는 있지만,
값의 출처와 생명주기, 동기화 방식이 다르다.
이 차이를 구분하지 않으면 Zustand store 안에 API 응답, 캐시, 로딩 상태, 에러 상태가 한꺼번에 들어가기 시작하고,
결국 store가 점점 무거워진다.
전역 상태는 무엇일까
전역 상태는 말 그대로 앱 안에서 여러 컴포넌트가 함께 사용하는 클라이언트 상태다.
예를 들면 아래 같은 것들이다.
- 모달이 열려 있는지
- 현재 선택된 탭이 무엇인지
- 사이드바가 접혀 있는지
- 사용자가 입력 중인 필터 조건
- 다크 모드인지 라이트 모드인지
- 장바구니에 임시로 담아둔 UI용 상태
이 값들의 공통점은 비교적 명확하다.
- 서버에서 받아오는 원본 데이터가 아니다
- 사용자의 현재 인터랙션에 의해 바뀐다
- 앱 내부에서 직접 값을 만든다
- 새로고침하거나 화면을 벗어나면 사라져도 이상하지 않은 경우가 많다
이런 상태는 Zustand와 잘 맞는다.
예를 들어 아래 같은 상태는 전형적인 전역 상태다.
import { create } from "zustand";
interface UiState {
isSidebarOpen: boolean;
selectedCategory: string | null;
openSidebar: () => void;
closeSidebar: () => void;
setSelectedCategory: (category: string | null) => void;
}
export const useUiStore = create<UiState>((set) => ({
isSidebarOpen: false,
selectedCategory: null,
openSidebar: () => set({ isSidebarOpen: true }),
closeSidebar: () => set({ isSidebarOpen: false }),
setSelectedCategory: (selectedCategory) => set({ selectedCategory }),
}));
이 상태는 서버와 동기화할 필요가 없다.
앱이 지금 어떤 UI 상태인지 표현하는 것이 핵심이기 때문이다.
서버 상태는 무엇일까
반대로 서버 상태는 클라이언트가 "소유"하는 값이 아니라,
서버에서 받아오고, 다시 최신 상태와 맞춰야 하는 값이다.
예를 들면 아래 같은 것들이다.
- 사용자 프로필 API 응답
- 상품 목록
- 주문 내역
- 알림 목록
- 검색 결과
- 댓글 목록
이 값들의 특징은 보통 아래와 같다.
- 원본 데이터가 서버에 있다
- 시간이 지나면 stale해질 수 있다
- 다시 불러오거나 갱신해야 한다
- 로딩, 에러, 재시도, 캐시 정책을 함께 고민해야 한다
즉, 서버 상태는 단순히 "전역에서 같이 쓰는 값"이 아니라,
"비동기 요청과 동기화 전략이 함께 붙는 값"이다.
바로 이 지점에서 Zustand만으로 관리하기 시작하면 점점 힘들어진다.
왜 서버 상태를 Zustand에 다 넣으면 힘들어질까
물론 기술적으로는 가능하다.
요청을 날리고, 응답을 store에 넣고, 로딩과 에러 상태도 함께 들고 있으면 된다.
예를 들면 아래처럼 작성할 수는 있다.
interface ProductState {
products: Product[];
isLoading: boolean;
error: string | null;
fetchProducts: () => Promise<void>;
}
처음에는 이 방식이 그럴듯해 보인다.
하지만 조금만 복잡해지면 금방 아래 문제들이 따라온다.
1. 캐시 전략을 직접 고민해야 한다
한 번 받아온 데이터를 언제 다시 가져와야 하는가,
같은 요청을 중복 호출하지 않으려면 어떻게 해야 하는가,
화면을 다시 방문했을 때 기존 데이터를 재사용할 것인가 같은 고민이 생긴다.
이건 단순한 상태 저장이 아니라 서버 상태 관리의 영역이다.
2. 로딩, 에러, 재요청 흐름이 함께 커진다
서버 상태는 데이터만 있는 것이 아니다.
- 최초 로딩
- 새로고침
- background refetch
- 실패
- 재시도
이런 흐름이 같이 붙는다.
이걸 매번 store에 직접 구현하기 시작하면 Zustand store가 캐시 라이브러리처럼 변해간다.
3. 최신 데이터와의 동기화 책임이 생긴다
서버 데이터는 시간이 지나면 바뀔 수 있다.
즉, 지금 가지고 있는 값이 "정답"이라는 보장이 없다.
그래서 단순히 store에 넣는 것만으로는 충분하지 않고,
"언제 다시 서버와 맞출 것인가"라는 정책이 필요해진다.
4. UI 상태와 서버 상태가 한 store에 섞이기 쉽다
예를 들어 아래처럼 되기 쉽다.
- 상품 목록 데이터
- 로딩 여부
- 정렬 옵션
- 필터 값
- 모달 상태
- 에러 메시지
이렇게 되면 하나의 store 안에
"서버에서 받아온 데이터"와 "클라이언트가 제어하는 UI 상태"가 섞인다.
그러면 store는 금방 커지고, 책임도 흐려진다.
그럼 어떤 기준으로 나누면 좋을까
내 기준에서는 아래 질문을 해보면 꽤 잘 구분된다.
1. 이 값의 원본은 서버에 있는가
원본이 서버에 있다면 서버 상태일 가능성이 크다.
예를 들어 상품 목록, 사용자 프로필, 주문 내역은
클라이언트가 만들어낸 값이 아니라 서버에서 받아오는 값이다.
반면 현재 선택한 정렬 옵션, 모달 열림 여부는
클라이언트 안에서 직접 만든 상태다.
2. 이 값은 시간이 지나면 stale해질 수 있는가
서버 상태는 지금 들고 있는 값이 금방 오래된 값이 될 수 있다.
예를 들어 댓글 목록은 다른 사용자가 새 댓글을 달면 바뀔 수 있다.
주문 상태도 시간이 지나면 변경될 수 있다.
반면 isSidebarOpen 같은 값은 stale이라는 개념 자체가 크게 중요하지 않다.
3. 로딩, 에러, 재시도, 캐시를 함께 관리해야 하는가
이 질문에 "예"라면 서버 상태일 가능성이 높다.
이런 요구사항은 Zustand보다 React Query, TanStack Query 같은 도구가 훨씬 잘 다룬다.
4. 이 값은 UI를 제어하기 위한 상태인가
UI를 열고 닫고, 선택하고, 임시 값을 들고 있는 용도라면
보통은 전역 상태 혹은 로컬 상태로 보는 편이 자연스럽다.
즉, 이 값이 "서버 데이터 자체"인지, 아니면 "서버 데이터를 보여주기 위한 화면 상태"인지를 구분해야 한다.
실무에서는 이렇게 많이 나뉜다
예를 들어 상품 목록 페이지를 생각해보자.
아래 값들이 있다고 하자.
- 상품 목록 데이터
- 현재 페이지 번호
- 정렬 방식
- 선택된 카테고리
- 필터 모달 열림 여부
이때 구분은 보통 이렇게 간다.
서버 상태
- 상품 목록 데이터
- 총 페이지 수
- 서버 응답 메타데이터
이 값들은 API 응답에서 오고, 다시 요청하면 바뀔 수 있다.
전역 상태 혹은 UI 상태
- 현재 선택된 카테고리
- 정렬 방식
- 필터 모달 열림 여부
- 리스트 보기 방식(grid/list)
이 값들은 사용자의 현재 화면 조작 상태에 가깝다.
즉, "상품 목록을 보여주는 페이지"라고 해서
그와 관련된 모든 값을 한 store에 넣는 것이 아니라,
서버 원본 데이터와 화면 제어 상태를 나눠서 생각해야 한다.
그렇다면 사용자 정보는 어디에 둬야 할까
여기서 자주 헷갈리는 것이 로그인 사용자 정보다.
예를 들어 현재 로그인한 사용자 프로필은 서버에서 받아오는 값이기도 하고,
앱 전역에서 참조하기도 한다.
이 경우는 상황에 따라 두 층으로 나눠 생각하는 편이 좋다.
- 사용자 프로필 원본 데이터: 서버 상태
- 인증 여부, 토큰 존재 여부, UI에서 자주 참조하는 최소 정보: 전역 상태 가능
예를 들어 아래처럼 생각할 수 있다.
meAPI 응답 전체: 서버 상태 도구로 관리isLoggedIn,accessToken,userRole같은 최소한의 앱 제어 정보: Zustand 가능
즉, "전역에서 많이 쓴다"는 이유만으로 서버 응답 전체를 store에 넣기보다,
정말 앱 제어에 필요한 최소 값만 전역 상태로 두는 편이 구조가 깔끔해진다.
Zustand와 서버 상태 도구를 같이 쓰는 방식
실무에서는 보통 둘 중 하나만 고집하기보다 역할을 나눠서 같이 쓴다.
예를 들어 이렇게 생각할 수 있다.
Zustand: UI 상태, 사용자 상호작용 상태, 전역 제어 상태React Query같은 도구: API 응답, 캐시, 동기화, 재검증
예를 들면 아래 조합이 자연스럽다.
// Zustand
interface FilterState {
selectedCategory: string | null;
sortOrder: "latest" | "price";
setSelectedCategory: (value: string | null) => void;
setSortOrder: (value: "latest" | "price") => void;
}
// Server state tool
const { data, isLoading, error } = useQuery({
queryKey: ["products", selectedCategory, sortOrder],
queryFn: fetchProducts,
});
이 구조의 장점은 역할이 명확하다는 점이다.
- 필터 값은 클라이언트가 제어한다
- 상품 데이터는 서버와 동기화한다
이렇게 나누면 store가 괜히 비대해지지 않고, 도구별 장점도 살릴 수 있다.
내가 판단할 때 쓰는 간단한 기준
복잡하게 생각하기 싫을 때는 아래처럼 정리해도 충분하다.
Zustand에 넣기 좋은 것
- 모달, 드롭다운, 탭 상태
- 선택된 필터 값
- 사이드바 열림 여부
- 다크 모드
- 여러 화면에서 공유해야 하는 클라이언트 제어 상태
서버 상태 도구에 맡기기 좋은 것
- API 응답 원본
- 목록 데이터
- 상세 데이터
- 로딩, 에러, 재시도, 캐시가 함께 필요한 데이터
- 최신성과 동기화가 중요한 데이터
애매할 때는 이렇게 본다
"이 값은 서버 원본을 대표하는가, 아니면 화면 동작을 제어하는가?"
이 질문이 가장 실용적이다.
마무리
Zustand를 잘 쓰려면 store를 많이 만드는 것보다,
"어떤 상태를 맡기지 않아야 하는가"를 먼저 아는 것이 더 중요하다고 느낀다.
전역 상태와 서버 상태를 구분하지 않으면
store는 금방 커지고, 로딩과 에러와 캐시와 UI 제어가 한곳에 섞이기 시작한다.
반대로 이 둘을 명확히 나누면 구조가 단순해진다.
- 클라이언트가 직접 제어하는 값은
Zustand - 서버에서 가져오고 다시 맞춰야 하는 값은 서버 상태 도구
내 기준에서 Zustand는 서버 데이터를 담는 만능 창고가 아니라,
앱의 전역적인 클라이언트 상태를 다루는 도구에 더 가깝다.
그래서 어떤 값을 store에 넣을지 고민될 때는
"이 값의 원본은 어디에 있는가?"부터 먼저 물어보는 편이 가장 안전하다.
'Develope > React' 카테고리의 다른 글
| Zustand를 로컬 상태 대신 써도 되는 순간은 언제일까 (0) | 2026.04.13 |
|---|---|
| Zustand store를 너무 크게 만들면 어떤 문제가 생길까? slice 패턴으로 나누는 기준 (0) | 2026.04.13 |
| Zustand에서 shallow 비교는 언제 필요할까? selector와 함께 이해하기 (0) | 2026.04.11 |
| Zustand Selector는 언제 써야 할까? 성능과 가독성 사이에서 판단하는 기준 (1) | 2026.04.09 |
| CRA에서 Vite + SSR로 마이그레이션하며 겪은 문제와 해결 과정 (0) | 2026.04.05 |