Zustand useShallow
Zustand 스토어에서 상태를 구독할 때, 셀렉터가 반환하는 값이 "참조적으로 동일한지"에 따라 리렌더링 여부가 결정된다. 문제는 객체나 배열을 반환하는 셀렉터는 매번 새로운 참조를 만들기 때문에, 실제 데이터가 변하지 않았는데도 컴포넌트가 리렌더링된다는 것이다. useShallow는 이 문제를 얕은 비교(shallow comparison)로 해결하는 훅이다.
왜 리렌더링이 발생하는가
Zustand의 기본 동작을 이해하려면 먼저 Object.is 비교를 알아야 한다.
const store = create(() => ({
bears: 0,
fish: 0,
name: 'pond',
}))
원시값을 반환하는 셀렉터는 문제가 없다.
// bears가 변할 때만 리렌더링 — 원시값이라 Object.is로 비교 가능
const bears = useStore((state) => state.bears)
state.bears는 숫자다. 숫자는 값 자체가 같으면 Object.is로 비교했을 때 true가 나온다. 그래서 다른 상태(fish, name)가 변해도 bears가 그대로면 리렌더링이 발생하지 않는다.
하지만 객체나 배열을 반환하면 이야기가 달라진다.
// ❌ 매번 리렌더링됨
const { bears, fish } = useStore((state) => ({
bears: state.bears,
fish: state.fish,
}))
이 셀렉터는 호출될 때마다 새로운 객체 리터럴을 생성한다. JavaScript에서 {} !== {}이기 때문에 Object.is는 항상 false를 반환한다. 결과적으로 스토어의 어떤 값이 변하든 — 심지어 name만 변해도 — 이 컴포넌트는 리렌더링된다.
배열도 마찬가지다.
// ❌ 매번 리렌더링됨
const keys = useStore((state) => Object.keys(state))
Object.keys()는 매번 새 배열을 반환한다. 스토어의 키가 ['bears', 'fish', 'name']으로 동일하더라도, 배열 자체는 새 참조이므로 Object.is는 false다.
이것이 Zustand 리렌더링 최적화의 핵심 함정이다. 셀렉터의 반환값이 참조 타입(객체, 배열)이면 기본 비교 방식으로는 변화를 올바르게 감지할 수 없다.
Object.is vs 얕은 비교
두 비교 방식의 차이를 명확하게 정리하자.
Object.is (Zustand 기본값)
Object.is(1, 1) // true — 같은 원시값
Object.is('hello', 'hello') // true — 같은 문자열
Object.is({a: 1}, {a: 1}) // false — 다른 참조
Object.is([1, 2], [1, 2]) // false — 다른 참조
const obj = {a: 1}
Object.is(obj, obj) // true — 같은 참조
Object.is는 참조가 동일한 객체인지만 본다. 내용물은 확인하지 않는다.
얕은 비교 (shallow equal)
shallowEqual({a: 1, b: 2}, {a: 1, b: 2}) // true — 1뎁스 값이 모두 같음
shallowEqual([1, 2, 3], [1, 2, 3]) // true — 각 인덱스 값이 같음
shallowEqual({a: {x: 1}}, {a: {x: 1}}) // false — 중첩 객체는 참조가 다름
shallowEqual({a: 1}, {a: 1, b: 2}) // false — 키 개수가 다름
얕은 비교는 객체의 1뎁스(최상위 프로퍼티)까지만 비교한다. 각 프로퍼티의 값을 Object.is로 비교하되, 키 개수가 같고 모든 값이 일치하면 같다고 판단한다. 중첩된 객체까지 파고들지는 않는다 — 그래서 "얕은" 비교다.
얕은 비교의 내부 구현을 간단히 보면 이렇다.
function shallowEqual(objA, objB) {
if (Object.is(objA, objB)) return true
if (
typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null
) {
return false
}
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
if (keysA.length !== keysB.length) return false
for (const key of keysA) {
if (!Object.is(objA[key], objB[key])) return false
}
return true
}
- 먼저
Object.is로 참조가 같은지 확인 (빠른 경로) - 둘 다 객체인지 확인
- 키 개수가 같은지 확인
- 각 키의 값을
Object.is로 비교
이 알고리즘 덕분에 {bears: 3, fish: 5}와 {bears: 3, fish: 5}는 다른 참조지만 같다고 판단된다.
useShallow 사용법
useShallow는 zustand/react/shallow에서 import한다.
import { useShallow } from 'zustand/react/shallow'
셀렉터를 useShallow로 감싸면 반환값의 비교 방식이 Object.is에서 얕은 비교로 바뀐다.
객체 반환 셀렉터
// ❌ Before: name이 변해도 리렌더링
const { bears, fish } = useStore((state) => ({
bears: state.bears,
fish: state.fish,
}))
// ✅ After: bears나 fish가 변할 때만 리렌더링
const { bears, fish } = useStore(
useShallow((state) => ({
bears: state.bears,
fish: state.fish,
}))
)
useShallow로 감싸면 이전 반환값 {bears: 3, fish: 5}와 새 반환값 {bears: 3, fish: 5}를 얕은 비교한다. 값이 같으므로 리렌더링이 스킵된다.
배열 반환 셀렉터
// ❌ Before: 값이 변해도 키 목록이 같은데 리렌더링
const keys = useStore((state) => Object.keys(state))
// ✅ After: 실제로 키가 추가/삭제될 때만 리렌더링
const keys = useStore(
useShallow((state) => Object.keys(state))
)
배열의 경우 각 인덱스의 값을 비교한다. ['bears', 'fish', 'name']과 ['bears', 'fish', 'name']은 얕은 비교로 같다.
튜플 반환 셀렉터
여러 값을 배열(튜플)로 반환하는 패턴도 흔하다.
const [bears, fish] = useStore(
useShallow((state) => [state.bears, state.fish])
)
이 패턴은 구조 분해 할당과 잘 어울린다. 배열의 각 원소가 원시값이면 얕은 비교만으로 충분하다.
useShallow의 내부 동작
useShallow는 React 훅이다. 내부적으로 useRef를 사용해 이전 셀렉터 결과를 저장하고, 새 결과와 얕은 비교를 수행한다.
// 개념적 구현 (실제 코드를 단순화)
function useShallow<T>(selector: (state: S) => T): (state: S) => T {
const prevRef = useRef<T>()
return (state: S) => {
const next = selector(state)
if (shallowEqual(prevRef.current, next)) {
return prevRef.current as T // 이전 참조 그대로 반환
}
prevRef.current = next
return next
}
}
핵심 메커니즘:
- 셀렉터가 실행될 때마다 새 결과를 계산한다
- 이전 결과(
prevRef.current)와 얕은 비교를 수행한다 - 같으면 이전 참조를 그대로 반환한다 — 이것이 핵심이다
- 다르면 새 결과를 저장하고 반환한다
3번이 중요하다. 이전 참조를 그대로 반환하면 Object.is 비교에서 true가 나오기 때문에 Zustand가 리렌더링을 스킵한다. 결국 useShallow는 얕은 비교를 통해 참조 동일성을 보장하는 메모이제이션 래퍼인 것이다.
useShallow가 필요한 경우와 필요 없는 경우
필요 없는 경우
// 원시값 반환 — Object.is로 충분
const bears = useStore((state) => state.bears)
const name = useStore((state) => state.name)
원시값은 Object.is로 비교할 수 있으므로 useShallow가 필요 없다.
// 이미 스토어에 있는 객체 참조를 그대로 반환
const user = useStore((state) => state.user)
스토어에 저장된 객체를 그대로 반환하면 참조가 동일하다. state.user가 변경되지 않았다면 같은 참조를 반환하므로 Object.is가 true다.
필요한 경우
// 새 객체를 생성하는 셀렉터
const coords = useStore(
useShallow((state) => ({ x: state.x, y: state.y }))
)
// 새 배열을 생성하는 셀렉터
const items = useStore(
useShallow((state) => state.todos.filter((t) => !t.done))
)
// Object.keys, Object.values 등
const keys = useStore(
useShallow((state) => Object.keys(state.settings))
)
셀렉터 안에서 {}, [], .filter(), .map(), Object.keys() 등을 사용하면 매번 새 참조가 생성된다. 이런 경우 useShallow가 필요하다.
useShallow의 한계
중첩 객체 비교 불가
const state = create(() => ({
user: {
profile: { name: 'Kim', age: 25 },
settings: { theme: 'dark' },
},
}))
// ⚠️ useShallow로는 부족할 수 있음
const profile = useStore(
useShallow((state) => ({
profile: state.user.profile, // 이건 괜찮음 — 같은 참조
}))
)
// ⚠️ 이 경우는 문제
const data = useStore(
useShallow((state) => ({
names: state.users.map((u) => ({ id: u.id, name: u.name })),
}))
)
두 번째 예시에서 .map()이 새 객체 배열을 생성한다. useShallow는 1뎁스만 비교하므로 names 배열의 각 원소(객체)는 참조가 다르다 → 매번 다르다고 판단한다.
이런 경우에는 셀렉터를 더 단순하게 만들거나, 커스텀 비교 함수를 사용해야 한다.
커스텀 equality function
useShallow가 맞지 않는 경우, Zustand의 두 번째 인자로 커스텀 비교 함수를 넘길 수 있다.
import { shallow } from 'zustand/shallow'
const data = useStore(
(state) => ({
names: state.users.map((u) => u.name),
count: state.users.length,
}),
(a, b) => (
a.count === b.count &&
a.names.every((name, i) => name === b.names[i])
)
)
이 방식은 비교 로직을 완전히 제어할 수 있지만, 비교 함수를 직접 작성해야 하는 번거로움이 있다.
실전 패턴
여러 상태를 한 번에 가져오는 유틸리티 훅
스토어에서 여러 키를 뽑아오는 패턴을 재사용 가능한 훅으로 만들 수 있다.
import { useShallow } from 'zustand/react/shallow'
function usePick<S, K extends keyof S>(
useStore: (selector: (state: S) => Pick<S, K>) => Pick<S, K>,
...keys: K[]
) {
return useStore(
useShallow((state) =>
keys.reduce(
(acc, key) => {
acc[key] = state[key]
return acc
},
{} as Pick<S, K>
)
)
)
}
// 사용
const { bears, fish } = usePick(useStore, 'bears', 'fish')
키 기반으로 상태를 뽑아오면서 자동으로 useShallow를 적용한다. 반복적인 셀렉터 작성을 줄일 수 있다.
개별 셀렉터 분리 (useShallow 없이)
사실 가장 성능이 좋은 방법은 셀렉터를 원시값 단위로 분리하는 것이다.
// 가장 최적화된 방식 — useShallow 불필요
const bears = useStore((s) => s.bears)
const fish = useStore((s) => s.fish)
각 셀렉터가 원시값을 반환하므로 Object.is만으로 충분하다. useShallow의 얕은 비교 연산도 필요 없다. 다만 가져올 값이 많아지면 코드가 길어진다는 단점이 있다.
슬라이스 패턴에서의 활용
대규모 스토어를 슬라이스로 분리하는 패턴에서도 useShallow는 유용하다.
const useStore = create((set) => ({
// auth slice
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
// ui slice
theme: 'light',
sidebar: true,
toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}))
// auth 관련 상태만 구독
const auth = useStore(
useShallow((s) => ({
user: s.user,
token: s.token,
login: s.login,
logout: s.logout,
}))
)
슬라이스 단위로 상태를 가져올 때 useShallow를 사용하면, 해당 슬라이스의 값이 실제로 변할 때만 리렌더링된다.
정리
| 상황 | 권장 방식 |
|---|---|
| 원시값 하나 구독 | 셀렉터만 사용 (useShallow 불필요) |
| 원시값 여러 개 구독 | 개별 셀렉터 분리 또는 useShallow |
| 객체/배열 반환 셀렉터 | useShallow 사용 |
| 중첩 객체 비교 필요 | 커스텀 equality function |
.map(), .filter() 결과 | useShallow (1뎁스 원시값이면 OK) |
useShallow는 Zustand에서 불필요한 리렌더링을 막는 가장 간단한 도구다. 하지만 만능은 아니다. 셀렉터의 반환값 구조를 이해하고, 어떤 비교 방식이 적절한지 판단하는 것이 핵심이다. 가능하면 셀렉터를 단순하게 유지하고, 복잡한 파생 데이터는 스토어 레벨에서 계산하는 것이 최선이다.