React와 Zustand의 얕은 비교

Jemin·2024년 7월 29일
1

개발 지식

목록 보기
47/51
post-thumbnail

서론

팀원들과 Zustand의 불필요한 렌더링을 줄일 수 있는 올바른 사용방법에 대해서 논의하다가 꽤 깊어지게 되었는데, 이때 새롭게 알게된 것들을 정리해보려 합니다.

Zustand와 함께 일하기

위 글을 보면서 불필요한 리렌더링을 줄이기 위해서는 atomic selectors를 사용하자는 의견이 나왔습니다. 하지만 하나의 Component에서 하나의 Store의 여러 State를 사용한다면 코드가 길어질 수 있기 때문에 이 경우 shllow 함수를 넘겨주기로 하였습니다.

import shallow from 'zustand/shallow'

// ⬇️ much better, because optimized
const { bears, fish } = useBearStore(
  (state) => ({ bears: state.bears, fish: state.fish }),
  shallow
)

위 글에서 shallow 함수를 넘겨주면 "선택기에서 객체나 배열을 반환하려면 얕은 비교를 사용하도록 비교 함수를 조정할 수 있습니다."라고 설명하고 있습니다.

여기서 "React는 기본적으로 상태 변경을 주소값을 비교하여 Component를 리렌더링하는데 이것을 얕은 비교라고 하지 않나? 어째서 옵션의 이름이 shallow일까?" 라는 의문이 생겨 팀원들과 함께 알아보았습니다.

이것에 대해서 정리할 내용들은 아래와 같습니다.

  1. React의 얕은 비교

  2. Zustand의 Shallow, useShallow

주니어들끼리 머리를 맞대고 논의한 내용이기 때문에 틀린 내용이 있을 수 있습니다. 과감하게 지적해주시면 감사하겠습니다.

React의 얕은 비교

React 얕은 비교에 대한 얕은 오해

React에서 얕은 비교가 제가 정확하게 알고 있는 것이 맞는지 확인하기 위해서 찾아보던 중 위 글을 읽게 되었습니다. 위 글에서 설명하는 shallowEqual함수에 대한 자세한 설명은 아래 글을 통해 쉽게 이해할 수 있습니다.

How Does Shallow Comparison Work In React?

/**
 * Performs equality by iterating through keys on an object and returning false
 * when any key has values which are not strictly equal between the arguments.
 * Returns true when the values of all keys are strictly equal.
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (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;
  }

  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      // $FlowFixMe[incompatible-use] lost refinement of `objB`
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }

  return true;
}

export default shallowEqual;

React github에 있는 shallowEqual 함수입니다.

실제 React의 shallowEqual함수가 Object.is를 사용해 객체의 주소를 비교하고 이후 객체 내부의 Property까지 비교를 합니다. 하지만 깊은 복사와는 다르게 재귀적으로 모든 깊이를 비교하지는 않습니다. 따라서 얕은 비교는 맞지만 객체의 경우 Property까지 비교하여 더 엄격하게 비교하는 것으로 보입니다.

Zustand Shallow & useShallow

Zustand github에서 shallow함수를 찾을 수 있는데, 리액트의 shallowEqual함수와 굉장히 유사합니다.

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
}

shallowEqual함수와 동일하게 Object.is를 사용해 참조를 비교한 후 객체의 Property까지 비교하고 있습니다.

하지만 공식문서에서는 shallow보다는 useShallow 훅을 사용하는 것을 권장하고 있었습니다.
Prevent rerenders with useShallow

해당 글에서는 Selector에서 반환하는 값이 변경되면 리렌더링이 발생하고 이때 변경을 감지하는데 참조 동일성을 판단하는 Object.is를 사용한다고 합니다. Object.is를 사용하면 주소값만 비교하고 끝나지만, useShallow를 사용한다면 이를 방지하여 불필요한 리렌더링을 줄일 수 있다고 합니다.

아무래도 shallow함수와 유사한 방법으로 객체안의 Property를 비교하여 값이 변경됐는지 확인하고 리렌더링하는 것으로 보입니다.

export default function App() {
	const { bear, nuts } = useZustandStore(
        useShallow((state) => ({
            bear: state.bear,
            nuts: state.nuts,
        }))
    );
}

따라서 위와 같이 useShallow를 사용해 Selector함수를 전달한다면 반환된 객체 외의 다른 값이 변경되어도 Property가 같다고 판단하여 리렌더링이 발생하지 않는 것 같습니다.

결론

  1. React에서 사용되는 shallowEqual함수의 얕은 비교는 객체를 비교할때 Object.is를 통해 참조와 내부의 Property까지 비교합니다.

  2. Zustand의 shallow 함수는 React의 shallowEqual함수와 동일하게 객체 내부의 Property까지 비교합니다.

  3. Store에 shallow함수를 전달하지 않은 상태에서 Selector함수를 사용해 반환한 객체들은 Object.is를 사용해 비교됩니다. 따라서 참조가 달라질때마다 매번 불필요한 리렌더링이 발생합니다.

  4. 3번을 방지하기 위해서 useShallow의 사용을 권장하고 있습니다.

  5. zustand의 shallow는 아무래도 React의 shallowEqual함수와 동일한 기능을 하기에 shallow라는 이름을 사용하는 것 같습니다.

위 4가지 결론이 나왔고 저희팀은 React 내부의 정확한 비교 방법과 Zustand에서 왜 shallow라는 명칭을 사용하는지 이해할 수 있었습니다.

주니어들끼리 머리를 맞대고 논의한 내용이기 때문에 틀린 내용이 있을 수 있습니다. 과감하게 지적해주시면 감사하겠습니다.

참고
Zustand와 함께 일하기
React 얕은 비교에 대한 얕은 오해
How Does Shallow Comparison Work In React?
shallow [zustand github]
Prevent rerenders with useShallow
(번역) 블로그 답변: React 렌더링 동작에 대한 (거의) 완벽한 가이드#컴포넌트 렌더링 최적화 기법

profile
경험은 일어난 무엇이 아니라, 그 일어난 일로 무엇을 하느냐이다.

0개의 댓글