Hello Zustand!

Thomas·2024년 2월 25일
0
post-thumbnail

Overview

프론트엔드에서 상태관리는 중요합니다.
핵심적으로 상태를 통해서 변하는 값에 따라서 UI 를 동적으로 다룰수 있기 때문입니다.
React 에서는 기본적으로 useState 라는 훅을 사용해 상태를 관리하고 있지만 프로젝트를 진행하다보면 컴포넌트에 계층이 생기고 상위 컴포넌트에 담겨 있는 상태 값을 하위 컴포넌트에서 사용해야 하는 경우가 발생합니다.
이때 컴포넌트에 props 을 통해서 상태를 전달해주고 여러 컴포넌트를 거치다보면 props drilling 이라는 현상이 발생하는데, 이를 해결하기 위해 전역 상태관리 라는 개념이 생겼고 이를 구현한 라이브러리인 Redux, MobX, Recoil, Zustand 들이 세상에 나오게 됐습니다.

상태 관리 라이브러리

이전의 프로젝트에서 Redux 와 Recoil 을 사용해본 경험이 있습니다.
처음엔 Redux 를 사용했지만 Redux 를 사용하면서 느꼈던 불편함을 해결하기 위해 Recoil 을 도입을 해 아키텍처를 리팩토링 했고, Recoil 을 사용하다 보니 이런 저런 불만이 생기기 시작했습니다.

  • 업데이트 주기에 대한 불안함

제작년 메타에서는 상당수의 직원이 권고사직을 당하는 슬픈 일이 있었습니다. 추측이지만 그때 Recoil 라이브러리 팀의 멤버들이 팀을 나가게 됐고, 그 이후로 유지보수가 잘 되지 않는 것 같습니다.
실제로 Recoil 의 Github 를 살펴보면 이런 토론이 있습니다.

2020 년에 출시된 라이브러리가 아직도 메이저 버전을 출시하지 못했고, 최근 유지보수가 2023년 4월이란 것이 과연 해당 라이브러리를 지속해서 사용하는데 위험성이 없을까 라는 불안함이 생겼습니다.

  • 상대적으로 큰 크기

패키지 자체가 생각보다 큽니다. Recoil 은 자신을 가볍다고 소개하지만 아토믹 패턴이여서 사용하기에 가볍다는건지 생각보다 패키지가 무거웠습니다.

위의 두 가지 문제점은 다른 상태관리 라이브러리를 사용하기에 충분했습니다. 그래서 새로운 프로젝트에서 Zustand 를 적극 사용해봤습니다.

Zustand 장점

  1. Redux 와 유사한 Store 패턴, 하지만 상태관리를 위한 보일러 플레이트가 없다.
  2. 사용하기 위해 알아야 하는 로직이 적다. 즉, 러닝 커브가 낮다.
  3. React 에 직접적으로 의존하지 않기 때문에 만약 React 를 벗어나야 한다면 다른 라이브러리나 Vanila JS 에 이식하기 쉽다. (이식성)
  4. 다양한 미들웨어를 지원한다. 예를 들면 Persist 를 위한 별도의 Third-Party Library 를 설치할 필요가 없다.
  5. Redux Devtool 을 활용할 수 있다.
  6. swallow 를 통해 랜더링을 제한할 수 있다.

Zustand 사용기

중앙 집중식으로 작성된 코드들

기존에 프로젝트에서 사용한 Zustand 로직을 리팩토링을 했습니다. Zustand 는 중앙 집중형으로 사용할 수 있는데 아래와 같은 방식으로 사용중에 있었습니다.

import { GoogleUser } from 'common/types/user';
import { create } from 'zustand';

export type Theme = 'light' | 'dark';

interface UserState {
  userInfo: GoogleUser;
  setUserInfo: (userInfo: GoogleUser) => void;
  adminPlatformTypeIndex: number;
  setAdminPlatformTypeIndex: (adminPlatformTypeIndex: number) => void;
  adminRegion: number;
  setAdminRegion: (adminRegion: number) => void;
  adminRegionIndex: number;
  setAdminRegionIndex: (adminRegionIndex: number) => void;
  adminLanguageIndex: number;
  setAdminLanguageIndex: (adminLanguageIndex: number) => void;
  clearStorage: () => void;
}

export const useStore = create<UserState>()(set => ({
  // setUserInfo
  userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}'),
  setUserInfo: (userInfo: GoogleUser) => {
    localStorage.setItem('userInfo', JSON.stringify(userInfo));
    set({ userInfo });
  },
  // setAdminRegion
  adminRegion: Number(localStorage.getItem('adminRegion')) || 0,
  setAdminRegion: (adminRegion: number) => {
    localStorage.setItem('adminRegion', adminRegion.toString());
    set({ adminRegion });
  },
  // setAdminRegionIndex
  adminRegionIndex: Number(localStorage.getItem('adminRegionIndex')) || 0,
  setAdminRegionIndex: (adminRegionIndex: number) => {
    localStorage.setItem('adminRegionIndex', adminRegionIndex.toString());
    set({ adminRegionIndex });
  },
  // setAdminPlatformTypeIndex
  adminPlatformTypeIndex: Number(localStorage.getItem('adminPlatformTypeIndex')) || 0,
  setAdminPlatformTypeIndex: (adminPlatformTypeIndex: number) => {
    localStorage.setItem('adminPlatformTypeIndex', adminPlatformTypeIndex.toString());
    set({ adminPlatformTypeIndex });
  },
  // setAdminLanguageIndex
  adminLanguageIndex: Number(localStorage.getItem('adminLanguageIndex')) || 0,
  setAdminLanguageIndex: (adminLanguageIndex: number) => {
    localStorage.setItem('adminLanguageIndex', adminLanguageIndex.toString());
    set({ adminLanguageIndex });
  },
  // clearStorage
  clearStorage: () => {
    try {
      localStorage.removeItem('userInfo');
      localStorage.removeItem('adminRegion');
      localStorage.removeItem('adminRegionIndex');
      localStorage.removeItem('adminPlatformTypeIndex');
      localStorage.removeItem('adminLanguageIndex');
      set({
        userInfo: {} as GoogleUser,
        jwtToken: '',
        adminRegion: 0,
        adminRegionIndex: 0,
        adminPlatformTypeIndex: 0,
        adminLanguageIndex: 0
      });
    } catch {
      console.log('err');
    }
  }
}));

물론 코드에 정답은 없지만 개인적으로 상태값은 관심사 별로 분리해야 한다고 생각합니다.

리팩토링

상태 관련된 코드를 관심사 별로 Store 를 만들 예정입니다. 서버에서 가져온 데이터들은 @tanstack/react-query 라이브러리를 활용하기 때문에 별도로 작성할 필요는 없습니다.
사용자의 경험을 위해 Persist 를 적용을 해야 하는 부분이 있습니다. 이를 해결하기 위해 Zustand 의 middleware 를 활용해봅니다.

디렉토리

폴더의 구조는 아래와 같습니다.

src
 ㄴ store
     ㄴ region
         ㄴ index.ts
         ㄴ type.ts

타입

먼저 타입을 소개합니다.

import { StateCreator } from 'zustand';
import { PersistOptions } from 'zustand/middleware';

export type AdminRegion = number;

export interface AdminRegionStates {
  adminRegion: AdminRegion;
}

export interface AdminRegionActions {
  setAdminRegion: (region: AdminRegion) => void;
}

export type AdminRegionPersist = (
  config: StateCreator<AdminRegionStore>,
  options: PersistOptions<AdminRegionStates>
) => StateCreator<AdminRegionStore>;

export interface AdminRegionStore extends AdminRegionStates, AdminRegionActions {}

AdminRegion 은 실제 다루게 될 상태입니다.
AdminRegionStates 는 Store 타입을 만들기 위해 인터페이스로 선언합니다.
AdminRegionActions 은 다룰 action 타입입니다. 해당 상태는 set 이외에 다른 액션이 필요하지 않기에 setter 함수만 정의해줍니다.
AdminRegionPersist Persist 를 위해 선언해줍니다. PersistOptionszustand/middleware 에서 import 합니다.
마지막으로 AdminRegionStore 는 실제 store 에 넣어줄 타입입니다.

index.ts

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { AdminRegion, AdminRegionPersist, AdminRegionStates, AdminRegionStore } from './type';

cosnt key = 'admin-region-store';
const adminRegion = JSON.parse(localStorage.getItem(key)) as {
  state: AdminRegionStates;
};

export const initialState: AdminRegionStates = {
  adminRegion: Number(adminRegion?.state?.adminRegion) || 0
};

const useAdminRegionStore = create<AdminRegionStore>(
  (persist as unknown as AdminRegionPersist)(
    set => ({
      adminRegion: initialState.adminRegion,
      setAdminRegion: (adminRegion: AdminRegion) => {
        set({ adminRegion });
      }
    }),
    {
      name: key,
      partialize: state => ({ adminRegion: state.adminRegion })
    }
  )
);

export default useAdminRegionStore;

상태 코드를 담고있는 index.ts 파일의 코드입니다. 먼저 LocalStorage 에 Persist 하기 위해 key 를 정의합니다. 그리고 초기 값을 넣어주기 위해 LocalStorage 에서 해당 키를 활용해 값을 가져온 후 initialState 에 넣어줍니다.

애플리케이션에서 해당 상태를 최초의 상태값으로 설정해야 할 상황이 생길수도 있기에 최초 값을 initialState 으로 선언했습니다.

Region 이라는 값은 애플리케이션에서 사용자의 편의를 위해 유지시키고 싶은 값 입니다. 그래서 Zustand 미들웨어를 활용해 쉽게 LocalStorage 에 값을 저장하도록 합니다. persist 함수를 import 해 콜백 함수와 config 값을 넣어줍니다.

콜백 함수의 파라미터로 객체를 넣어주는데 해당 객체의 set, get 메서드를 활용해 상태값을 저장하고 가져올 수 있습니다.

해당 메서드를 통해 상태에 대한 action 을 정의합니다.

Persist 는 여러 스토리지를 활용할 수 있습니다. LocalStorage, SessionStorage, AsyncStorage, IndexedDB 에 데이터를 저장할 수 있고 LocalStorage 가 기본값 입니다.

더 자세한 내용은 아래 문서에서 확인할 수 있습니다.

https://docs.pmnd.rs/zustand/integrations/persisting-store-data#hydration-and-asynchronous-storages

만약 Persist 를 사용하지 않는다면 간단하게 아래의 코드로 Store 를 정의할 수 있습니다.

const useAdminRegionStore = create<AdminRegionStore>(set => ({
  adminRegion: initialState.adminRegion,
  setAdminRegion: (adminRegion: AdminRegion) => {
    set({ adminRegion });
  }
}));

이밖에 Zustand 의 Immer 미들웨어를 활용해 불변성을 구현할 수 있습니다. 이번 포스팅에서 알아보지 않았지만 swallow 를 사용해 값이 변했을 경우만 랜더링이 발생하도록 랜더링 퍼포먼스를 최적화 할 수 있습니다.

마치며

프론트엔드 개발을 하면서 여러 상태관리 라이브러리를 사용해봤습니다. React 생태계가 발전하면서 더욱 다양한 상태관리 라이브러리가 등장하고 다양한 패턴으로 상태관리를 할 수 있게 되었습니다.

요즘은 @tanstack/react-query 를 활용해 서버에서 내려받은 데이터를 클라이언트 상태로 관리하지 않기 때문에 예전만큼 전역으로 관리되는 상태가 많지 않습니다. 그렇기에 패키지 크기가 작고 간단하게 사용할 수 있는 Zustand 는 상당히 메리트가 있는 선택지가 아닐까 생각합니다.

잘못된 내용이 있다면 댓글로 설명해주시면 감사하겠습니다. 읽어봐주셔서 감사합니다.

profile
안녕하세요! 주니어 웹 개발자입니다 😆

0개의 댓글