학습 목표·선수 지식: 같은 참조 변이, leaf selector, getState 단발 읽기, 다른 store 인스턴스를 한 화면에서 따로 진단합니다. 선수 지식: action, selector, 객체 불변 업데이트.
Zustand 값은 바뀌는데 화면이 그대로일 때 확인할 순서
Zustand 상태 변경 후 리렌더링이 안 되는 문제는 대개 네 범주로 좁혀집니다. 기존 객체·배열을 직접 수정했거나, 컴포넌트가 변경된 값을 구독하지 않거나, selector가 이전과 같은 결과를 반환하거나, 화면과 업데이트 코드가 서로 다른 store 인스턴스를 쓰는 경우입니다. 아래 순서대로 store 변경 여부와 React 구독 여부를 분리해 확인하면 불필요한 수정 범위를 줄일 수 있습니다.
검증 기준: 2026-09-12, npm의 Zustand 5.0.15와 현재 공식 문서를 기준으로 예제를 확인했습니다. 4.x 코드를 유지하는 프로젝트라면 equality 함수 전달 방식 등 버전 차이를 먼저 확인하세요.
실습 경로: 현재 ZIP → 전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.
증상별로 첫 확인 지점을 정합니다

| 관찰한 증상 | 가능성이 큰 원인 | 첫 확인 |
|---|---|---|
getState() 값도 그대로임 |
action 미호출, 조건문 조기 종료, 다른 store 호출 | action 시작·끝에 현재 값 기록 |
getState()는 바뀌지만 화면은 그대로임 |
구독하지 않은 필드, 동일한 selector 결과 | 컴포넌트의 selector 반환값 확인 |
| 객체의 내부 필드만 바꿀 때 재현됨 | 기존 참조 직접 수정, 중첩 객체 병합 누락 | 각 중첩 단계에 새 객체가 생기는지 확인 |
| 여러 값을 객체·배열로 묶을 때 렌더 기준이 이상함 | 매 렌더 새 결과 생성 또는 equality 방식 불일치 | 분리 구독 또는 useShallow 적용 |
| 새로고침 직후에만 초기 화면이 다름 | persist 재수화 시점 |
일반 리렌더 문제와 hydration 문제를 분리 |
1단계: store가 실제로 바뀌었는지 화면과 분리해 확인합니다
먼저 “React가 다시 그리지 않는다”와 “store 자체가 바뀌지 않았다”를 분리해야 합니다. action을 호출하기 전후에 같은 store의 getState()를 확인하면 첫 분기가 명확해집니다. 운영 코드에 로그를 계속 남기기보다 로컬 재현에서 잠시 사용하고 제거하세요.
개념 부분 코드: src/examples/post-2991-1.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
console.log('before', useUserStore.getState().user);
useUserStore.getState().rename('Haebi');
console.log('after', useUserStore.getState().user);
after 값도 그대로라면 selector를 고칠 단계가 아닙니다. 클릭 핸들러가 실행되는지, action이 조건문에서 끝나지 않는지, import한 store가 화면에서 쓰는 store와 같은 파일·인스턴스인지 확인하세요. 반대로 store 값은 바뀌었는데 화면만 그대로라면 다음 단계에서 참조와 구독 범위를 봅니다.
Zustand 공식 Flux 방식 권장 사항은 상태 변경에 set 또는 setState를 사용해 병합과 listener 통지를 거치도록 안내합니다.
2단계: 객체와 배열은 새 참조로 업데이트합니다
기존 객체를 직접 바꾼 뒤 같은 객체를 반환하면 값은 달라 보여도 구독 결과의 참조가 그대로일 수 있습니다. 특히 외부에서 getState()로 꺼낸 객체를 직접 수정하는 코드는 피해야 합니다. 상태는 set 안에서 새 객체나 새 배열로 만들어 반환하세요.
개념 부분 코드: src/examples/post-2991-2.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
// 피해야 할 코드: 기존 객체와 같은 참조를 다시 반환
renameWrong: (name) =>
set((state) => {
state.user.name = name;
return { user: state.user };
}),
// 권장 코드: 변경되는 중첩 단계에 새 참조 생성
rename: (name) =>
set((state) => ({
user: { ...state.user, name },
})),
중첩 객체는 한 단계 병합만 된다는 점을 확인합니다
Zustand의 set은 기본적으로 최상위 한 단계만 얕게 병합합니다. user.profile.name처럼 더 깊은 값을 바꾸면 user와 profile을 각각 복사해야 합니다. 이 동작은 공식 불변 상태와 병합 가이드와 상태 업데이트 가이드에서 확인할 수 있습니다.
개념 부분 코드: src/examples/post-2991-3.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
name: 'Haebi',
},
},
}));
배열도 push, splice, sort로 원본을 바꾸기보다 map, filter, 전개 구문 등으로 새 배열을 반환합니다. Map과 Set 역시 내부를 수정한 같은 인스턴스가 아니라 새 인스턴스를 만들어야 합니다.
3단계: 컴포넌트가 변경된 값을 실제로 구독하는지 봅니다
컴포넌트는 store 전체가 아니라 selector가 반환한 값의 변화를 기준으로 다시 렌더링됩니다. user.age를 바꿨는데 컴포넌트가 user.name만 구독한다면 다시 그려지지 않는 것이 정상입니다. 화면에 필요한 값을 selector가 실제로 반환하는지 확인하세요.
Hook 호출은 컴포넌트 또는 커스텀 Hook 함수 본문 안에 넣는 부분 코드입니다. import는 파일의 최상위에 두고, Hook을 모듈 최상위에서 호출하지 마세요.
개념 부분 코드: src/examples/post-2991-4.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
// store 구독 경로에서는 name의 변화에 반응합니다. 부모 렌더 등은 별개입니다.
const name = useUserStore((state) => state.user.name);
// age도 화면에 필요하면 별도로 구독
const age = useUserStore((state) => state.user.age);
진단 중에는 selector 안에 복잡한 변환을 넣기 전에 원시 필드를 각각 구독해 보세요. 이 상태에서 화면이 갱신되면 action보다 selector 결과 생성 방식이 원인일 가능성이 큽니다. selector 최적화의 원리는 Zustand 리렌더링 원리와 selector 최적화 방법에서 이어서 볼 수 있습니다.
4단계: 계산된 객체·배열 selector는 useShallow 필요 여부를 판단합니다

selector가 객체나 배열을 새로 만들면 결과 비교 방식이 렌더 횟수에 영향을 줍니다. Zustand 5의 기본 create 사용 예에서는 필요한 값을 각각 구독하는 방식이 가장 단순합니다. 여러 값을 한 번에 묶어야 하고 각 최상위 값이 같을 때 렌더를 건너뛰려면 공식 가이드의 useShallow 패턴을 사용할 수 있습니다.
Hook 호출은 컴포넌트 또는 커스텀 Hook 함수 본문 안에 넣는 부분 코드입니다. import는 파일의 최상위에 두고, Hook을 모듈 최상위에서 호출하지 마세요.
개념 부분 코드: src/examples/post-2991-5.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
import { useShallow } from 'zustand/react/shallow';
const { name, rename } = useUserStore(
useShallow((state) => ({
name: state.user.name,
rename: state.rename,
})),
);
useShallow는 결과의 최상위 값을 얕게 비교합니다. 깊은 중첩 객체 내부를 직접 수정한 문제를 해결해 주는 도구는 아닙니다. 먼저 불변 업데이트를 보장한 다음 렌더 최적화 목적으로 적용하세요. 정확한 동작과 현재 import 경로는 공식 useShallow로 불필요한 렌더 방지하기 문서를 기준으로 확인할 수 있습니다.
5단계: 서로 다른 store 인스턴스와 persist hydration을 구분합니다
화면과 action이 같은 store를 import하는지 확인합니다
컴포넌트는 A 인스턴스를 구독하는데 이벤트 핸들러는 B 인스턴스를 바꾸면 양쪽 모두 정상 동작하는 것처럼 보여도 화면은 갱신되지 않습니다. store factory를 렌더마다 호출하거나, provider 안팎에서 서로 다른 vanilla store를 만들거나, 별칭 경로와 상대 경로가 중복 모듈로 번들되는 구조를 점검하세요. store 생성은 정해진 소유 위치에서 한 번만 하고 화면과 action이 같은 export를 사용하도록 맞춥니다.
새로고침 직후에만 다르면 persist 재수화를 따로 봅니다
버튼을 눌렀을 때 상태 변경이 반영되지 않는 문제와, 새로고침 직후 저장 상태가 늦게 복원되는 문제는 원인이 다릅니다. 후자는 storage를 읽어오는 재수화 시점, 저장된 version, migrate 동작을 확인해야 합니다. 상태 구조가 바뀐 배포라면 Zustand persist 마이그레이션 기준을 함께 확인하세요.
동작하는 최소 예제로 비교합니다
세 종류의 읽기를 같은 화면에서 비교
전체 실습의 잘못된 변이 버튼은 user를 직접 바꾸고 같은 참조를 반환합니다. 객체 구독은 Guest로 남지만 name 원시 값 구독은 Mutated로 바뀔 수 있습니다. 이는 변이가 안전하다는 뜻이 아니라 selector 반환값에 따라 증상이 달라짐을 보여 줍니다. getState만 호출한 단발 읽기는 구독하지 않아 Guest로 남습니다.
불변 변경 버튼은 새 user 객체를 만들므로 객체·이름 구독이 Fixed를 표시합니다. 단발 읽기는 그대로이며 다른 store 변경 버튼은 otherStore만 바꾸므로 화면 store는 Fixed를 유지합니다. 부모를 다시 렌더하면 단발 읽기도 최신값을 우연히 읽을 수 있어 구독이 정상인 것처럼 보일 수 있습니다.
아래 예제는 새 객체를 반환하고, 컴포넌트가 변경되는 원시 값과 action을 각각 구독합니다. 현재 코드와 이 예제를 비교할 때 store 생성 위치, action 호출, selector 반환값을 한 항목씩 바꾸면 원인을 좁히기 쉽습니다.
같은 화면에서 실패 원인을 비교하는 전체 프로젝트가 아래에 있습니다. 일부러 잘못된 renameWrong과 정상 rename을 함께 둔 진단용 실습입니다. 정상 코드에 renameWrong을 복사하지 마세요. 전체 파일과 실행 안내 보기
이 예제도 갱신되지 않으면 브라우저 콘솔 오류, 이벤트 차단, 중복 React/모듈 인스턴스 같은 프로젝트 환경을 확인합니다. 반대로 최소 예제는 동작한다면 기존 store에 객체 직접 수정, 조건부 action, 복잡한 selector, provider 경계 중 하나가 추가되면서 문제가 생긴 것입니다.
최종 체크리스트와 결론
결론: 가장 먼저 store가 실제로 바뀌는지 확인하고, 다음으로 새 참조와 selector 구독 범위를 검사하세요. useShallow는 불변 업데이트 오류를 가리는 해결책이 아니라 계산된 selector의 렌더 기준을 조정하는 도구입니다. 이 순서를 지키면 “Zustand가 리렌더링하지 않는다”는 넓은 증상을 action, 참조, selector, 인스턴스 문제로 구체화할 수 있습니다.
다음 학습 경로
- Zustand 기본 사용법과 실전 글 모음 — store와 action 구조를 처음부터 점검할 때
- Zustand 리렌더링 원리와 selector 최적화 방법 — 렌더 기준을 더 깊게 이해할 때
- React state vs Zustand — 전역 상태가 필요한 범위를 다시 판단할 때
업데이트 기록: 2026-09-12 — Zustand 5.0.15 공식 문서 기준으로 equality 예제를 useShallow 방식으로 교정하고, store 인스턴스·persist hydration 진단 절차를 보강했습니다.
설치부터 확인까지: 전체 실행 프로젝트
Node.js 24와 npm을 준비하고 ZIP을 푼 프로젝트 폴더에서 아래 순서로 실행합니다. 여러 파일을 함께 실행하는 완성형 실습입니다. 뷰어의 파일 경로는 ZIP 프로젝트 루트 기준이며, 짧은 개념 예제와 별개입니다.
npm install
npm run build
npm test
npm run dev
Vite가 출력한 로컬 주소를 여세요. 서버·인증·외부 API 없이 실행하는 브라우저 SPA입니다.
현재 실습 ZIP
README.md
# Zustand diagnosis 실습
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-diagnosis",
"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.test.tsx
// @vitest-environment jsdom
import { expect, test } from 'vitest';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { screenStore, otherStore } from './store';
(
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
test('같은 user 참조, leaf, getState 비구독, 다른 인스턴스를 독립 관찰', async () => {
const host = document.createElement('div');
const root = createRoot(host);
try {
await act(async () => root.render(<App />));
await act(async () => screenStore.getState().renameWrong('Mutated'));
expect(host.textContent).toContain('객체 구독: Guest');
expect(host.textContent).toContain('이름 구독: Mutated');
expect(host.textContent).toContain('단발 읽기: Guest');
await act(async () => screenStore.getState().rename('Fixed'));
expect(host.textContent).toContain('객체 구독: Fixed');
expect(host.textContent).toContain('이름 구독: Fixed');
expect(host.textContent).toContain('단발 읽기: Guest');
await act(async () => otherStore.getState().rename('Other'));
expect(screenStore.getState().user.name).toBe('Fixed');
expect(otherStore.getState().user.name).toBe('Other');
} finally {
await act(async () => root.unmount());
screenStore.setState({ user: { name: 'Guest', age: 20 } });
}
});
src/App.tsx
import { useStore } from 'zustand';
import { otherStore, screenStore } from './store';
export function ObjectName() {
const user = useStore(screenStore, (state) => state.user);
return <p>객체 구독: {user.name}</p>;
}
export function LeafName() {
const name = useStore(screenStore, (state) => state.user.name);
return <p>이름 구독: {name}</p>;
}
export function SnapshotName() {
const name = screenStore.getState().user.name;
return <p>단발 읽기: {name}</p>;
}
export default function App() {
return (
<main>
<h1>구독과 참조 진단</h1>
<ObjectName />
<LeafName />
<SnapshotName />
<button onClick={() => screenStore.getState().renameWrong('Mutated')}>
잘못된 변이
</button>
<button onClick={() => screenStore.getState().rename('Fixed')}>불변 변경</button>
<button onClick={() => otherStore.getState().rename('Other')}>
다른 store 변경
</button>
</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.ts
import { createStore } from 'zustand/vanilla';
type UserStore = {
user: { name: string; age: number };
renameWrong: (name: string) => void;
rename: (name: string) => void;
};
export const createUserStore = () =>
createStore<UserStore>()((set) => ({
user: { name: 'Guest', age: 20 },
renameWrong: (name) =>
set((state) => {
state.user.name = name;
return { user: state.user };
}),
rename: (name) => set((state) => ({ user: { ...state.user, name } })),
}));
export const screenStore = createUserStore();
export const otherStore = createUserStore();
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"]
}
기대 화면과 확인 순서
잘못된 변이 → 객체 Guest/이름 Mutated/단발 Guest, 불변 변경 → 객체 Fixed/이름 Fixed/단발 Guest 순서로 보입니다. 다른 store 변경은 화면 값을 바꾸지 않습니다.
검증 범위: npm 설치, TypeScript 타입 검사와 Vite production 빌드, Vitest 자동 테스트를 실행했습니다. UI 테스트는 jsdom이며 실제 기기의 레이아웃·성능 측정은 포함하지 않습니다.
공식 근거와 확인 범위
확인일: 2026-09-12. 실행 프로젝트는 Zustand 5.0.15, React 19.3.0, TypeScript 7.0.2로 검사했습니다. 공식 문서의 API 설명과 실제 설치 버전의 동작을 구분해 확인합니다.
이 글이 도움이 되었나요?
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의 새 글을 확인할 수 있습니다.