Zustand의 shallow comparison으로 최적화하기

Zustand store에서 객체 상태를 구독할 때 shallow 비교를 사용하면 불필요한 리렌더링을 방지할 수 있다는 것을 알게 되었다.

Zustand의 기본 동작

Zustand는 기본적으로 상태를 비교할 때 Object.is를 사용한다.

shallow 비교란?

shallow는 간단한 데이터에 대해서 빠르게 비교할 수 있는 방식이다. 중첩된 객체의 경우에는 내부 속성까지 비교하지 않고 참조값을 기준으로 판단한다. 이를 통해 선택자에서 반환하는 객체가 새로 생성되더라도 실제로 필요한 값이 변경되지 않았다면 리렌더링을 피할 수 있다.

아래 처럼 shallow를 전달해서 사용할 수 있다.

import { shallow } from 'zustand/shallow'

export function useUser() {
  return useUserStore(
    store => ({
      id: store.user?.id,
      name: store.user?.name,
    }),
    shallow // 👈 명시적 설정 필요
  )
}

Primitive 타입 비교

숫자, 문자열, 불리언 등 primitive 타입은 Object.isshallow 모두 값 자체를 비교한다. 따라서, 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를 반환한다.

shallow 비교의 동작 원리

Zustand의 shallow 함수는 다음과 같은 방식으로 두 객체를 비교한다.

  1. 두 객체가 같은 참조(메모리 주소)를 가지면 true를 반환한다.

  2. 두 객체의 키 개수가 다르면 false를 반환한다.

  3. 각 속성 값을 비교하며, 값이 다르면 false를 반환한다. 단, 객체나 배열인 경우 참조가 같으면 true를 반환한다.

  4. 속성 값이 객체나 배열이면 참조(메모리 주소)를 비교하지만, 최상위 속성의 값이 동일하면 같다고 판단한다.

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

결론

정리해보면 불필요한 리렌더링을 방지하기 위해서는

  1. 객체의 특정 속성만 선택하는 것이 좋다.

  2. 중첩되지 않은 객체나 배열을 구독하는 경우 shallow를 사용하면 불필요한 리렌더링을 방지할 수 있다.


Written by@Donghoon Song
사람들의 꿈을 이어주는 코멘토에서 일하고 있습니다.

InstagramGitHubTwitterLinkedIn