Zustand 리렌더링 문제는 변경 감지 기준부터 봐야 합니다
Zustand에서 store 값을 바꿨는데 화면이 그대로라면 먼저 “값이 바뀌었는가”와 “컴포넌트가 구독한 값이 바뀐 것으로 비교되었는가”를 나눠 봐야 합니다. 객체나 배열을 직접 수정하면 내부 값은 달라져도 참조가 그대로라 selector 결과가 같다고 판단될 수 있습니다. 이 글에서는 불변 업데이트, selector 범위, equality 함수, subscribe와 차이, 중첩 상태 업데이트까지 순서대로 점검합니다.
- Zustand에서 리렌더링이 일어나는 기준
- 상태를 직접 수정했을 때 생기는 문제
- 객체와 배열은 새 참조로 업데이트하기
- selector와 equality 함수 점검하기
- subscribe와 useStore를 헷갈리지 않기
- 중첩 상태 업데이트 체크리스트
- 참고 자료
Zustand에서 리렌더링이 일어나는 기준

Zustand를 처음 쓸 때 가장 흔한 오해는 store가 바뀌면 그 store를 쓰는 모든 컴포넌트가 무조건 다시 렌더링된다는 생각입니다. 실제로는 컴포넌트가 useStore 안에서 선택한 값, 즉 selector 결과를 구독합니다. 그 결과가 이전 값과 달라졌다고 판단될 때 컴포넌트가 다시 렌더링됩니다.
예를 들어 useTodoStore((state) => state.todos)를 쓰는 컴포넌트는 todos 배열을 구독합니다. set이 호출되어 store가 갱신되더라도 selector가 반환한 배열 참조가 이전과 같으면 React 쪽에서는 바뀐 값으로 보기 어렵습니다. 그래서 “store에는 값이 들어갔는데 화면이 그대로”인 상황이 생깁니다.
핵심은 참조 동일성입니다. 숫자나 문자열 같은 원시 값은 값 자체가 달라지면 비교가 쉽지만, 객체와 배열은 내부 내용이 달라져도 같은 객체를 계속 가리키면 이전 값과 같은 것으로 판단될 수 있습니다. Zustand 리렌더링 문제를 볼 때는 항상 selector가 무엇을 반환하는지, 그 반환값의 참조가 바뀌는지부터 확인해야 합니다.
상태를 직접 수정했을 때 생기는 문제
가장 흔한 원인은 객체나 배열을 직접 수정하는 코드입니다. JavaScript에서는 배열에 push를 하거나 객체 속성을 직접 바꿔도 같은 배열같은 객체를 계속 사용합니다. 내부 내용은 바뀌었지만 바깥 참조는 유지됩니다.
const useTodoStore = create((set) => ({ todos: [], addTodo: (todo) => set((state) => { state.todos.push(todo); // 기존 배열을 직접 수정 return { todos: state.todos }; // 같은 배열 참조를 다시 반환 })})); 이 코드는 겉으로 보면 todo가 추가됩니다. 하지만 state.todos가 가리키는 배열 자체는 그대로입니다. 컴포넌트가 todos 배열을 selector로 구독하고 있다면 이전 selector 결과와 새 selector 결과가 같은 참조일 수 있고이때 리렌더링이 기대처럼 일어나지 않을 수 있습니다.
객체도 마찬가지입니다. state.user.name = 'Kim'처럼 직접 수정한 뒤 { user: state.user }를 반환하면 user 객체 참조가 바뀌지 않습니다. Zustand가 문제가 아니라 React 상태 관리에서 중요한 불변성 규칙을 어긴 코드에 가깝습니다.
객체와 배열은 새 참조로 업데이트하기
해결의 기본은 새 배열과 새 객체를 반환하는 것입니다. 배열에는 push 대신 spread 문법이나 map, filter처럼 새 배열을 만드는 방식을 사용합니다.
const useTodoStore = create((set) => ({ todos: [], addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo]})), toggleTodo: (id) => set((state) => ({ todos: state.todos.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo )}))})); 이렇게 작성하면 todos 배열의 참조가 새로 만들어집니다. 특정 todo를 수정할 때도 해당 todo 객체만 새 객체로 바꾸고, 나머지는 기존 객체를 그대로 재사용할 수 있습니다. 중요한 점은 변경이 필요한 경로에서 새 참조를 만들어 selector가 변화를 감지할 수 있게 하는 것입니다.
객체 업데이트도 같은 원리입니다. user 이름을 바꾼다면 기존 객체를 직접 수정하지 말고 { ...state.user, name }처럼 새 객체를 만듭니다.
setUserName: (name) => set((state) => ({ user: { ...state.user, name}})); selector와 equality 함수 점검하기

상태를 올바르게 새 참조로 만들었는데도 헷갈린다면 selector를 봐야 합니다. selector가 너무 넓으면 필요 없는 변경에도 자주 렌더링되고, 너무 좁거나 엉뚱한 값을 고르면 원하는 변경을 구독하지 못합니다.
// 필요한 값만 명확히 구독
const count = useCartStore((state) => state.items.length);
const totalPrice = useCartStore((state) => state.totalPrice); 여러 값을 객체로 묶어 반환할 때는 매 렌더마다 새 객체가 만들어질 수 있습니다. 이때 Zustand의 shallow 비교를 사용할 수 있습니다. 다만 shallow는 모든 문제를 해결하는 마법이 아닙니다. 얕은 비교는 객체의 바로 아래 속성만 비교하므로, 중첩 객체 내부를 직접 수정한 문제까지 자동으로 고쳐주지는 않습니다.
import { shallow } from 'zustand/shallow'; const { userName, cartCount } = useAppStore( (state) => ({ userName: state.user.name, cartCount: state.cart.items.length}), shallow
); selector의 기준은 “이 컴포넌트가 화면을 그리는 데 실제로 필요한 값인가?”입니다. 필요한 값만 구독하고, 여러 값을 묶어야 할 때만 equality 함수를 검토하는 편이 안전합니다.
subscribe와 를 헷갈리지 않기
Zustand에는 React 컴포넌트 안에서 쓰는 useStore 방식과 컴포넌트 밖에서 store 변화를 감지하는 subscribe 방식이 있습니다. subscribe 콜백이 실행된다는 사실이 곧 특정 React 컴포넌트가 리렌더링된다는 뜻은 아닙니다.
컴포넌트가 다시 렌더링되려면 그 컴포넌트가 useStore로 구독한 selector 결과가 바뀌어야 합니다. 반대로 로그나 웹소켓 처리처럼 React 화면과 직접 연결되지 않은 작업은 subscribe가 적합할 수 있습니다.
const unsubscribe = useCartStore.subscribe((state) => { console.log('cart changed', state.items);
}); // React 화면 갱신은 컴포넌트 안의 useStore selector 기준
const items = useCartStore((state) => state.items); getState로 최신 값을 읽는 경우도 마찬가지입니다. 값을 읽을 수는 있지만, 그 읽기 자체가 컴포넌트를 구독시키지는 않습니다. 화면과 연결해야 한다면 컴포넌트 안에서 useStore selector를 사용해야 합니다.
중첩 상태 업데이트 체크리스트
중첩 상태는 더 조심해야 합니다. 예를 들어 settings.theme.color를 바꾸면서 settings 객체 참조를 그대로 두면, settings를 구독하는 컴포넌트는 변경을 감지하지 못할 수 있습니다. 변경되는 경로의 각 레벨에서 새 객체를 만들어야 합니다.
setThemeColor: (color) => set((state) => ({ settings: { ...state.settings, theme: { ...state.settings.theme, color}}})); 리렌더링되지 않는 문제를 만났다면 아래 순서로 확인해보면 됩니다.
- 배열에
push,splice처럼 원본을 바꾸는 메서드를 쓰지 않았는지 확인합니다. - 객체 속성을 직접 수정한 뒤 같은 객체를 반환하지 않았는지 확인합니다.
- 컴포넌트가
useStoreselector로 실제 필요한 값을 구독하는지 확인합니다. shallow를 쓰고 있다면 얕은 비교로 충분한 구조인지 확인합니다.- 중첩 상태는 변경되는 경로마다 새 객체를 만들었는지 확인합니다.
subscribe나getState확인 결과를 React 리렌더링과 혼동하지 않습니다.
정리하면 Zustand 리렌더링 문제는 대부분 “Zustand가 화면을 못 바꾼다”가 아니라 “컴포넌트가 구독한 값이 바뀐 것으로 보이지 않는다”에서 출발합니다. 먼저 직접 수정을 없애고, 새 참조를 반환하고, 그다음 selector와 equality 기준을 다듬는 순서가 가장 안정적입니다.
참고 자료
Zustand 공식 문서의 selector, updating state, shallow 비교, subscribe 사용 설명과 React 공식 문서의 state 불변 업데이트 원칙을 기준으로 정리했습니다.
같이 읽으면 좋은 글
- React 처음 배우는 순서: 컴포넌트부터 state, Zustand까지
- Zustand 학습 순서: store, action, selector, persist까지
- React state vs Zustand: 언제 전역 상태가 필요할까
“Zustand 리렌더링 문제 해결: 상태 변경 후 화면이 바뀌지 않을 때”에 대한 7개의 생각