zustand에 렌더링 최적화 적용하기 (maintainer에게 답변 받은 썰)

이수빈·2024년 1월 1일
post-thumbnail

최근 작업중인 프로젝트의 설계 단계에서 전역 상태 관리 도구 라이브러리를 기존에 다른 프로젝트에서 사용 중이던 redux toolkit에서 좀 더 가벼운 zustand로 작업을 해보는게 어떻겠냐 라는 의견이 있었다.

내가 이 의견에 긍정적이었던 이유는 React를 사용해 작업을 해오던 기존 프로젝트들에서는 거의 모든 데이터들을 전역 상태 관리 도구에 저장 후 꺼내서 사용했지만, 이번 프로젝트는 Next를 사용하기 때문에 SSR로 데이터들을 미리 불러와서 뿌려주는 형식의 흐름으로 전역 상태 관리 도구의 의존성이 기존보다는 낮아져 사용하던 라이브러리를 과감하게 다른 라이브러리로 교체해도 큰 문제가 없다고 판단을 했기 때문이다.

조사를 해보니 확실히 redux toolkit 보다는 가벼웠고, 같은 FLUX 패턴을 사용하기도 했고 사용법도 간단해 익히는데 큰 어려움이 없었다.

프로젝트 중반부로 접어들게 되자 에러 처리, 성능 등 초반부에는 기능 구현 하느라 눈에 보이지 않던 것들이 서서히 보이기 시작했고, zustand를 사용하는 특정 페이지에서 리렌더링이 심하게 많이 발생하는 것을 확인하게 되었다. 너무 찝찝한 나머지 조금 해결을 하고 넘어가기 위해 관련한 내용을 찾아보았고, 그 과정중에서 효과를 본 몇가지를 적어보려 한다.

1. 구조 분해 할당으로 데이터 가져오지 않기

redux toolkit을 사용할때와 마찬가지로, 나는 store에서 데이터를 가져올때 항상 아래와 같이 불러와서 사용했다.

const { bear, honey } = useBearStore()

하지만 이렇게 선언하는것 만으로도 리렌더링을 유발 시킨다는것을 깨닳았다.
store를 전부 가지고 왔기 때문에 bear, honey만 사용한다고 하더라도 store가 업데이트 되면 같이 리렌더링 된다.
따라서 구조 분해 할당이 아닌 다음과 같이 하나씩 불러와서 사용해야 한다. 이는 공식문서에도 잘 나와있는 패턴이다.

const bear = useBearStore((state) => state.bear)
const honey = useBearStore((state) => state.honey)

모든 선언부에 잘못된 방식으로 작성 되어 있어 모두 수정해 주었고, 이 하나의 수정만으로도 불필요한 렌더링이 많이 줄어들었다.

2. shallow 함수 사용하기

zustand는 기본적으로 엄격한 비교, 즉 데이터의 값 뿐만아니라 데이터의 메모리까지 비교해 이전 값과 동일한지 아닌지를 검사하는 과정을 거친다. 원시값이 아닌 객체나 배열은 메모리 값이 항상 다르기 때문에 엄격한 비교를 거치면 내부의 값이 일치하더라도 항상 다른 값이라고 판단하게 된다.
그래서 사용 할 수 있는 것이 shallow 함수인데 이 함수를 아래와 같이 두 번째 인자로 전달하게 되면 얕은 비교, 즉 데이터의 값만 일치하는지를 검사하기 때문에 객체나 배열도 원시값처럼 내부의 값만 일치하게 되면 변하지 않았다고 인식해 리렌더링이 일어나지 않는다.

import { shallow } from 'zustand/shallow'

const honey = useBearStore((state) => state.honey, shallow)

이 두 번째 인자는 데이터의 이전값과 변경된 이후의 값이 파라미터로 전달되고 true나 false로 이전값과 변경된 이후의 값이 동일한지 리턴 시키는 함수이다. shallow 함수 이외에도 직접 함수를 커스텀해서 작성해 줄 수도 있다.

import { shallow } from 'zustand/shallow'

const honey = useBearStore((state) => state.honey, (prev,next) => JSON.parse(prev) === JSON.parse(next))

추가적으로 zustand의 동작 과정이 궁금해 라이브러리 내부 파일을 열어서 확인하는 중 다음과 같은 링크를 발견했다.

내용은 대충 보니 5버전부터 store를 생성할때 사용하는 함수인 create 함수가 createWithEqualityFn으로 변경되고, shallow 함수를 사용하는 store는 shallow를 store의 생성하는 부분에 작성함으로 store를 호출할때마다 작성해주지 않아도 된다는 글이였다.
가장 최신 버전은 4.4.7로 5버전에 언제 나올지는 모르겠지만 함수를 한 번만 작성해도 되고 나중을 위해서 생성하는 함수도 같이 교체 해주었다.

3. 중첩된 객체 or 배열은 깊은 비교 사용하기

여기까지 하면 불필요한 리렌더링은 더 이상 일어나지 않을 줄 알았다. 하지만 사용하지 않는 곳들에서 자꾸 리렌더링이 일어났는데, 일어나는 부분의 데이터의 공통점은 형태가 원소가 객체인 배열이라는 것이었다.

shallow 함수를 이용해서 얕은 비교로 값만 비교하는데 뭐가 문제일까하고 shallow 함수가 들어있는 파일을 열어보았다. shallow 함수는 다음과 같이 생겼고 데이터를 넣고 직접 디버깅을 시켜 보았다.

문제가 된 부분은 마지막 이 부분이였는데

var keysA = Object.keys(objA);
  if (keysA.length !== Object.keys(objB).length) {
    return false;
  }
  for (var i = 0; i < keysA.length; i++) {
    if (!Object.prototype.hasOwnProperty.call(objB, keysA[i]) || !Object.is(objA[keysA[i]], objB[keysA[i]])) {
      return false;
    }
  }

if문의 오른쪽 식에서 Object.is의 인자에 객체 배열의 원소, 즉 객체가 비교 데이터로 들어가기 때문에 이 값은 항상 false이고, 함수는 false를 리턴하기 때문에 항상 다르다고 판단하는 것이었다.

하루 정도 검색과 고민으로 삽질을 하고도 해결 방법을 찾지 못한채 지푸라기 잡는 심정으로 zustand 깃헙에 질문글을 남겨 놓았었는데 하루 정도 지나니까 답변이 달렸다는 알림을 받았고 확인 해보았다.

그런데

무려 zustand의 maintainer가 답변을 달아 주었다!
사용중인 데이터에는 얕은 비교가 아닌 깊은 비교를 사용해야 한다고 답변을 받았고, shallow 함수 대신 lodash의 isEqual 함수를 추천한다는 답변이었다. 실제로 isEqual 함수로 깊은 비교를 하니 값이 바뀌지 않으면 불필요하게 리렌더링이 일어나지 않았다.
추가적으로 고민하고 있던 다른 질문거리도 질문을 하고 친절하게 답변을 받았다. 질문 글은 여기서 확인 할 수 있다.

느낀점

혼자 고민하고 뾰족한 해결 방법을 찾지 못해 질문글을 올려본 경험은 적지 않지만, 이렇게 외국인에게 답변을 받고 해결하게 된건 처음이라 되게 신기하고 즐거운 경험이었다. 질문의 답변 대상이 라이브러리의 maintainer라서 더욱 신기했던것 같고 zustand에 대한 애정이 좀 더 늘어난 것 같다.

profile
내가 나중에 보려고

0개의 댓글