zustand shallow으로 최적화된 상태관리하기

·2025년 8월 3일

zustand

목록 보기
2/2

zustand와 친해지기 프로젝트 시작한지 어연 2달차...

나는 아직까지.. zustand를 다알지 못한다는걸 깨달았다....

어쩌다가 만난 zustand shallow ...

넌 누구냐?

Zustand Shallow

Zustand 로 캐시 저장, 로컬 스토리지 저장 하기

zustand에 대한 소개는 윗글에서 했으니 넘어가고~

오늘은 shallow에 심도있게 알아보고자 한다.

shallow란?

사전적 의미로는 얕은 이라는 의미인데

일명 자바스크립트에서 행해지는 얕은 비교를 수행하는 친구이다.

자바스크립트에서는 원시값비교와 참조값 비교로 진행되는데

원시값 비교로 값이 같은걸 비교하는 반면

const a = 5;
const b = 5;
console.log(a === b); // true (값이 같음)

참조값 비교는

const obj1 = { x: 1 };
const obj2 = { x: 1 };
console.log(obj1 === obj2); // false (다른 참조)
const obj3 = obj1;
console.log(obj1 === obj3); // true (같은 참조)

오브젝트 내부를 비교하는 것이 아니라 실제로 같은 곳에서 만들어지고 같은 위치 인지를 비교한다.

위에서 obj1 과 obj2는 서로 다른 메모리 공간에 있는 별개의 객체이므로 ===으로 비교를 해도 false라는 값이 떨어진다.


그러면 참조값비교에서는 얉은 비교 (Shallow) 와 깊은 비교 (Deep)가 나뉘게 된다.

얕은 비교(Shallow Comparison)

const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 2 };

console.log(obj1 === obj2); // false

앞에서와 동일하게 내용이 같아 보이지만 참조가 다르기때문에 동일하다고 판단이 불가하다.

그렇다면 깊은 비교는?

깊은 비교(Deep Comparison)

function deepEqual(obj1, obj2) {
  if (obj1 === obj2) return true; // 같은 참조면 바로 true
  if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return false; //둘중 하나라도 object가 아니면 false

  const keys1 = Object.keys(obj1);
  const keys2 = Object.keys(obj2);
  if (keys1.length !== keys2.length) return false; // 두 오브젝트의 키 개수가 다르면 바로 false

  for (let key of keys1) { //obj1의 키를 하나씩 순회하면서 obj2와 비교시작 => 모든 속성을 하나씩 확인
    if (!keys2.includes(key)) return false;
    if (typeof obj1[key] === 'object' && typeof obj2[key] === 'object') {
      if (!deepEqual(obj1[key], obj2[key])) return false; // 재귀적으로 깊게 비교
    } else if (obj1[key] !== obj2[key]) {
      return false; // 객체가 아닌경우 두 값이 다르면 false 반환
    }
  }
  return true; // 모든 비교를 통과하면 true 반환
}

console.log(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })); // true
console.log(deepEqual({ a: 1, nested: { x: 10 } }, { a: 1, nested: { x: 10 } })); // true

재귀적으로 deepEqual에서 계속해서 deepEqual를 호출하면서 참조값의 내부까지 비교한다.


그럼 갑자기 zustand의 Shallow 얘기하다가 왜 자바스크립트 이야기를 하냐고?

zustand Shallow는 이름부터 알 수 있듯이 얕은 비교만을 행하기 때문이다!

Shallow 사용하기

기존상황

const BearAndFishCounter = () => {
  const { bears, fish } = useBearStore((state) => ({
    bears: state.bears,
    fish: state.fish,
  }));
  console.log('BearAndFishCounter 렌더링!');
  return (
    <div>
      Bears: {bears}, Fish: {fish}
    </div>
  );
};

increaseBears를 호출해서 bears만 바뀌어도, 셀렉터는 매번 새로운 객체를 만들어내기 때문에 zustand는 이 객체가 "바뀌었다"고 판단해서 컴포넌트를 리렌더링한다.

심지어 fish는 전혀 바뀌지 않았는데도!

왜냐? 자바스크립트에서 { bears: 1, fish: 0 }와 { bears: 2, fish: 0 }는 내용이 비슷해도 다른 참조(reference)이기 때문이다.

\=== 비교로는 같다고 안 나온다. 이게 바로 불필요한 리렌더링의 원인이 된다.

그럼 여기서 Shallow를 사용한다면?

import { useShallow } from 'zustand/react/shallow';

const BearAndFishCounter = () => {
  const { bears, fish } = useBearStore(
    useShallow((state) => ({
      bears: state.bears,
      fish: state.fish,
    }))
  );
  console.log('BearAndFishCounter 렌더링!');
  return (
    <div>
      Bears: {bears}, Fish: {fish}
    </div>
  );
};

이제 increaseBears를 호출하면 bears만 바뀌고 fish는 그대로이다.

useShallow가 { bears: 1, fish: 0 }와 { bears: 2, fish: 0 }를 얕은 비교로 보면, fish 값은 여전히 같다고 판단해서 리렌더링을 막아줄 수 있다.

실제로 바뀐 부분(bears)에 대해서만 반응하도록 최적화가 된다!


그럼 언제 useShallow를 써야하는가?

  • 셀렉터가 객체를 반환할 때: 위 예시처럼 여러 상태를 한 번에 가져오려고 객체로 묶으면 필수.
  • 셀렉터가 배열을 반환할 때: 예를 들어, useBearStore(useShallow((state) => [state.bears, state.fish]))처럼 배열로 반환할 때. 새 배열은 항상 새 참조니까 얕은 비교가 필요하다.
  • 성능 최적화가 필요할 때: 큰 스토어에서 일부만 구독하는데 불필요한 리렌더링이 발생하면 useShallow로 줄일 수 있다.

간단한 예제로 비교하자면

import { create } from 'zustand';

const useStore = create((set) => ({
  a: 0,
  b: 0,
  setA: () => set((state) => ({ a: state.a + 1 })),
  setB: () => set((state) => ({ b: state.b + 1 })),
}));

const App = () => {
  const { a, b, setA, setB } = useStore((state) => ({
    a: state.a,
    b: state.b,
    setA: state.setA,
    setB: state.setB,
  }));
  console.log('App 렌더링!');
  return (
    <div>
      <p>A: {a}</p>
      <p>B: {b}</p>
      <button onClick={setA}>A 증가</button>
      <button onClick={setB}>B 증가</button>
    </div>
  );
};

export default App;

위와 같은 코드에서는 "A증가" 라는 버튼을 누르면 a의 값만 바뀌지만 컴포넌트가 매번 리렌더링 된다. (콘솔에 App 렌더링! 이 계속 찍힘)

import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

const useStore = create((set) => ({
  a: 0,
  b: 0,
  setA: () => set((state) => ({ a: state.a + 1 })),
  setB: () => set((state) => ({ b: state.b + 1 })),
}));

const App = () => {
  const { a, b, setA, setB } = useStore(
    useShallow((state) => ({
      a: state.a,
      b: state.b,
      setA: state.setA,
      setB: state.setB,
    }))
  );
  console.log('App 렌더링!');
  return (
    <div>
      <p>A: {a}</p>
      <p>B: {b}</p>
      <button onClick={setA}>A 증가</button>
      <button onClick={setB}>B 증가</button>
    </div>
  );
};

export default App;

하지만 useShallow를 활용한다면 setA를 호출했을때 a만 바뀌면 리렌더링이 필요한 상황에서만 발생한다.


주의할 점

하지만 useShallow가 만능은 아니다.
useShallow는 얕은 비교만 하니까 객체안에 객체가 중첩된 경우에는 작동이 안될수도 있다.

import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

const useStore = create((set) => ({
  data: {
    count: 0,
    nested: {
      value: 10,
    },
  },
  increaseNestedValue: () => set((state) => ({
    data: {
      ...state.data,
      nested: {
        value: state.data.nested.value + 1, // 새 객체 안 만듦
      },
    },
  })),
}));

const Component = () => {
  const { count, nested } = useStore(
    useShallow((state) => ({
      count: state.data.count,
      nested: state.data.nested,
    }))
  );
  const increaseNestedValue = useStore((state) => state.increaseNestedValue);

  console.log('Component 렌더링!');
  return (
    <div>
      <p>Count: {count}</p>
      <p>Nested Value: {nested.value}</p>
      <button onClick={increaseNestedValue}>Nested Value 증가</button>
    </div>
  );
};

export default Component;

increaseNestedValue를 호출하면 nested.value가 10에서 11로 바뀌지만, useShallow는 nested 참조가 바뀌었는지(얕은 비교)만 보니까:
count: 안 바뀜.
nested: 새 객체로 교체되니까 참조 바뀜 → 리렌더링


하지만 객체나 배열 관리에는 최적화에는 이만한 기능이 없다.😎

세상은 넓고 내가 모르는건 너무 많구나~

profile
하고싶은거 짱많은 주니어 프론트엔드 개발자

0개의 댓글