학습 목표·선수 지식: 전체 구독·원시 값 선택·새 객체 선택의 차이를 구분하고 Zustand 5의 안정적인 selector 결과를 만듭니다. 선수 지식: state와 action, Object.is와 객체 참조.
먼저 확인할 핵심
Zustand에서 selector를 사용하는 이유를 컴포넌트 구독 범위 관점으로 정리합니다. 전체 store를 가져오는 방식이 왜 편해 보이는지, 상태가 늘어났을 때 어떤 문제가 생기는지, 여러 값을 가져올 때 useShallow를 언제 검토해야 하는지까지 관리자 필터 UI 흐름으로 정리합니다.
실습 경로: 현재 ZIP → 전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.
전체 store를 가져오면 왜 편해 보이는가

Zustand를 처음 쓸 때는 store에서 필요한 값을 한 번에 꺼내는 방식이 가장 편해 보입니다. 상태와 action이 모두 한 객체 안에 있으니 구조 분해로 꺼내면 코드도 짧습니다. 작은 카운터 예제나 설정값 한두 개를 다루는 화면에서는 이 방식만으로도 크게 불편하지 않습니다.
문제는 store가 실제 화면의 여러 기능을 담기 시작할 때 드러납니다. 관리자 화면의 목록 필터를 예로 들면 검색어, 카테고리, 정렬 기준, 보기 방식, 초기화 action이 같은 store에 들어갈 수 있습니다. 검색창 컴포넌트는 검색어와 검색어 변경 함수만 필요하지만, 전체 store를 가져오면 컴포넌트가 자신과 관계없는 값까지 같이 들고 있는 모양이 됩니다. 화면이 바로 깨지는 오류가 아니라서 처음에는 티가 덜 나고, 필터 조건이 늘어난 뒤에야 수정하기 불편한 구조로 보이는 경우가 많습니다.
처음 작성하기 쉬운 형태
개념 부분 코드: src/examples/post-2944-1.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
import { create } from 'zustand';
type FilterState = {
keyword: string;
category: string;
sort: 'latest' | 'popular';
viewMode: 'grid' | 'list';
setKeyword: (keyword: string) => void;
setCategory: (category: string) => void;
setSort: (sort: 'latest' | 'popular') => void;
resetFilters: () => void;
};
export const useFilterStore = create<FilterState>((set) => ({
keyword: '',
category: 'all',
sort: 'latest',
viewMode: 'grid',
setKeyword: (keyword) => set({ keyword }),
setCategory: (category) => set({ category }),
setSort: (sort) => set({ sort }),
resetFilters: () =>
set({ keyword: '', category: 'all', sort: 'latest', viewMode: 'grid' }),
}));
이 store 자체는 문제가 없습니다. 상태와 action이 한곳에 모여 있어 필터 화면을 만들기 시작하기에는 충분합니다. 다만 이 store를 컴포넌트에서 어떻게 꺼내 쓰는지가 다음 문제를 만듭니다.
개념 부분 코드: src/examples/post-2944-2.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
function SearchInput() {
const { keyword, setKeyword } = useFilterStore();
return (
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어를 입력하세요"
/>
);
}
SearchInput이 실제로 쓰는 값은 keyword와 setKeyword뿐입니다. 구조 분해에서 category와 sort를 지워도 selector 없이 hook을 호출하면 전체 store 구독은 그대로입니다. 사용하지 않는 변수 제거와 구독 범위 축소는 서로 다른 수정입니다.
Zustand는 hook을 통해 store를 사용할 수 있지만, hook을 호출하는 방식에 따라 컴포넌트가 구독하는 범위가 달라집니다. useFilterStore()처럼 selector 없이 호출하면 store 전체를 가져오는 모양이 됩니다. 이 상태에서는 나중에 category나 sort가 바뀌었을 때 검색창이 왜 다시 렌더링되는지 확인해야 하는 상황이 생길 수 있습니다. selector는 이런 상황을 줄이기 위해 컴포넌트가 바라보는 store 조각을 코드에 직접 남기는 장치입니다.
selector는 필요한 값만 구독하는 기준이다
selector는 store 전체 중에서 컴포넌트가 사용할 부분만 고르는 함수입니다. 문법만 보면 useFilterStore((state) => state.keyword)처럼 단순하지만, 의미는 조금 더 분명합니다. 이 컴포넌트는 store 중에서 keyword를 바라본다는 선언입니다.
검색창 컴포넌트는 검색어를 보여주고, 사용자가 입력한 값을 store에 반영하는 역할만 가집니다. 그렇다면 selector도 그 범위 안에서 끝나는 것이 자연스럽습니다.
필요한 상태와 action만 가져오기
개념 부분 코드: src/examples/post-2944-3.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
function SearchInput() {
const keyword = useFilterStore((state) => state.keyword);
const setKeyword = useFilterStore((state) => state.setKeyword);
return (
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어를 입력하세요"
/>
);
}
이렇게 바꾸면 줄 수는 늘어납니다. 대신 컴포넌트가 무엇을 필요로 하는지 바로 보입니다. SearchInput은 category나 sort를 모릅니다. 필터 화면이 커져도 검색창은 검색어 입력이라는 책임에 머뭅니다. 나중에 검색창만 별도 컴포넌트로 분리하거나 모바일 검색 UI로 재사용할 때도 불필요한 상태 의존성이 따라오지 않습니다.
action도 같은 기준으로 가져오면 됩니다. action은 상태값처럼 화면에 직접 표시되지는 않지만, 컴포넌트가 사용하는 store의 일부입니다. ResetFilterButton이 resetFilters만 실행한다면, 해당 버튼은 reset action만 selector로 가져와도 충분합니다.
개념 부분 코드: src/examples/post-2944-4.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
function ResetFilterButton() {
const resetFilters = useFilterStore((state) => state.resetFilters);
return <button onClick={resetFilters}>필터 초기화</button>;
}
이 코드는 상태값을 하나도 읽지 않습니다. 버튼의 역할이 초기화 실행이라면 이 정도가 오히려 더 명확합니다. 나중에 필터 store에 날짜 범위나 가격 범위가 추가되어도 이 새 상태를 직접 알 필요는 없습니다.
여러 값을 가져올 때 생기는 참조 문제
아래 useShallow 없는 FilterSummary는 Zustand 5에서 피해야 하는 비교용 부분 코드입니다. 기본 create의 hook은 React useSyncExternalStore를 사용하므로 상태가 그대로라면 snapshot도 안정적이어야 합니다. 매 평가마다 새 객체를 만드는 selector는 getSnapshot 캐시 경고와 Maximum update depth exceeded를 일으킬 수 있습니다. 단순히 리렌더 횟수만 늘어나는 패턴으로 이해하면 안 됩니다.
selector를 쓰다 보면 값을 하나씩 가져오는 코드가 길어 보일 수 있습니다. 그래서 관련된 값을 객체로 묶어 한 번에 가져오고 싶어집니다. 검색어와 카테고리를 함께 쓰는 필터 요약 컴포넌트라면 아래와 같이 쓰고 싶어질 수 있지만, 다음 코드는 Zustand 5의 안정된 출력 조건을 위반하는 비교용 예제입니다.
개념 부분 코드: src/examples/post-2944-5.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
function FilterSummary() {
const filter = useFilterStore((state) => ({
keyword: state.keyword,
category: state.category,
}));
return (
<p>
{' '}
검색어: {filter.keyword || '없음'} / 카테고리: {filter.category}{' '}
</p>
);
}
이 코드는 읽기에는 깔끔합니다. 하지만 selector가 객체를 반환한다는 점을 봐야 합니다. JavaScript에서 객체는 내용이 같아도 새로 만들어지면 다른 참조입니다. Zustand는 selector의 반환값을 기준으로 변경 여부를 판단하기 때문에, 객체나 배열을 매번 새로 반환하는 코드는 snapshot을 안정적으로 반환하지 못해 무한 업데이트 오류를 만들 수 있습니다.
이때 useShallow를 검토합니다. useShallow는 selector 결과를 얕게 비교해서, 객체 안의 실제 값이 바뀌지 않았다면 이전 결과를 재사용하는 데 사용합니다. 모든 selector에 붙이는 장식은 아닙니다. 단일 문자열, 숫자, boolean을 가져올 때보다 여러 값을 객체나 배열로 묶어서 반환할 때 먼저 확인할 도구입니다.
객체로 묶어 가져올 때 useShallow 사용하기
개념 부분 코드: src/examples/post-2944-6.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
import { useShallow } from 'zustand/react/shallow';
function FilterSummary() {
const { keyword, category } = useFilterStore(
useShallow((state) => ({ keyword: state.keyword, category: state.category })),
);
return (
<p>
{' '}
검색어: {keyword || '없음'} / 카테고리: {category}{' '}
</p>
);
}
여기서 얕은 비교는 객체 안의 1단계 값만 비교한다는 뜻입니다. keyword와 category가 문자열처럼 단순한 값이면 판단하기 쉽습니다. 반대로 중첩 객체나 배열 안의 값을 직접 바꾸는 구조라면 useShallow만으로 문제가 해결된다고 보면 안 됩니다. Zustand에서 selector를 깔끔하게 쓰려면 store 업데이트도 불변성을 지키는 방식으로 작성해야 합니다. selector는 읽는 범위를 좁히고, update 로직은 상태 변경 기준을 흔들리지 않게 만드는 역할로 나눠 보는 것이 좋습니다.
primitive 값 하나만 가져오는 selector에는 보통 useShallow가 필요하지 않습니다. useFilterStore((state) => state.keyword)처럼 문자열 하나를 가져오는 경우라면 반환값 자체가 비교하기 단순합니다. useShallow는 여러 값을 묶는 선택 결과에서 먼저 떠올리는 편이 낫습니다.
컴포넌트별 selector 적용 기준
selector가 막는 것은 선택 결과가 같은 store 알림에 의한 업데이트입니다. SearchInput의 부모가 다시 렌더하거나 자체 state·context가 바뀌면 별도로 다시 렌더할 수 있습니다. action 함수도 보통 생성 시 참조가 유지될 뿐, 직접 새 함수로 교체하면 구독 결과가 달라집니다.
selector를 잘게 나누는 목적은 코드를 무조건 길게 만드는 데 있지 않습니다. 기준은 컴포넌트의 역할입니다. 컴포넌트가 화면에 보여주는 값, 사용자의 입력을 받아 바꾸는 값, 버튼 클릭으로 실행하는 action을 기준으로 selector를 정하면 됩니다.
필터 화면을 컴포넌트별로 나누면 selector 기준이 더 선명해집니다.
| 컴포넌트 | 필요한 store 값 | selector 기준 |
|---|---|---|
| SearchInput | keyword, setKeyword | 검색어 입력에 필요한 값만 가져옵니다. |
| CategoryTabs | category, setCategory | 카테고리 선택 상태와 변경 action만 가져옵니다. |
| SortSelect | sort, setSort | 정렬 기준과 변경 action만 가져옵니다. |
| ResetFilterButton | resetFilters | 초기화 action만 가져옵니다. |
이런 식으로 나누면 store 하나를 여러 컴포넌트가 공유하더라도 각 컴포넌트의 관심사는 좁게 유지됩니다. 전역 상태를 쓴다고 해서 모든 컴포넌트가 전역 상태 전체를 알아야 하는 것은 아닙니다. 오히려 Zustand를 편하게 쓰려면 store는 공유하되, 컴포넌트가 읽는 범위는 좁히는 쪽이 관리하기 쉽습니다.
반복되는 selector를 커스텀 훅으로 감싸기
개념 부분 코드: src/examples/post-2944-7.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
export function useFilterKeyword() {
return useFilterStore((state) => state.keyword);
}
export function useSetFilterKeyword() {
return useFilterStore((state) => state.setKeyword);
}
export function useFilterReset() {
return useFilterStore((state) => state.resetFilters);
}
selector가 여러 파일에서 반복된다면 커스텀 훅으로 감싸는 방식도 가능합니다. 다만 처음부터 모든 상태에 대해 훅을 만들어두면 파일만 늘어날 수 있습니다. 여러 컴포넌트에서 반복해서 사용하거나, selector 내부 기준을 나중에 바꿀 가능성이 있는 값부터 분리하는 정도가 현실적입니다. store 파일 하나에 모든 selector 훅을 몰아넣는 방식보다, 필터 도메인이나 화면 단위로 묶어두면 찾기도 쉽습니다.
예를 들어 keyword를 단순 문자열로 쓰다가 나중에 앞뒤 공백을 제거한 값으로 보여줘야 한다면, 여러 컴포넌트에 흩어진 selector를 각각 수정하는 것보다 안쪽만 바꾸는 편이 낫습니다. 이런 경우에는 커스텀 훅이 단순 축약 이상의 의미를 가집니다.
코드를 바꿀 때 확인할 부분

기존에 useFilterStore()처럼 전체 store를 가져오던 코드를 selector 방식으로 바꿀 때는 한 번에 모든 코드를 뜯어고치기보다 컴포넌트 단위로 확인하는 것이 안정적입니다. 먼저 해당 컴포넌트가 실제로 화면에 표시하는 값과 이벤트에서 사용하는 action을 나눠봅니다.
그다음 사용하지 않는 상태가 같이 들어와 있는지 확인합니다. 검색창 컴포넌트 안에 sort가 들어와 있다면, 정말 검색창에서 정렬 기준이 필요한지 다시 보는 식입니다. 필요 없다면 selector 범위에서 제거합니다.
마지막으로 selector가 객체나 배열을 반환하는지 확인합니다. 여러 값을 묶어 반환해야 하는 상황이라면 값들이 단순한지, 불필요한 새 참조가 만들어질 수 있는지, useShallow가 필요한지 순서대로 보면 됩니다. React DevTools로 렌더링 흔적을 확인할 때도 이 기준이 있으면 원인을 좁히기 쉽습니다. 이 점검은 성능 최적화 이전에 컴포넌트가 store를 읽는 방식을 정리하는 과정에 가깝습니다.
Hook 호출은 컴포넌트 또는 커스텀 Hook 함수 본문 안에 넣는 부분 코드입니다. import는 파일의 최상위에 두고, Hook을 모듈 최상위에서 호출하지 마세요.
개념 부분 코드: src/examples/post-2944-8.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
import { useShallow } from 'zustand/react/shallow';
const keyword = useFilterStore((state) => state.keyword);
const setKeyword = useFilterStore((state) => state.setKeyword);
const { category, sort } = useFilterStore(
useShallow((state) => ({ category: state.category, sort: state.sort })),
);
위 코드처럼 단일 primitive 값은 그대로 가져오고, 여러 값을 묶어야 할 때만 useShallow를 붙이면 기준이 단순해집니다. 모든 곳에 같은 패턴을 강제로 적용하기보다 반환값의 형태를 보고 판단하는 것이 중요합니다.
설치부터 확인까지: 전체 실행 프로젝트
Node.js 24와 npm을 준비하고 ZIP을 푼 프로젝트 폴더에서 아래 순서로 실행합니다. 여러 파일을 함께 실행하는 완성형 실습입니다. 뷰어의 파일 경로는 ZIP 프로젝트 루트 기준이며, 짧은 개념 예제와 별개입니다.
npm install
npm run build
npm test
npm run dev
Vite가 출력한 로컬 주소를 여세요. 서버·인증·외부 API 없이 실행하는 브라우저 SPA입니다.
현재 실습 ZIP
README.md
# Zustand filters 실습
Node.js 24 및 npm. 이 폴더에서 `npm install`, `npm run build`, `npm test`, `npm run dev` 순서로 실행합니다. Vite가 표시한 로컬 주소를 여세요.
Zustand 5.0.15 / React 19.3.0 / TypeScript 7.0.2 기준입니다.
독립 브라우저 SPA이며 서버·인증·외부 API는 포함하지 않습니다. 자동 테스트는 Vitest와 jsdom 또는 메모리 저장소를 사용합니다. 브라우저 실제 새로고침·기기 성능 검사는 별도로 수행해야 합니다.
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zustand 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
package.json
{
"name": "zustand-filters",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"zustand": "5.0.15"
},
"devDependencies": {
"typescript": "7.0.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1"
}
}
src/App.tsx
import { useShallow } from 'zustand/react/shallow';
import { useFilterStore } from './store';
export function BroadSummary() {
const { filters } = useFilterStore();
return <p>전체 구독: {filters.keyword || '없음'}</p>;
}
export function FilterSummary() {
const { keyword, category } = useFilterStore(
useShallow((state) => ({
keyword: state.filters.keyword,
category: state.filters.category,
})),
);
return (
<p>
요약: {keyword || '없음'} / {category}
</p>
);
}
export function SearchInput() {
const keyword = useFilterStore((state) => state.filters.keyword);
const setKeyword = useFilterStore((state) => state.setKeyword);
return (
<label>
검색어{' '}
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} />
</label>
);
}
export function ResetFilterButton() {
const resetFilters = useFilterStore((state) => state.resetFilters);
return <button onClick={resetFilters}>초기화</button>;
}
function Controls() {
const category = useFilterStore((state) => state.filters.category);
const onlySoldOut = useFilterStore((state) => state.filters.onlySoldOut);
const setCategory = useFilterStore((state) => state.setCategory);
const toggleSoldOut = useFilterStore((state) => state.toggleSoldOut);
const toggleTheme = useFilterStore((state) => state.toggleTheme);
const theme = useFilterStore((state) => state.theme);
return (
<section>
<label>
카테고리{' '}
<select value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="all">전체</option>
<option value="outer">아우터</option>
</select>
</label>
<button aria-pressed={onlySoldOut} onClick={toggleSoldOut}>
품절만 보기
</button>
<button onClick={toggleTheme}>테마 변경</button>
<p>테마: {theme}</p>
</section>
);
}
export default function App() {
return (
<main>
<h1>필터 action과 구독 범위</h1>
<SearchInput />
<Controls />
<FilterSummary />
<BroadSummary />
<ResetFilterButton />
</main>
);
}
src/main.tsx
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('root 요소가 필요합니다.');
createRoot(root).render(<App />);
src/store.test.tsx
// @vitest-environment jsdom
import { afterEach, expect, test } from 'vitest';
import { act, Profiler } from 'react';
import { createRoot } from 'react-dom/client';
import { useFilterStore } from './store';
import { BroadSummary, FilterSummary, SearchInput } from './App';
(
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => useFilterStore.setState(useFilterStore.getInitialState(), true));
test('연속 토글은 최신 값으로 계산하고 다른 필드와 이전 객체를 보존한다', () => {
const api = useFilterStore.getState();
api.setKeyword('coat');
const before = useFilterStore.getState().filters;
api.toggleSoldOut();
api.toggleSoldOut();
api.setCategory('outer');
expect(useFilterStore.getState().filters).toEqual({
keyword: 'coat',
category: 'outer',
onlySoldOut: false,
});
expect(before).toEqual({ keyword: 'coat', category: 'all', onlySoldOut: false });
api.resetFilters();
expect(useFilterStore.getState().filters).toEqual({
keyword: '',
category: 'all',
onlySoldOut: false,
});
expect(useFilterStore.getState().toggleSoldOut).toBe(api.toggleSoldOut);
});
test('부모 고정: 관련 없는 테마 변경은 전체 구독만 갱신한다', async () => {
const host = document.createElement('div');
const root = createRoot(host);
const commits = { broad: 0, narrow: 0, shallow: 0 };
try {
await act(async () =>
root.render(
<>
<Profiler id="broad" onRender={() => commits.broad++}>
<BroadSummary />
</Profiler>
<Profiler id="narrow" onRender={() => commits.narrow++}>
<SearchInput />
</Profiler>
<Profiler id="shallow" onRender={() => commits.shallow++}>
<FilterSummary />
</Profiler>
</>,
),
);
const baseline = { ...commits };
await act(async () => useFilterStore.getState().toggleTheme());
expect(commits.broad).toBeGreaterThan(baseline.broad);
expect(commits.narrow).toBe(baseline.narrow);
expect(commits.shallow).toBe(baseline.shallow);
await act(async () => useFilterStore.getState().setKeyword('coat'));
expect(commits.narrow).toBeGreaterThan(baseline.narrow);
expect(commits.shallow).toBeGreaterThan(baseline.shallow);
expect(host.textContent).toContain('요약: coat / all');
} finally {
await act(async () => root.unmount());
}
});
test('Zustand 5의 매번 새 객체 selector는 snapshot 경고와 업데이트 오류를 재현한다', async () => {
const { vi } = await import('vitest');
const errors: unknown[][] = [];
const log = vi.spyOn(console, 'error').mockImplementation((...args) => {
errors.push(args);
});
const host = document.createElement('div');
const root = createRoot(host);
function Unstable() {
const selection = useFilterStore((state) => ({ keyword: state.filters.keyword }));
return <p>{selection.keyword}</p>;
}
try {
await expect(act(async () => root.render(<Unstable />))).rejects.toThrow(
/Maximum update depth/,
);
expect(errors.flat().join(' ')).toMatch(/getSnapshot.*cached/);
} finally {
await act(async () => root.unmount());
log.mockRestore();
}
});
src/store.ts
import { create } from 'zustand';
type Filters = { keyword: string; category: string; onlySoldOut: boolean };
const initialFilters: Filters = { keyword: '', category: 'all', onlySoldOut: false };
type FilterStore = {
filters: Filters;
theme: 'light' | 'dark';
setKeyword: (keyword: string) => void;
setCategory: (category: string) => void;
toggleSoldOut: () => void;
resetFilters: () => void;
toggleTheme: () => void;
};
export const useFilterStore = create<FilterStore>()((set) => ({
filters: { ...initialFilters },
theme: 'light',
setKeyword: (keyword) => set((state) => ({ filters: { ...state.filters, keyword } })),
setCategory: (category) =>
set((state) => ({ filters: { ...state.filters, category } })),
toggleSoldOut: () =>
set((state) => ({
filters: { ...state.filters, onlySoldOut: !state.filters.onlySoldOut },
})),
resetFilters: () => set({ filters: { ...initialFilters } }),
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}));
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true
},
"include": ["src"]
}
기대 화면과 확인 순서
검색어를 입력하면 요약이 바뀌고 카테고리와 품절 버튼도 반영됩니다. 두 번 토글하면 원래 상태, 초기화하면 빈 검색어/all/false입니다. 테마 변경은 검색어를 바꾸지 않습니다. Profiler 구독 비교는 테스트 결과로 확인합니다.
검증 범위: npm 설치, TypeScript 타입 검사와 Vite production 빌드, Vitest 자동 테스트를 실행했습니다. UI 테스트는 jsdom이며 실제 기기의 레이아웃·성능 측정은 포함하지 않습니다.
마무리 점검
Zustand selector는 store에서 값을 꺼내는 짧은 문법이 아니라, 컴포넌트가 어떤 상태를 구독할지 정하는 기준입니다. 전체 store를 가져오는 코드는 처음에는 빠르게 작성할 수 있지만, 상태가 늘어나면 컴포넌트의 관심사가 쉽게 넓어집니다.
값 하나만 필요하면 해당 값만 selector로 가져오고, action 하나만 필요하면 action만 가져오면 됩니다. 여러 값을 한 번에 가져와야 한다면 객체나 배열을 반환하게 되므로 참조 비교 문제를 같이 봐야 합니다. 이때 useShallow는 모든 selector에 붙이는 규칙이 아니라, 선택 결과가 새 참조를 만들 수 있을 때 검토하는 도구입니다.
다음에 Zustand store를 컴포넌트에 연결할 때는 “이 컴포넌트가 store 전체를 알아야 하는가”를 먼저 확인하면 됩니다. 대부분의 경우 필요한 값은 생각보다 적습니다. selector는 그 범위를 코드에 남기는 역할을 하고, 이 기준이 잡혀 있으면 store가 커져도 컴포넌트 구조가 덜 흔들립니다.
새로고침 후에도 필요한 상태를 유지해야 한다면 Zustand persist로 새로고침 후 상태 저장하기에서 저장 범위와 hydration 기준을 함께 확인할 수 있습니다.
공식 근거와 확인 범위
확인일: 2026-09-12. 실행 프로젝트는 Zustand 5.0.15, React 19.3.0, TypeScript 7.0.2로 검사했습니다. 공식 문서의 API 설명과 실제 설치 버전의 동작을 구분해 확인합니다.
같이 읽으면 좋은 글
연결 학습: selector 결과의 참조 확인
목표·선행 지식: 필요한 값만 선택하고 선택 결과가 언제 바뀌는지 설명합니다.
원시값을 선택하는 것과 매번 새 객체를 만들어 반환하는 것은 다릅니다. 여러 값을 묶는 selector는 참조 안정성과 useShallow 적용을 검토합니다. selector를 사용해도 부모 렌더나 context 변경 같은 다른 렌더 경로가 사라지는 것은 아닙니다.
직접 확인할 과제
count를 읽는 컴포넌트와 actions만 읽는 컴포넌트를 나누세요. 무관한 상태를 변경하고 React Profiler로 실제 렌더를 비교합니다. 콘솔 호출 횟수만으로 개발 모드 전체를 단정하지 않습니다.
공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드
공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.
이 글이 도움이 되었나요?
Zustand 학습 순서
필수 14개 · 전체 15개
읽음 기록 관리
전체 과정 목차 (15개)
- 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
- 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
- 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
- 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
- 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기
- 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기
- 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기 현재 글
- 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
- 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기
- 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
- 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
- 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
- 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성
- 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
- 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.