이 글에서 정리하는 내용
Zustand는 store를 만드는 코드가 짧아서 처음에는 쉽게 느껴집니다. 다만 실제로 헷갈리는 지점은 문법보다 상태를 어디에 둘지, 어떤 컴포넌트가 어떤 값을 구독하게 만들지, 새로고침 뒤에도 남길 값인지 판단하는 부분입니다.
이 글은 Zustand를 처음 정리할 때 필요한 큰 기준을 잡는 허브입니다. 로컬 state와 전역 store를 나누는 기준, state와 action의 역할, selector를 쓰는 이유, persist를 붙이기 전 확인할 지점을 한 흐름으로 연결합니다.
- Zustand가 필요한 순간
- store, state, action을 나눠 보는 기준
- selector와 action을 분리해서 읽는 이유
- 학습 순서를 잡을 때 연결해서 볼 글
- persist를 붙이기 전에 확인할 것
- 정리
Zustand가 필요한 순간

React에서 상태를 다룰 때 모든 값을 전역 store로 빼야 하는 것은 아닙니다. 검색창에 입력 중인 값, 작은 모달의 열림 상태, 한 카드 안에서만 쓰는 hover 여부처럼 범위가 좁은 값은 useState로 두는 쪽이 더 단순합니다. 이런 상태까지 전부 store에 넣으면 나중에 화면 하나를 고칠 때도 전역 상태 파일을 같이 열어봐야 합니다.
Zustand가 필요한 순간은 같은 상태가 여러 컴포넌트에 걸쳐 쓰이기 시작할 때입니다. 쇼핑몰 카테고리 화면을 예로 들면 상단 검색 영역, 왼쪽 필터, 상품 리스트, 정렬 버튼이 같은 조건을 공유할 수 있습니다. 이 구조를 props로만 연결하면 중간 컴포넌트가 실제로 쓰지 않는 값까지 계속 전달하게 됩니다.
이때 Zustand는 상태를 컴포넌트 트리 바깥의 store에 두고, 필요한 컴포넌트가 필요한 값만 가져가게 만드는 역할을 합니다. “Redux보다 간단하다”는 말로만 이해하면 부족합니다. Zustand의 장점은 store를 작게 시작할 수 있고, selector를 통해 구독 범위를 좁히며, action으로 변경 규칙을 한곳에 모을 수 있다는 데 있습니다.
로컬 상태와 전역 상태의 경계가 애매하다면 React state vs Zustand: 언제 전역 상태가 필요할까를 함께 보는 편이 좋습니다. 상태 관리 라이브러리를 고르기 전에 먼저 “이 값이 여러 화면에서 공유되는가”를 확인해야 선택이 덜 흔들립니다.
store, state, action을 나눠 보는 기준
Zustand의 기본 코드는 짧지만, 읽을 때는 store, state, action을 나눠서 보는 것이 안정적입니다. store는 공유 상태를 담는 공간이고, state는 실제 값입니다. action은 그 값을 바꾸는 함수입니다. Zustand가 action이라는 구조를 강제하는 것은 아니지만, 변경 로직을 store 안에 모아두면 컴포넌트가 커졌을 때 추적하기 쉬워집니다.
| 구성 | 역할 | 수정할 때 먼저 볼 부분 |
|---|---|---|
| store | 공유 상태와 변경 함수를 담는 공간 | 기능 단위가 지나치게 넓어지지 않았는지 |
| state | 화면에서 읽는 실제 값 | 한 컴포넌트 안에서 끝나는 값까지 포함했는지 |
| action | 상태를 변경하는 함수 | 컴포넌트마다 다른 방식으로 값을 바꾸고 있지 않은지 |
| selector | 컴포넌트가 구독할 값을 고르는 함수 | store 전체를 가져와 불필요한 렌더링 여지를 만들지 않았는지 |
아래 예시는 상품 목록 필터 상태를 store로 분리한 형태입니다. 실제 화면에서는 검색어 입력, 카테고리 버튼, 초기화 버튼이 서로 다른 컴포넌트에 배치될 수 있습니다. 그런 경우 상태와 action을 store 안에 함께 두면 각 컴포넌트가 같은 기준으로 필터를 바꿀 수 있습니다.
import { create } from 'zustand';
type FilterState = {
keyword: string;
selectedCategory: string;
setKeyword: (keyword: string) => void;
selectCategory: (category: string) => void;
resetFilter: () => void;
};
export const useProductFilterStore = create<FilterState>((set) => ({
keyword: '',
selectedCategory: 'all',
setKeyword: (keyword) => set({ keyword }),
selectCategory: (category) => set({ selectedCategory: category }),
resetFilter: () => set({ keyword: '', selectedCategory: 'all' }),
}));
여기서 컴포넌트는 setKeyword, selectCategory, resetFilter를 호출할 뿐입니다. 필터 값을 어떤 초기 상태로 되돌릴지, 카테고리 기본값을 무엇으로 둘지는 store 안에 남습니다. 필터 조건이 가격 범위, 정렬 방식, 품절 포함 여부까지 늘어나도 변경 지점을 한곳에서 찾을 수 있습니다.
selector와 action을 분리해서 읽는 이유
Zustand에서 자주 놓치는 부분은 값을 가져오는 방식입니다. useProductFilterStore()처럼 selector 없이 store 전체를 가져오면 코드가 짧아 보입니다. 하지만 그 컴포넌트가 실제로 쓰지 않는 값이 바뀌어도 store 객체 변화의 영향을 받을 수 있습니다.
컴포넌트가 실제로 필요한 값만 selector로 꺼내면 구독 범위가 눈에 보입니다. 필터 바는 검색어와 검색어 변경 함수만 필요하고, 상품 리스트는 선택된 카테고리와 정렬 조건이 필요할 수 있습니다. 두 컴포넌트가 같은 store를 쓰더라도 같은 값을 구독할 필요는 없습니다.
export function ProductFilterBar() {
const keyword = useProductFilterStore((state) => state.keyword);
const setKeyword = useProductFilterStore((state) => state.setKeyword);
const resetFilter = useProductFilterStore((state) => state.resetFilter);
return (
<div className="filter-bar">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="상품명 검색"
/>
<button type="button" onClick={resetFilter}>
초기화
</button>
</div>
);
}
이 예시는 코드가 조금 길어졌지만, 필터 바가 구독하는 값이 분명합니다. 나중에 selectedCategory가 바뀌는 로직을 상품 리스트 쪽에서 수정하더라도 필터 바를 같이 의심할 필요가 줄어듭니다.
action도 같은 방식으로 분리해서 봐야 합니다. 버튼 클릭 함수 안에서 상태 객체를 직접 조립하는 코드가 반복되면 컴포넌트마다 변경 규칙이 달라질 수 있습니다. action을 store에 모아두면 컴포넌트는 “어떤 동작을 실행할지”만 결정하고, 상태를 어떤 형태로 바꿀지는 store가 맡습니다.
리렌더링까지 같이 점검해야 한다면 Zustand 리렌더링 원리와 selector 최적화 방법에서 selector가 렌더링 범위와 어떻게 연결되는지 따로 확인하면 됩니다.
학습 순서를 잡을 때 연결해서 볼 글
Zustand 관련 글은 비슷한 단어가 반복되기 때문에 순서를 놓치기 쉽습니다. store를 아직 만들지 않았는데 persist부터 보면 저장 설정과 상태 구조가 섞여 보입니다. 반대로 selector를 모른 채 리렌더링 문제를 보면 왜 컴포넌트가 다시 그려지는지보다 “Zustand가 이상하게 동작한다”는 쪽으로 오해하기 쉽습니다.
- Zustand Store 타입 지정: TypeScript로 state와 action 안전하게 관리하기 — store 구조가 익숙해진 뒤 타입을 붙이는 단계에서 연결됩니다.
- Zustand persist 사용법: 새로고침 후 상태 저장하기 — 저장해야 할 상태와 저장하지 않을 상태를 나눌 때 참고할 수 있습니다.
- Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법 — 상태는 바뀐 것 같은데 화면이 그대로일 때 확인할 글입니다.
권장 순서는 단순합니다. 먼저 로컬 state와 전역 store의 경계를 잡고, 그 다음 store와 action을 작성합니다. 이후 selector로 구독 범위를 좁히고, TypeScript 타입을 붙입니다. persist는 마지막에 “이 값이 브라우저를 새로고침해도 남아야 하는가”를 확인한 뒤 적용하면 됩니다.
persist를 붙이기 전에 확인할 것

persist는 store의 일부 상태를 브라우저 저장소에 남겨 새로고침 뒤에도 이어 쓰게 만드는 middleware입니다. 기능 이름 때문에 “상태 관리가 더 안정적으로 된다”라고 받아들이기 쉽지만, persist는 상태 구조를 좋게 만들어주는 도구가 아닙니다. 이미 분리된 상태 중에서 저장할 값만 고르는 단계에 가깝습니다.
저장할 만한 값은 사용자가 다시 들어왔을 때 이어지는 것이 자연스러운 값입니다. 테마 설정, 사이드바 접힘 여부, 목록 보기 방식, 임시 장바구니처럼 사용자가 직접 선택한 환경 값이 여기에 들어갑니다. 반면 모달 열림 여부, 일시적인 hover 상태, 페이지 안에서만 쓰는 입력 중간값은 저장했을 때 오히려 화면을 어색하게 만들 수 있습니다.
서버 데이터나 로그인 토큰처럼 보안과 동기화가 얽힌 값도 조심해야 합니다. 저장 자체가 가능하다는 이유만으로 persist에 넣으면 서버 상태와 클라이언트 저장 값이 어긋날 수 있습니다. 그런 값은 인증 흐름, 서버 갱신 시점, 만료 정책을 먼저 확인한 뒤 별도 방식으로 다루는 편이 맞습니다.
persist를 붙이기 전에는 세 가지를 확인하면 됩니다. 첫째, 이 값이 새로고침 후에도 남아야 하는가. 둘째, 저장된 값이 오래 남아도 화면이 어색하지 않은가. 셋째, 서버나 인증 상태와 충돌하지 않는가. 이 질문에 답하기 어렵다면 store 구조를 먼저 정리한 뒤 저장 여부를 다시 판단해야 합니다.
정리
Zustand는 store를 빠르게 만들 수 있는 라이브러리지만, 실제 기준은 “무엇을 전역으로 둘 것인가”에서 시작합니다. 한 컴포넌트 안에서 끝나는 값은 로컬 state로 두고, 여러 컴포넌트가 같은 값을 공유하거나 같은 변경 규칙을 써야 할 때 store로 분리합니다.
store 안에서는 state와 action의 역할을 나누고, 컴포넌트에서는 selector로 필요한 값만 가져옵니다. 이 기준이 잡히면 리렌더링 문제를 볼 때도 확인 범위가 줄어듭니다. 화면이 다시 그려지지 않는 문제인지, 너무 자주 다시 그려지는 문제인지, 변경 로직이 컴포넌트 안에 흩어진 문제인지 구분할 수 있습니다.
persist는 마지막에 붙이는 선택지입니다. 상태를 저장할 수 있다는 사실보다 저장된 상태가 다음 방문에서도 자연스러운지가 더 중요합니다. store, action, selector, persist를 한 번에 외우려 하기보다 이 순서대로 나누어 보면 Zustand 코드를 수정할 때 기준이 남습니다.
“Zustand 학습 순서: store, action, selector, persist까지”에 대한 1개의 생각