본문 바로가기
FRONTEND/React

Zustand로 상태관리 하기

by 드로니뚜벅이 2024. 5. 28.

리액트는 독립적인 컴포넌트 단위로 구성되어 있습니다. useState hook을 사용하여 하나의 컴포넌트에서 상태를 관리하고 props를 통해 부모-자식 간에 상태를 전파할 수 있습니다.
상태가 시작된 지점과 어떤 컴포넌트를 거쳐가는지, 모든 흐름을 이해하고 기억한다면 useState와 props를 사용하는데 무리가 없지만 프로젝트의 규모가 커짐에 따라 관리해야 할 상태의 개수가 늘어납니다. 그렇기 때문에 상태관리 툴을 사용해 효율적으로 상태를 관리할 필요가 있습니다. 상태관리를 위한 툴은 Redux,  Context API, React Query, Recoil, Jotai, Zustand 등이 널리 사용되고 있다.

Recoil과 Jotai는 Context, Provider 그리고 Hook을 기반으로 가능한 작은 상태를 효율적으로 관리하는데 반해 Zustand는 리덕스와 비슷하게 하나의 큰 스토어를 기반으로 상태를 관리하는 라이브러리입니다.

 

저는 Next.js를 사용하면서 전역 상태관리 라이브러리로 Zustand를 사용하고 있습니다. (Zustand는 독일어로 상태라는 뜻이라고 하네요.)

상태관리 라이브러리마다 제 각각 장단점이 있겠지만 Redux에 잠깐 발을 담가서 그런지 Zustand가 더 친밀하게 느껴지는 것 같네요. Redux에 비해 복잡도나 러닝 커브가 확연히 낮다고 볼 수 있습니다.

 

Zustand 장점

  • TypeScript로 작성되었습니다. (@types와 같은 추가 라이브러리 불필요)
  • 리덕스(Redux)를 축소화시킨 느낌으로 리덕스와 유사합니다.
  • Provider가 필요하지 않아 앱을 래핑하지 않아도 되기 때문에 불필요한 리랜더링을 최소화할 수 있습니다.
  • 동작을 이해하기 쉬울 뿐만 아니라 사용하기 쉽습니다.
  • 보일러플레이트가 거의 없습니다.
  • Redux Devtools를 사용할 수 있어 디버깅이 용이합니다.
  • 특정 라이브러리에 엮이지 않습니다. (그래도 React와 함께 쓸 수 있는 API는 기본적으로 제공합니다.)
  • 한 개의 중앙에 집중된 형식의 스토어 구조를 활용하면서, 상태를 정의하고 사용하는 방법이 단순합니다.
  • Context API를 사용할 때와 달리 상태 변경 시 불필요한 리랜더링을 일으키지 않도록 제어하기 쉽습니다.
  • React에 직접적으로 의존하지 않기 때문에 자주 바뀌는 상태를 직접 제어할 수 있는 방법도 제공합니다.

 

Zustand 동작원리

1) Flux Pattern

 

Zustand는 MVC 패턴이 아닌 Flux 패턴을 사용합니다.
Flux 패턴이란 사용자 입력을 기반으로 Action을 만들고 Action을 Dispatcher에 전달하여 Store(Model)의 데이터를 변경한 뒤 View에 반영하는 단방향의 흐름으로 애플리케이션을 만드는 아키텍처입니다.
각 요소들은 단방향 흐름에 따라 순서대로 역할을 수행하고, View로부터 새로운 데이터 변경이 생기면 처음부터 다시 이 순서대로 실행합니다. 이렇게 함으로써 예외 없이 데이터를 처리할 수 있게 됩니다.

 

2) Sub/Pub Model

Zustand는 Sub/Pub 모델을 기반으로 이루어져 있습니다.
스토어의 상태 변경이 일어날 때 실행할 Listener를 모아두었다가(Subscribe) 상태가 변경되었을 때 등록된 Listener들에게 상태가 변경되었다고 알려줍니다(Publish).

 

3) Closure

Zustand는 스토어를 생성하는 함수를 호출할 때 클로저를 활용합니다. 클로저는 간단히 말해 '함수가 선언될 시 그 주변 환경을 기억하는 것'으로, 스토어의 상태는 스토어를 조회하거나 변경하는 함수 바깥 스코프에 항상 유지되도록 만들어져 있습니다. 그러면 상태의 변경, 조회, 구독 등의 인터페이스를 통해서만 스토어를 다루고 실제 상태는 애플리케이션의 생명 주기를 처음부터 끝까지 의도치 않게 변경되는 것을 막을 수 있습니다.

즉, 하나의 Store는 Context가 아니라 Store가 가지는 Closure를 기반으로 생성되며 이 Store의 상태가 변경되면 이 상태를 구독하고 있는 컴포넌트에 전파해 리렌더링을 알리는 방식입니다.

 

 

프로젝트에서 전역적으로 사용하고자 하는 데이터는 use...Store 파일을 생성해서 아래처럼 저장해서 관리합니다.

├── src
│   └── store
│       ├── useDiaryStore.ts
│       ├── useMemoStore.ts
│       └── useTodoStore.ts

 

내부적으로 React의 Context API를 사용하여 상태를 전역으로 공유합니다. 따라서, Zustand의 State도 set함수를 통해 값이 업데이트되면 컴포넌트가 다시 렌더링됩니다.

 

Zustand 설치하기

$ npm i zustand

 

Zustand Store 생성 (useCounterStore.ts)

create() 함수를 이용해 store를 생성합니다. Redux와 달리 Providers로 감싸지 않고 사용이 가능합니다.

매개변수로 set() 함수를 받아 함수 업데이트를 통해 상태를 업데이트합니다.

// src/store/useCounterStore.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  inc: () => void;
  dec: () => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 1,
  inc: () => {
    set((state) => ({ count: state.count + 1 }))
  },
  dec: () => {
    set((state) => ({ count: state.count - 1 }))
  },
}));

export default useCounterStore;

 

위 코드에서 CountState 인터페이스를 리덕스(Redux)처럼 State와 Action 타입으로 구분해서 아래처럼 다시 정의해 볼 수 있습니다.

// src/store/useCounterStore.ts
import { create } from 'zustand';

type State = {
  count: number;
}
type Actions = {
  inc: () => void;
  dec: () => void;
}

const useCounterStore = create<State & Actions>((set) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set((state) => ({ count: state.count - 1 })),
}));

export default useCounterStore;

 

Zustand 사용하기

import './App.css';
import useStore from '../store/useCounterStore';

function App() {
  const { count, inc, dec } = useCounterStore((state) => state);
  return (
    <>
      <h1>현재 카운트는 {count}입니다.</h1>
      <button
        onClick={() => {
          inc();
        }}
      >
        숫자 더하기
      </button>
      <button
        onClick={() => {
          dec();
        }}
      >
        숫자 빼기
      </button>
    </>
  );
}

export default App;

 

Zustand Persist Middleware

전역 상태를 사용하는데, 새로 고침 후에도 State(값)을 사용하도록 스토리지에 값을 저장해야하는 경우 persist를 이용하면 된다.

"Persist 미들웨어를 사용하면 Zustand 상태를 저장소(예: localStorage, AsyncStorage, IndexedDB등)에 저장하여 해당 데이터를 유지할 수 있다."

// 기본 사용 예시
// create 함수 내에 persist(()=> (), { }) 형태로 작성
import { create } from 'zustand'
import { devtools, persist, createJSONStorage } from 'zustand/middleware'

const StorageKey = 'storage-key';

export const useBearStore = create(
  devtools(
    persist(
      (set, get) => ({
        bears: 0,
        addABear: () => set({ bears: get().bears + 1 }),
      }),
      {
        name: StorageKey, // name of the item in the storage (must be unique)
        storage: createJSONStorage(() => sessionStorage), // (optional)이기 때문에 해당 줄을 적지 않으면 'localStorage'가 기본 저장소로 사용된다.
      },
    ),
  )
);

 

 

바닐라 스토어 생성

createStore를 사용하면 리액트와 상관없는 바닐라 스토어를 생성할 수 있습니다. 이렇게 생성된 스토어는 useStore 훅을 통해 사용할 수 있습니다.

// src/store/counterStore.ts
import { createStore, useStore } from 'zustand';

type State = {
  count: number;
}
type Actions = {
  inc: () => void;
  dec: () => void;
}

const counterStore = createStore<State & Actions>((set) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set((state) => ({ count: state.count - 1 })),
}));

export default counterStore;

 

사용 예:

import counterStore from '../store/useCounterStore';

export default function Counter() {
  const { count, inc, dec } = useStore(counterStore);
  
  return (
    <div>
      <div>{count}</div>
      <button onClick={inc}>+</button>
      <button onClick={dec}>-</button>
    </div>
  );
}

 

 

 

더 자세한 내용은 Zustand 공식 문서를 참고하세요.

 

참고사이트