2024년 01월 12일
7

Zustand Tip

Frontend
KKingmo

Changmo Oh

@KKingmo

전체 글 보기

서론

Zustand를 사용한 상태 관리는 효율성과 성능을 향상시킬 수 있지만, 잘못 사용하면 예상치 못한 성능 저하로 이어질 수 있다.

문제진단

다음은 Zustand를 사용한 코드이다. 이 코드는 상태 ab를 관리하며, 각 상태에 대한 setter 함수를 포함한다.

import { create } from "zustand";
 
interface AppStoreStates {
  a: number;
  b: number;
 
  setA: (a: number) => void;
  setB: (b: number) => void;
}
 
const appStore = create<AppStoreStates>((set) => ({
  a: 0,
  b: 0,
  setA: (a: number) => set({ a }),
  setB: (b: number) => set({ b }),
}));

이를 다음과 같이 사용할 때 개발자는 A 컴포넌트의 버튼을 눌렀을 때, A 컴포넌트만 리렌더하기를 바란다. 하지만 버튼을 누를 시 A, B 컴포넌트가 모두 리렌더된다.

const A = () => {
  const { a, setA } = appStore();
 
  console.log("a rendering")
 
  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};
 
const B = () => {
  const { b, setB } = appStore();
 
  console.log("b rendering")
 
  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set B</button>
    </>
  );
};
 
function App() {
  return (
    <>
      <A />
      <B />
    </>
  );
}
 
export default App;

여기서 A와 B 컴포넌트가 같이 리렌더되는 이유는 Zustand 스토어를 사용할 때 전체 객체를 호출하는 방식 때문이다. 이 방법은 스토어의 모든 상태를 리턴하기 때문에, 하나의 상태가 변경되어도 연관된 모든 컴포넌트가 리렌더된다.

최적화 전략

상태 선택적 사용

appStore를 호출할 때 전체 상태를 가져오는 대신, 필요한 상태만 선택적으로 추출하는 방법을 사용하자. 이는 리렌더링을 해당 상태에 한정 시키는 효과를 가진다.

const A = () => {
  const a = appStore((state) => state.a);
  const setA = appStore((state) => state.setA);
 
  console.log("a rendering");
 
  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};
 
const B = () => {
  const b = appStore((state) => state.b);
  const setB = appStore((state) => state.setB);
 
  console.log("b rendering");
 
  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set B</button>
    </>
  );
};

이제 기대했던 바와 같이 A 컴포넌트의 버튼을 눌렀을 때, A 컴포넌트만 리렌더된다.
이 방식은 문제상황의 방식과 어떠한 차이가 있을까?

appStore에서 들고온 값에 대한 차이가 있다. 이 방식은 Object가 아닌 원시타입의 변수와 절대 바뀌지 않는 action 함수를 가져오고, 문제상황의 방식은 다음과 같이 구조분해 할당을 했더라도 { b, setB } 사실 들고 온 값은 {a, b, setA, setB}이다.

들고온 값이 Object이냐 원시타입이냐의 차이인데 이것과 리렌더링에는 어떠한 상관관계가 있을까? 기본적으로 zustand는 react처럼, state의 변경에 얕은 비교를 한다.

얕은비교(shallow comparison)는 참조 자료형의 경우 메모리 주소를 비교하기 때문에, 서로 다른 메모리주소에 같은 값이 저장되어 있어도 다르게 평가한다.

따라서 객체 내부의 값이 변경되어도 새로운 Object가 할당되지 않으면, 상태변화를 인지하지 못한다. 때문에 리렌더링을 위해서는 zustand의 set 함수를 통해 새로운 Object로 갈아 끼워야한다.

shallow 함수 사용

import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
 
const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))
 
// 객체 선택, state.nuts 또는 state.honey가 변경될 때 컴포넌트 리렌더.
const { nuts, honey } = useBearStore(
  useShallow((state) => ({ nuts: state.nuts, honey: state.honey })),
)
 
// 배열 선택, state.nuts 또는 state.honey가 변경될 때 컴포넌트 리렌더.
const [nuts, honey] = useBearStore(
  useShallow((state) => [state.nuts, state.honey]),
)
 
// 매핑된 선택, state.treats가 순서, 수량 또는 키에 대해 변경될 때 컴포넌트 리렌더.
const treats = useBearStore(useShallow((state) => Object.keys(state.treats)))

동등성을 비교하는 custom 함수를 만들어 인자로 넘겨주기

const treats = useBearStore(
(state) => state.treats,
(oldTreats, newTreats) => compare(oldTreats, newTreats),
)

zustand set 함수 이해하기

zustand는 React의 useState와 유사하게 불변성을 유지하면서 상태를 업데이트하는 방식을 사용한다. 예를 들어, useState를 사용할 때는 다음과 같이 할 수 있다.

const [state, setState] = useState({ count: 0 });
setState(prevState => ({ ...prevState, count: prevState.count + 1 }));

zustand에서는 이러한 패턴이 매우 흔하기 때문에, set 함수는 자동으로 병합하는 기능을 제공한다.

setA: (a: number) => set({ a }),
// 때문에 위 코드는 아래와 똑같이 작동한다.
setA: (a: number) => set((state) => ({ ...state, a})),

set 함수가 위와같이 작동 하면서 새로운 객체를 생성하고, 할당하기 때문에 a가 바뀌어도 b에서 참조하고 있던 메모리주소가 바뀌게되어 위의 문제상황과 같이 불필요한 리렌더링이 발생하게 된다.