February 02, 2025
Zustand store에서 객체 상태를 구독할 때 shallow
비교를 사용하면 불필요한 리렌더링을 방지할 수 있다는 것을 알게 되었다.
Zustand는 기본적으로 상태를 비교할 때 Object.is를 사용한다.
shallow
는 간단한 데이터에 대해서 빠르게 비교할 수 있는 방식이다. 중첩된 객체의 경우에는 내부 속성까지 비교하지 않고 참조값을 기준으로 판단한다. 이를 통해 선택자에서 반환하는 객체가 새로 생성되더라도 실제로 필요한 값이 변경되지 않았다면 리렌더링을 피할 수 있다.
아래 처럼 shallow
를 전달해서 사용할 수 있다.
import { shallow } from 'zustand/shallow'
export function useUser() {
return useUserStore(
store => ({
id: store.user?.id,
name: store.user?.name,
}),
shallow // 👈 명시적 설정 필요
)
}
숫자, 문자열, 불리언 등 primitive 타입은 Object.is
와 shallow
모두 값 자체를 비교한다. 따라서, primitive 값을 선택자로 사용할 경우 별도의 shallow
비교 없이도 값이 같으면 리렌더링이 발생하지 않는다.
객체를 비교할 때 shallow
는 각 객체의 최상위 속성 값만을 비교한다.
import { shallow } from 'zustand/shallow'
const obj1 = { a: 1, b: 2, c: 3 }
const obj2 = { a: 1, b: 2, c: 3 }
console.log(Object.is(obj1, obj2)) // false
console.log(shallow(obj1, obj2)) // true
위의 예제에서 두 객체의 참조값은 다르지만 top-level 속성(a, b, c)의 값이 모두 같기 때문에 shallow
함수는 true
를 반환한다.
import { shallow } from 'zustand/shallow'
const obj1 = { a: 1, b: 2, c: { d: 4 } }
const obj2 = { a: 1, b: 2, c: { d: 4 } }
console.log(Object.is(obj1, obj2)) // false
console.log(shallow(obj1, obj2)) // false
하지만 객체 내부에 중첩된 객체가 포함되어 있을 경우, shallow 비교는 해당 속성의 참조만을 비교하기 때문에 false
를 반환한다.
Zustand의 shallow 함수는 다음과 같은 방식으로 두 객체를 비교한다.
두 객체가 같은 참조(메모리 주소)를 가지면 true를 반환한다.
두 객체의 키 개수가 다르면 false를 반환한다.
각 속성 값을 비교하며, 값이 다르면 false를 반환한다. 단, 객체나 배열인 경우 참조가 같으면 true를 반환한다.
속성 값이 객체나 배열이면 참조(메모리 주소)를 비교하지만, 최상위 속성의 값이 동일하면 같다고 판단한다.
github에 있는 shallow
함수의 코드는 아래와 같다.
export function shallow<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {
return true
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}
if (objA instanceof Map && objB instanceof Map) {
if (objA.size !== objB.size) return false
for (const [key, value] of objA) {
if (!Object.is(value, objB.get(key))) {
return false
}
}
return true
}
if (objA instanceof Set && objB instanceof Set) {
if (objA.size !== objB.size) return false
for (const value of objA) {
if (!objB.has(value)) {
return false
}
}
return true
}
const keysA = Object.keys(objA)
if (keysA.length !== Object.keys(objB).length) {
return false
}
for (const keyA of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, keyA as string) ||
!Object.is(objA[keyA as keyof T], objB[keyA as keyof T])
) {
return false
}
}
return true
}
이제 본론으로 넘어 와 보자. Zustand의 useStore 훅에서 shallow
를 활용하면 불필요한 리렌더링을 방지할 수 있다.
import { create } from 'zustand'
const useUserStore = create(set => ({
user: { id: 123, name: 'John' },
// 전체 user를 변경하는 함수
setUser: user => set({ user }),
// user 객체의 일부 속성을 업데이트하는 함수
updateUser: updates =>
set(state => ({ user: { ...state.user, ...updates } })),
}))
export function useUser() {
return useUserStore(
store => ({
id: store.user?.id,
name: store.user?.name,
}),
shallow // 👈 명시적 설정 필요
)
}
// 초기 상태: { user: { id: 123, name: 'John' } }
const { id: id1 } = useUser() // 123 반환
// 1. id가 변경되지 않은 업데이트
setStore({ user: { id: 123, name: 'Jane' } })
const { id: id2 } = useUser() // 123 → 리렌더링 발생 ❌
// 2. id가 변경된 업데이트
setStore({ user: { id: 456, name: 'Jane' } })
const { id: id3 } = useUser() // 123 → 리렌더링 발생 ✅
// 3. email이 추가된 업데이트
setStore({ user: { id: 123, name: 'Jane', email: 'test@test.com' } })
const { id: id4 } = useUser() // 123 → 리렌더링 발생 ❌
shallow
를 사용하면 1번의 경우 id2를 사용하는 컴포넌트는 리렌더링이 발생하지 않는다. 왜냐하면 name은 바뀌었지만 구독하고 있는 id의 값은 123 그대로 유지되었기 때문이다.
2번의 경우는 구독하고 있는 id값이 달라졌으므로 id3을 사용하는 컴포넌트는 리렌더링이 발생한다.
3번처럼 새로운 속성이 추가되어도 id값은 바뀌지 않았기 때문에 id4를 사용하는 컴포넌트는 리렌더링이 발생하지 않는다.
만약에 shallow
를 사용하지 않으면 1번, 2번, 3번 모두 리렌더링이 발생할 것이다.
이렇게 중첩되지 않는 객체나 배열을 구독하는 경우 shallow
를 사용하면 불필요한 리렌더링을 방지할 수 있다.
shallow
를 사용하지 않고 이렇게 필요한 값만 구독하면 primitive 타입을 반환하기 때문에 Object.is
에서 같다고 판단해 리렌더링이 발생하지 않는다. 그래서 보통의 경우에는 이렇게 필요한 값만 구독하는 것이 좋다.
export function useUserNo() {
return useUserStore(store => store.user?.id)
}
하지만 객체에서 사용하는 값이 많고 업데이트가 자주 일어난다면 이렇게 객체를 한번에 구독하고 shallow
함수를 사용하는 것을 고려해볼 수 있다.
import React from 'react'
import create from 'zustand'
import { shallow } from 'zustand/shallow'
// Zustand 스토어 정의: 여러 user 관련 속성을 포함합니다.
const useUserStore = create(set => ({
user: {
id: 123,
name: 'John',
email: 'john@example.com',
phone: '010-1234-5678',
address: 'Seoul, Korea',
role: 'user',
},
// user 객체의 일부 또는 전체를 업데이트하는 함수
updateUser: updates =>
set(state => ({
user: { ...state.user, ...updates },
})),
}))
// 전체 user 객체를 shallow 비교와 함께 구독하는 훅
export function useUser() {
return useUserStore(state => state.user, shallow)
}
// 예시 컴포넌트: 여러 user 관련 속성을 렌더링합니다.
function UserComponent() {
const user = useUser() // 전체 user 객체를 구독
console.log('UserComponent rendered')
return (
<div>
<p>ID: {user.id}</p>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Address: {user.address}</p>
<p>Role: {user.role}</p>
</div>
)
}
// 예시 업데이트 코드 (실제 환경에서는 이벤트 핸들러 등에서 호출)
// 예를 들어, 1초 후 name과 email을 업데이트하는 경우:
setTimeout(() => {
useUserStore
.getState()
.updateUser({ name: 'Jane', email: 'jane@example.com' })
}, 1000)
export default UserComponent
정리해보면 불필요한 리렌더링을 방지하기 위해서는
객체의 특정 속성만 선택하는 것이 좋다.
중첩되지 않은 객체나 배열을 구독하는 경우 shallow
를 사용하면 불필요한 리렌더링을 방지할 수 있다.