상태관리 라이브러리 Zustand를 쓰다 보면, 여러 상태를 한꺼번에 구독할 때 리렌더링이 생각보다 자주 일어나는 문제를 겪게 된다. 특히 객체를 반환하는 셀렉터(selector)를 쓸 때 그렇다.
이 글에서는 그 원인과, useShallow를 어떻게 활용하면 좋은지, 그리고 왜 원시값 중심으로 상태를 설계해야 복잡도가 줄어드는지 정리해본다.
🧩 Zustand의 기본 구독 구조
보통 우리는 Zustand에서 이렇게 상태를 꺼내 쓴다.
const count = useStore((s) => s.count);
이 경우 count 값이 바뀔 때만 컴포넌트가 리렌더링된다. 간단하다.
그런데 아래처럼 객체 형태로 여러 값을 묶어서 반환하면 이야기가 달라진다.
const { count, user } = useStore((s) => ({
count: s.count,
user: s.user,
}));
이렇게 하면 매번 새로운 객체가 만들어지기 때문에, Zustand는 “값이 바뀌었다”고 인식하고 리렌더링을 강제로 실행한다. 내용이 같더라도 객체 참조가 달라지기 때문이다.
⚙️ useShallow의 등장
이럴 때 등장한 것이 useShallow다. 이름 그대로 얕은 비교(shallow equality) 를 해준다.
즉, 객체 안의 각 프로퍼티를 비교해서 값이 똑같으면 “이전이랑 같네?” 하고 리렌더링을 막아준다.
import { useShallow } from 'zustand/react/shallow'
const { name, age } = useUserStore(
useShallow((s) => ({
name: s.name,
age: s.age,
}))
);
이렇게 하면 name, age 중 실제 값이 바뀐 항목이 있을 때만 렌더링이 일어난다.
🔬 useShallow를 직접 체감하기
아래는 useShallow의 효과를 눈으로 확인할 수 있는 간단한 예제다.
const useUserStore = create((set) => ({
name: 'Flow',
age: 30,
updateName: (name) => set({ name }),
updateAge: (age) => set({ age }),
}));
function WithoutShallow() {
const { name, age } = useUserStore((s) => ({ name: s.name, age: s.age }));
console.log('🚨 WithoutShallow 렌더링!');
return <p>{name} ({age})</p>;
}
function WithShallow() {
const { name, age } = useUserStore(
useShallow((s) => ({ name: s.name, age: s.age }))
);
console.log('✨ WithShallow 렌더링!');
return <p>{name} ({age})</p>;
}
➡️ 결과:
- WithoutShallow는 값이 같아도 매번 렌더링됨
- WithShallow는 실제 값이 바뀌었을 때만 렌더링됨
🧠 객체 참조의 함정
React와 Zustand는 상태 변경 여부를 판단할 때 객체의 내용이 아니라 참조(reference) 를 기준으로 본다.
const a = { name: 'Flow' };
const b = { name: 'Flow' };
console.log(a === b); // false ❌
즉, 같은 내용의 객체라도 새로 만들어지면 Zustand는 “값이 바뀌었다”고 생각한다. 그래서 아래 코드처럼 set을 두 번 호출하면 리렌더링이 일어난다.
set({ user: { name: 'Flow' } }); // 1차
set({ user: { name: 'Flow' } }); // 2차
// 내용은 같지만 새 객체 → 리렌더링 발생 ❌
이런 이유로 useShallow는 객체 구조를 다루기에 완벽하지 않다.
💡 useShallow는 원시값에 강하다
useShallow는 얕은 비교만 하기 때문에, 객체나 배열보다는 원시값(Primitive) 에서 가장 큰 효과를 발휘한다.
타입 비교 결과
| 숫자 / 문자열 / 불리언 | ✅ 내용이 같으면 동일로 인식 |
| 객체 / 배열 | ❌ 참조가 다르면 다른 값으로 인식 |
즉, useShallow를 쓸 거라면 이런 식으로 상태를 설계하는 게 가장 깔끔하다.
const useUserStore = create(() => ({
name: 'Flow',
age: 32,
isOnline: true,
}));
const { name, age, isOnline } = useUserStore(
useShallow((s) => ({ name: s.name, age: s.age, isOnline: s.isOnline }))
);
여기서 세 필드 모두 원시값이므로, 값이 바뀌지 않으면 렌더링도 안 일어난다. 👌
⚠️ 객체/배열에는 효과가 거의 없음
아래처럼 객체나 배열을 구독하면 shallow 비교가 의미가 거의 없다.
const { user, tasks } = useStore(
useShallow((s) => ({
user: s.user, // 객체
tasks: s.tasks // 배열
}))
);
이 경우, set({ user: { name: 'Flow' } })처럼 새 객체를 만들면 매번 참조가 달라져서 결국 리렌더링된다.
🚀 실무 팁: 원시값 중심 설계
Zustand에서 복잡도를 줄이고 싶다면, 객체보다 원시값 중심으로 상태를 쪼개서 관리하는 것이 훨씬 낫다.
이유 설명
| ✅ 비교가 단순함 | === 비교로 충분하므로 리렌더링 제어가 명확함 |
| ✅ 최적화와 궁합 | useShallow, useMemo, React.memo가 제대로 동작 |
| ✅ 디버깅 용이 | Devtools에서 상태 추적이 쉬움 |
| ✅ 유지보수 편함 | 타입, 테스트, 로직 분리 모두 단순화 |
예시 비교
❌ 복잡한 구조
const useStore = create(() => ({ user: { name: 'Flow', age: 32 } }));
set({ user: { ...state.user, name: 'Mia' } }); // 참조 변경 → 렌더링 발생
✅ 단순 구조
const useStore = create(() => ({ userName: 'Flow', userAge: 32 }));
set({ userName: 'Mia' }); // 필요한 필드만 갱신
🧾 결론
useShallow는 여러 원시값을 한꺼번에 구독할 때 불필요한 렌더링을 막아주는 강력한 도구다.
하지만 객체나 배열은 참조가 달라지면 여전히 리렌더링이 발생하므로, 상태를 원시값 중심으로 쪼개는 구조가 가장 깔끔하고 안정적이다.
핵심 요약:
- 원시값 중심 설계 → 단순하고 예측 가능한 렌더링
- 객체나 배열 → shallow 비교 한계 있음
- useShallow는 원시값 조합 구독에서 진가를 발휘함
💬 실무적으로는 이렇게 정리할 수 있다:
“Zustand는 심플할수록 강력하다. 객체보다 원시값으로 쪼개라, shallow는 그때 빛난다.”
'Googling > React + Next + Eco' 카테고리의 다른 글
| Zustand 쓸 때 setter 바로 찍으면 왜 값이 안 바뀌는 걸까? (0) | 2025.10.18 |
|---|---|
| Next.js 배럴(Barrel) 파일 가이드 (14+) (0) | 2025.10.09 |
| Zustand에서 `useStore` 값 꺼내 쓰는 다양한 패턴과 shallow 비교 (0) | 2025.10.03 |