React) Zustand, custom selector를 활용한 렌더링 최적화

2ast·2023년 3월 28일
7

zustand 기본 사용법

import { useCallback } from "react";
import { create } from "zustand";

export const useUserStore = create((set) => ({
  userInfo: { name: "kim", age: 17 },
  token: "token",
  updateUerInfo: (userInfo) =>
    set((state) => ({ userInfo: { ...state.userInfo, ...userInfo } })),
  setToken: (token) => set(() => ({ token })),
}));
const userInfo = useUserStore(state=>state.userInfo);

zustand는 기본적으로 store를 선언하고, 컴포넌트 내부에서 훅으로 호출해 사용할 수 있다. 여기서 이번에 자세하게 볼 부분은 바로 useUserStore에 첫번째 인자로 들어가는 selector function이다.

useUserStore에서 아무런 인자도 넘기지 않고 바로 호출하면 기본적으로 해당 스토어의 모든 값들을 컴포넌트로 가져오게된다. 즉, 아래와 같이 사용해도 실제 기능 구현에는 전혀 무리가 없다.

const {userInfo,token,updateUserInfo,setToken} = useUserStore();

하지만 이처럼 스토어의 모든 값들을 컴포넌트로 불러와서 구조분해할당으로 값을 사용하는 방법은 얼핏봐서는 굉장히 편리해보이지만, 원치않는 컴포넌트의 리렌더링이 발생할 수 있다는 단점 또한 갖고 있다.

예를들어 A 컴포넌트에서는 token 값만 사용하기 때문에 아래와 같이 token을 불러와 사용중이라고 해보자.

const {token} = useUserStore();

이 상황에서 B 컴포넌트에서 userInfo.name 값이 수정되었다면 어떻게 될까? A 컴포넌트에서 실질적으로 사용중인 state는 token 하나 뿐이지만, useUserStore()의 결과로 해당 스토어의 모든 state가 리턴되어 A컴포넌트에 의존성이 주입되므로, userInfo의 값이 변경되는 순간 함께 리렌더링이 발생한다.

이런 상황을 미연에 방지하고자, 컴포넌트에서 꼭 필요한 state만 가져올 수 있도록 selector function이 존재하는 것이다.

const token = useUserStore(state=>state.token);

이렇게 token만을 가져오겠다고 선언하면 이 컴포넌트는 token에 대해서만 의존성이 생기기 때문에, 다른 곳에서 userInfo가 변경된다고 해서 리렌더링이 발생하지 않는다.

예외상황) 여러개의 state를 가져올 때

하지만 그렇다고 selector function이 만능인 것은 아니다. 로그인 상태라면 사용자의 이름을, 로그아웃 상태라면 '로그인'이라는 문구를 보여주는 컴포넌트가 있다고 가정해보자. 로그인 여부는 token 유무로 판단할 수 있으므로, 이 컴포넌트는 userInfo.name과 token 이라는 두 개의 state를 가져와야 하며, 아래와 같이 쓸 수 있다.

const {name,token} = useUserStore(state=>({
  name:state.userInfo.name,
  token:state.token
}))

아까 배웠던 selector function을 이용해 꼭 필요한 두개의 필드만을 가져왔다. 그러면 여기서 userInfo.age 값이 변경되면 이 컴포넌트는 리렌더링될까? 이상하게도 그렇다. 이 현상은 zustand가 리렌더링을 발생시키는 방식에서 기인한 현상으로 추측된다. 다만 이제부터 설명할 리렌더링을 발생시키는 방식에 대한 얘기는 문서나 소스코드를 통해 직접 확인한 내용이 아닌 겉으로 드러난 현상만을 보고 내가 추측한 것임을 미리 밝힌다.

zustand store hook를 보면 첫번째 인자인 selector function 뿐만 아니라 equals function도 옵셔널하게 받고 있는 것을 확인할 수 있다. 이 함수는 이전 selector function의 return value와 현재 return value가 같은지 비교해서 그 결과를 boolean으로 return하는 형태를 띠고 있다. 즉 zustand는 컴포넌트로 가져오는 값들(selector function의 return value)이 이전 컴포넌트와 같은지 비교하고, false가 리턴되었을 때만 리렌더링을 발생시키고 있다.

이걸 직접 확인하는 방법은 아주 간단한데, 바로 equlas function에서 항상 true를 리턴해보는 것이다.

const useInfo = useUserStore(state=>state.userInfo,()=>true)

useUserInfo의 두번째 인자로 항상 true를 리턴하는 함수를 주면, userInfo state에 어떤 변화가 일어나든 이 컴포넌트는 절대로 리렌더링 되지 않는 형상을 목격할 수 있다.

다시 아까의 상황으로 돌아오면, selector function은 name과 token을 key로 갖는 object를 리턴하고 있다. js에서 object 데이터 타입은 실제로 그 값 자체를 담고 있지 않고, 값을 담고 있는 메모리 주소를 담고 있다. 따라서 실질적으로 같은 데이터를 담고 있더라도, 매 렌더링마다 메모리 주소가 재할당 되기 때문에 리렌더 이전과 이후의 값이 달라졌다고 판단한다. 이런 이유로 항상 리렌더링이 발생했던 것이다.

이제 equals function이 있다는 것을 알았기 때문에, 아까 마주쳤던 문제점 자체는 간단하게 해결할 수 있다.

const {name,token} = useUserStore(state=>({
  name:state.userInfo.name,
  token:state.token
}),(prev,current)=>JSON.stringify(prev)===JSON.stringify(current))

이렇게 object 자체를 비교하는게 아니라, JSON.stringify를 이용해 문자열로 바꿔 비교하게 되면 그 값만 비교하는게 가능하기 때문에, 실질적인 값 자체가 바뀌었는지 판단할 수 있게 되므로, 아까와 같은 불필요한 리렌더링을 방지할 수 있다. (lodash같은 라이브러리를 이용해 깊은 비교 함수를 사용해도 좋다.)

더 쉬운 대안은 없을까

하지만 이대로 끝내기에는 뭔가 찝찝하다. 매번 저렇게 equlas function을 넘겨주는 방법밖에는 없을까? 멀리 갈것도 없이 userInfo state를 생각해보자. userInfo는 name, age, address 등 여러 필드의 user 정보를 담고 있는 object 형태로 저장된다. 이 userInfo state 하나에는 user에 대한 방대한 양의 데이터를 담고 있고, 수많은 컴포넌트에서 다양한 조합으로 불려와 사용될 거라는 걸 쉽게 예상할 수 있다.

A 컴포넌트에서는 nickname과 gender가, B 컴포넌트에서는 name과 phoneNumber와 address가, C 컴포넌트에서는 name과 address가 필요하게 될지 모른다. 그러면 이때마다 매번 selector function을 object 형태로 가공하고, equlas function을 정의해서 넘겨주는 수밖에는 없을까? 적어도 나는 그러고 싶지 않았다.

그래서 나는 userInfo의 원하는 값을 array 형태로 넘겨주면 렌더링 최적화까지 적용된 object를 반환해주는 custom hook을 만들기로 했다. array 형태로 넘겨진 keys를 reduce 메서드로 가공해서 selector function을 만들고, 이것을 useUserStore에 넘겨 return하는 구조로 되어 있다.


export const useUserSeletor = (keys) => {
  const seletorFunction = useCallback((state) => {
    return keys.reduce((acc, value) => ({ ...acc, [value]: state.userInfo[value] }), {});
  }, []);

  return useUserStore(
    seletorFunction,
    (a, b) => JSON.stringify(a) === JSON.stringify(b)
  );
};

실제로 state를 사용할 때는 아래와 같이 간단하게 선언해서 가져올 수 있다.

const {name,age} = useUserSelector(['name','age'])
profile
React-Native 개발블로그

3개의 댓글

comment-user-thumbnail
2023년 12월 1일

안녕하세요. 궁금한게 있는데..
setState들은 그냥 useUserStore에서 그냥 뽑아서 쓰시나요?

1개의 답글
comment-user-thumbnail
2024년 9월 6일

오랜만에 보다가 댓글로 남깁니다. 그냥 useShallow 쓰시면 됩니다.

답글 달기