React Global State Library Comparsion

류지승·2025년 2월 26일

React

목록 보기
19/19
post-thumbnail

서론

다들 react를 개발하면서 state를 관리하는 라이브러리를 사용하지 않는 경우는 거의 없을 것이다. 필자 또한 init setting할 때 자연스레 상태관리 라이브러리를 설치한다. useState / useContext로만으로 react를 처리하기엔 한계가 명확하기 때문이다.

필자는 주로 Zustand를 이용하여 전역 상태 관리를 하였다. 문뜩 다른 상태관리 라이브러리가 궁금했었고 이번 기회에 좀 더 깊게 공부하고자 블로그를 정리한다. 재미있겠다.

상태관리 라이브러리

Data Fetching을 관리하는 상태관리 라이브러리(react query / swr / apollo client)가 아닌 Global State를 관리하는 상태관리 라이브러리는 크게 zustand / redux / mobx / jotai / recoil이 있다.

라이브러리를 설명하기 앞서 필자가 왜 context API를 선호하지 않은 이유에 대해서 말해보겠다.

context API

react는 유지보수뿐만 아니라 데이터 흐름을 예측 가능하고 직관적으로 만들기 위해 기본적으로 양방향 데이터 바인딩이 아닌 단방향 데이터 바인딩이다. 따라서 서로 다른 컴포넌트에서 데이터를 사용하고 싶을 땐 공통으로 묶이는 부모 컴포넌트에서 state를 관리하게 된다. 또한 서로 다른 컴포넌트에서 해당 state를 사용하기 위해선 props drilling을 통해 state를 받는다.

다만 이 방식은 가독성에서 문제가 있다. props로 2개 ~ 3개정도의 layer를 넘겨준다면 문제가 없겠지만 그 이상의 layer로 state를 넘겨준다고 가정해보자. 그럼 state를 계속계속 내려야해서 중복 코드도 늘어날 것이며, 가독성 측면에서 보기 힘들 것이다.

react팀도 이를 판단하고 새로 만든 훅이 useContext이다. 해당 staet를 사용하는 공통 컴포넌트에서 context.provider를 선언하여 value에 state를 넣어주면 proivder layer로 묶인 모든 컴포넌트에서 해당 state를 사용할 수 있다.

import { useContext } from "react";
import { UserContext } from "./UserContext";

function Child() {
  const user = useContext(UserContext);

  return <div>{user.name}</div>;
}

useContext를 이용하여 해당 state가 필요한 컴포넌트에서 state를 호출하여 사용할 수 있게 하는것이다. useState + props drilling을 이용하는 것과 비교하자면 가독성 + 유지보수 측면에서 훨씬 개선되었다는 것을 확인할 수 있을 것이다.

다만 이 또한 문제가 있다. provider로 묶인 모든 component는 state의 변경이 일어났다면 전부 리렌더링이 되는 것이다. 해당 state가 필요없는 부분 조차 전부 리렌더링이 된다는 것이다. 이는 한 두번 일어나면 문제가 없겠지만 수십번 수백번이 일어나면 문제가 발생할 것이다.

이를 해결하기위해 React.Memo가 탄생하였는데, 해당 useEffect의 Dependency Array처럼 state가 변경될 때만 해당 컴포넌트를 리렌더링 시키게 하는 것이다. 리렌더링 최적화 측면에서 좋겠지만 이 또한 문제가 있다.. 가독성이 이라는 것이다. 극단적인 예시긴 하지만 예를 들어보자면, 제일 최상위에서 state를 선언해 provider로 넘겨준다고 가정해보자 해당 state가 필요한 component는 제일 하단 (100번째)의 컴포넌트들이라고 가정하면 나머지 컴포넌트들은 전부 React Memo를 달아줘야하는 것이다.

따라서 이러한 문제들 때문에 필자는 useContext를 선호하지 않는다. 당연히 한 5개단위정도의 layer에서의 props drlling이 발생한다면 사용하겠지만 서로 멀리 있는 컴포넌트에서의 state 관리는 반드시 상태관리 라이브러리를 사용한다.

Pattern

상태를 관리함에 있어 다양한 라이브러리가 존재하고 각 라이브러리 별로 state를 관리하는 특징들이 있다.

Flux 패턴


flux 패턴이란, 2014년 페이스북 컨퍼런스에서 발표된 아키텍처로, Client-Side 웹 애플리케이션을 만들기 위해 사용하는 디자인 패턴이다.

기존 client-side에서의 MVC 패턴의 문제점

// vue.js
<template>
  <input v-model="message" />
  <p>{{ message }}</p>
</template>

<script>
export default {
  data() {
    return {
      message: "Hello, World!"
    };
  }
};
</script>

message (Model)가 변경되면 <p> (View)가 자동 업데이트됨.
<input>(View)에서 사용자가 값을 변경하면 message (Model)도 자동 업데이트됨.

현재 간단한 입력 단계에선 큰 문제가 없지만 점차 규모가 커지면 서로 의존하고 있는 view - model이 생겨날 것이고, state 변화에 대해 예측하기 힘들것이다.

따라서 기존 MVC패턴의 문제를 타파하고자 facebook에서 Flux 패턴을 제안하였다. 이는 데이터 흐름을 양방향으로 컨트롤하는 게 아닌 단방향으로 처리함으로써 예측 가능한 어플리케이션을 만들자고 제안하였다.

action

action이란 사용자의 event 또는 서버 액션을 나타낸다. 예를 들어서, user가 A 값을 입력한다든지, server에서 어떤 데이터를 받았다는 지. 모든 행동을 action이라고 칭한다. actiontype으로 어떤 행동인지 정의하고, payload로 데이터를 넘겨주는 형태로 구성되어 있다. typerequired지만 payloadoptional로 필수가 아니다.

const action = {
  type: "ADD_TODO",
  payload: { id: 1, text: "Redux 공부하기" }
};

dispatch

flux 패턴의 특징으로 모든 이벤트 처리를 한 곳에 하는 것이다. 이벤트를 한 곳에서 처리함으로써, 예측 가능한 상태관리 및 미들웨어 적용이 용이해진다.
dispatch모든 action을 한 곳에서 처리하고 알맞은 store로 전달하는 역할을 한다. 여기서 핵심은 store의 업데이트는 반드시 dispatch로만 가능하다는 것이다.

store

store는 어플리케이션에서의 state를 저장하는 곳이다. dispatch를 통해 action을 받아 state를 업데이트하며 업데이트한 stateview로 전달한다.

view

view는 실제 UI를 담당한다. store에서 필요한 state를 불러와 ui를 그리며, event가 발생하면 action이 실행되어서 dispatch를 호출한다.

Flux 패턴은 위와 같이 예측 가능한 상태관리가 가능하지만, 단점으로는 Boilerplate가 존재한다는 것이다. state를 관리하기 위해서, action / dispatch / store라는 형태가 강제되기 때문이다. 따라서 단순 state를 관리한다면 flux 패턴을 기반으로 둔 상태관리 라이브러리는 부적합하다.

Atomic 패턴

atom이란 원자를 의미한다. 즉 flux 패턴과 다르게 한 곳에서 모든 state를 관리하는 것이 아닌, 각 분자별로 state를 분리하여 관리한다는 것이다.

Atomic 패턴은 상태 관리 시스템을 설계하는 방식으로, 상태를 독립적이고 작은 단위로 분리하여 관리하는 패턴이다. 이렇게 하면 각 상태가 독립적으로 변경되고, 다른 상태에 영향을 주지 않으며 필요한 컴포넌트에서만 상태를 구독할 수 있게 된다.

Atomic 패턴 장점

독립적인 상태 관리
각 상태는 서로 독립적으로 동작하고, 다른 상태에 영향을 미치지 않는다. 필요한 상태만 구독하고 관리할 수 있다.

컴포넌트의 세밀한 업데이트
상태가 변경될 때, 해당 상태를 구독하고 있는 컴포넌트만 리렌더링되기 때문에 불필요한 렌더링을 줄일 수 있다.

모듈화와 확장성
상태 관리가 독립적으로 이루어지므로, 애플리케이션의 규모가 커져도 상태 관리가 훨씬 더 효율적이고 확장 가능해진다.

Global State Library

Zustand - zustand 공식홈페이지

zustand의 가장 큰 포인트는 귀여운 곰이다..

Zustand는 독일어로 '상태'

zustand는 작고 빠르며 확장 가능한 React 프로젝트에서 사용하는 상태 관리 라이브러리이다. zustand의 핵심은 '작고, 빠르다'이다. 일단 패키지 번들 사이즈 5.0.3기준 87.1KB이다. 다른 상태관리 라이브러리와 비교했을 때 꽤나 큰 차이를 보인다.

zustand가 각광받고 있는 이유는 다음과 같다. 먼저 가볍다. 너무 가볍다. RTK와 비교했을 때 약 80배 정도된다. 최근 frontend 트렌드를 보면 성능 최적화가 대두되고 있는데 이 상황에서의 zustand의 번들 크기는 무시하지 못할 것이다.

또한, 러닝커브가 상당히 낮다. RTK만 봐도, action / dispatch / store / selector 등 배워야할 것이 많은데, zustand 같은 경우, create()를 통해 state를 정의하고 정의한 store명 ex)useStore()를 통해 state만 호출하면 끝이다.

zustand는 flux 패턴이나 action 로직이 없기 때문에, redux에 비해 상대적으로 구조화가 되어 있지않다. 이는, 비즈니스 로직과 상태 업데이트 로직이 섞일 가능성이 높다. 또한, 불필요한 re-render를 유발할 수도 있는데, store 전체를 구독하고 있으면, 모든 변경 시 리렌더링을 발생하기 때문이다. 따라서 store를 구독할 때는 필요한 state만 구독하는 편이 re-render를 덜 유발할 것이다.

Store

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 1,
  inc: () => set((state) => ({ count: state.count + 1 })),
}))

function Counter() {
  const count = useStore((state) => state.count)
  const inc = useStore((state) => state.inc)

  return (
    <div>
      <span>{count}</span>
      <button onClick={inc}>one up</button>
    </div>
  )
}

사용법

import { create } from 'zustand'

export const use__Store = create<{
	state: type
    action: () => void
}>((set, get) => {
  return {
    state: init,
    action: function
  }
})

store naming convention은 접두사 use / 접미사 Store이다.
ex) useTodoListStore
create를 통해 store를 생성하며, callback으로 set과 get을 받는다.

set, get 정의
set
현재 상태를 기반으로 새로운 상태를 설정하는 함수다. 상태 변경 로직이 간단하고, 현재 상태를 바로 읽고 업데이트하는 경우에 주로 사용된다. set에서 callback함수로 state를 호출할 수 있는데, store 내부에 있는 state를 직접적으로 접근하여 값을 업데이트할 수 있다. 이때, 얕은 복사형태로 값을 업데이트하는 게 아닌 새로운 객체를 생성하여 state를 업데이트한다.

import create from 'zustand';

const useStore = create<{
  count: number, 
  increase: () => void 
}>((set) => ({
  count: 0, 
  increase: () => set((state) => ({ count: state.count + 1 })),  
}));

get
현재 상태를 읽을 수 있게 해주는 함수다. get을 사용하면 상태를 읽고 그 값을 기반으로 더 복잡한 상태 업데이트를 처리할 수 있다. get은 상태를 비동기적으로 읽어야 할 때나, 상태 값을 명시적으로 조회해야 할 때 유용하다.

import create from 'zustand';

const useStore = create<{
  count: number, 
  increase: () => void 
}>((set, get) => ({
  count: 0, 
  increase: () => {
    const { count } = get()
    set({ count: count + 1 })
  } 
}));

Redux-Toolkit - RTK 공식홈페이지

Redux는 reduce + flux

NPM Trends기준으로 Redux만 봤을 때, 이를 이길 수 있는 라이브러리는 없을 것이다. 그만큼 근본있는 라이브러리이다. Flux 패턴 기반으로 state를 업데이트하며, 중앙 집중식 데이터 흐름, 강력한 devtools, 다양한 middleware, 풍부한 커뮤니티, etc

다만,, Boilerplate가 많다... 가볍게 state 처리할 때는 비효율적이다. distpatch action store selector...

Jotai - Jotai 공식홈페이지

Jotai는 일본어로 '상태'

jotai에서 소개하듯이 primitive and flexible state management for React. 리액트를 위한 원시적이며 유연한 상태관리 도구이다.

atom 기반으로 동작하며 redux와 다르게 중앙 한곳에서 관리하는 게 아닌 atom에서 state를 관리한다. useState와 형태가 비슷하여 러닝커브가 상대적으로 낮으며, 한 곳에서 state를 관리하는 게 아니라 각각 state를 관리하고 있기 때문에 의존성 측면이라던지 성능 최적화측면에서 유리하다.

세밀한 제어가 가능하다는 것은, 각각 atom을 구독하기 때문에 state간의 영향이 적다. state가 독립적으로 존재하며 state를 사용하는 component만 re-render 되기 때문에 state간의 의존도를 낮춰 리렌더링을 최소화할 수 있다.

하지만 jotai는 각각 atom에서 관리하기 때문에, 전반적인 application의 state 흐름을 한눈에 파악하기 힘들고, devtool도 갖고 있지 않아 디버깅이 어렵다.

atom

import { atom } from 'jotai'

const priceAtom = atom(10)
const messageAtom = atom('hello')
const productAtom = atom({ id: 12, name: 'good stuff' })

atomReact에서 useState와 비슷한 역할을 한다. 다만 컴포넌트 내부에서 사용하는 것이 아닌 전역에서 사용할 수 있다. state를 저장할 때 사용되며 useAtom을 이용하여 atom을 불러와서 component에서 사용한다.

atom은 세가지 pattern이 있다.

Derived Atom

  • Read-only Atom
    Read-only Atom은 상태를 읽을 수만 있고, 수정할 수는 없는 상태를 의미한다. Read-Only Atom은 의존하고 있는 다른 Atom이 업데이트되면, 해당 값도 자동으로 업데이트된다.
const readOnlyAtom = atom((get) => get(priceAtom) * 2)
  • Write-only Atom
    Write-only Atom은 상태를 읽을 수 없으며 수정만 할 수 있는 상태를 의미한다. 주로 액션을 통해 어떤 변화가 필요할 때 사용된다. Read-only Atom과 다르게 get argument의 atom이 변화가 일어나면 자동으로 state가 업데이트 되지 않는다. set을 통해 state를 업데이트하며, update는 상태를 변경할 때 외부에서 전달되는 값으로, 예를 들어 사용자가 입력한 값이나 다른 액션의 결과를 반영할 수 있다.

    특이하게 첫 번째 인자로 null을 넣는데, 이는 read only에서는 첫번째 인자로 state를 넣기 때문이다. 즉, write only 같은 경우 값을 읽지 못하고 수정만 할 수 있기 때문에 명시적으로 null을 넣으면서, 읽을 값이 없다는 것을 나타낸다.

const writeOnlyAtom = atom(
  null, // it's a convention to pass `null` for the first argument
  (get, set, update) => {
    // `update` is any single value we receive for updating this atom
    set(priceAtom, get(priceAtom) - update.discount)
    // or we can pass a function as the second parameter
    // the function will be invoked,
    //  receiving the atom's current value as its first parameter
    set(priceAtom, (price) => price - update.discount)
  },
)
  • Read-Write Atom
    Read-Write Atom은 상태를 읽음 동시에 수정도 가능하다.
const readWriteAtom = atom(
  (get) => get(priceAtom) * 2,
  (get, set, newPrice) => {
    set(priceAtom, newPrice / 2)
    // you can set as many atoms as you want at the same time
  },
)

useAtom

useAtomreact component 내부에서 atom을 접근할 때 사용하는 hook이다. React의 useState와 같은 형식으로 튜플형태로 atom을 호출할 수 있다.

atom은 WeakMap 형태로 저장되는데 key로 atom으로 넣어줘야 WeakMap에 접근하여 state와 setState를 불러올 수 있는 것이다. 해당 atom이 업데이트되면 자동으로 컴포넌트를 리렌더링 시켜준다.

const [atomValue] = useAtom(atom(0))

위와 같은 useAtom argument로 atom을 선언하게 되면 무한루프를 야기할 수 있다. 따라서 미리 atom을 선언하여 useAtom에 atom 객체를 넣어줘야한다.

const stableAtom = atom(0)
const Component = () => {
  const [atomValue] = useAtom(stableAtom) // This is fine
  const [derivedAtomValue] = useAtom(
    useMemo(
      // This is also fine
      () => atom((get) => get(stableAtom) * 2),
      [],
    ),
  )
}

useAtomValue / useSetAtom
useAtomValueatom을 읽을 때만 사용하는 hook이다. 주로 state만 필요한 경우에 사용한다. Read-Only Atom 같은 경우 useAtomValue를 사용한다.

const countAtom = atom(0)

const Counter = () => {
  const count = useAtomValue(countAtom)

  return <div>count: {count}</div>
}

useSetAtomatom을 업데이트할 때만 사용하는 hook이다. 주로 읽기가 필요없고 업데이트만 필요한 경우에 사용한다. Write-Only Atom 같은 경우 useSetAtom을 사용한다.

const switchAtom = atom(false)

const SetTrueButton = () => {
  const setCount = useSetAtom(switchAtom)
  const setTrue = () => setCount(true)

  return (
    <div>
      <button onClick={setTrue}>Set True</button>
    </div>
  )
}

const SetFalseButton = () => {
  const setCount = useSetAtom(switchAtom)
  const setFalse = () => setCount(false)

  return (
    <div>
      <button onClick={setFalse}>Set False</button>
    </div>
  )
}

export default function App() {
  const state = useAtomValue(switchAtom)

  return (
    <div>
      State: <b>{state.toString()}</b>
      <SetTrueButton />
      <SetFalseButton />
    </div>
  )
}

Recoil - Recoil 공식홈페이지

recoil은 maintainer가 그만 둔 상태로 2025년 1월 12일 deprecated 상태가 되었다.

Recoil은 facebook에서 만든 오픈소스로서, 현재 react의 useState / useContext의 문제점을 타파하기 위해 생긴 상태관리 라이브러리이다. Recoil은 Jotai와 비슷하게 Atomic 패턴으로 state를 관리한다.

atom

Jotai와 동일하게 Recoil은 Atom에 state을 저장하고 관리한다. react의 useState처럼 동작하지만, 전역적으로 공유가 가능하다. 또한 Jotai의 atom과는 다르게, atom 내부에 key, default라는 값이 존재한다. key는 내부적으로 state를 고유하게 식별하고 관리하기 위해 필요하며, React Devtools를 통해 디버깅을 확인할 수 있다.

const fontSizeState = atom({
  key: 'fontSizeState',
  default: 14,
});

selector

selectorJotai의 derrived atom과 유사한 역할을 한다. selector는 순수함수이며, atom의 state를 구독하여 atom의 state가 변경되면 리렌더링된다.

atom과 마찬가지로 selector를 구분하는 고유의 key가 존재한다. get를 통해 atom을 불러와 값을 파생시키며, set을 통해 atom을 업데이트 시킬 수 있다. useReducer 와 유사한 역할을 한다고 보면 된다.

const fontSizeState = atom({
  key: 'fontSizeState',
  default: 14,
});

const fontSizeSelector = selector({
  key: 'fontSizeSelector',
  get: ({ get }) => {
    const fontSize = get(fontSizeState);
    return fontSize + 'px';
  },
  set: ({ set }, newValue) => {
    set(fontSizeState, newValue);
  },
});

useRecoilState

React 컴포넌트 내부에서 state를 호출할 때 사용하는 hook이다. React의 useState와 유사하다고 보면 된다. Jotai에서는 useAtom과 유사한 친구라고 보면 된다.

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  return (
    <button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
      Click to Enlarge
    </button>
  );
}

useSetRecoilState / useRecoilvalue / useResetRecoilState
useSetRecoilStateJotai의 useSetAtom과 유사한 역할을 한다. 주로 쓰기전용 recoil hook으로 사용된다.

import {atom, useSetRecoilState} from 'recoil';

const namesState = atom({
  key: 'namesState',
  default: ['Ella', 'Chris', 'Paul'],
});

function FormContent({setNamesState}) {
  const [name, setName] = useState('');

  return (
    <>
      <input type="text" value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={() => setNamesState(names => [...names, name])}>Add Name</button>
    </>
)}

// This component will be rendered once when mounting
function Form() {
  const setNamesState = useSetRecoilState(namesState);

  return <FormContent setNamesState={setNamesState} />;
}

useRecoilValueJotai의 useAtomValue와 유사한 역할을 한다. 주로 읽기 전용 recoil hook으로 사용된다.

import {atom, selector, useRecoilValue} from 'recoil';

const namesState = atom({
  key: 'namesState',
  default: ['', 'Ella', 'Chris', '', 'Paul'],
});

const filteredNamesState = selector({
  key: 'filteredNamesState',
  get: ({get}) => get(namesState).filter((str) => str !== ''),
});

function NameDisplay() {
  const names = useRecoilValue(namesState);
  const filteredNames = useRecoilValue(filteredNamesState);

  return (
    <>
      Original names: {names.join(',')}
      <br />
      Filtered names: {filteredNames.join(',')}
    </>
  );
}

Jotai와 다르게 Recoil에는 useResetRecoilValue라는 hook이 존재하는데, hook 이름처럼 default value로 초기화하고 싶을 때 사용하는 hook이다.

import {todoListState} from "../atoms/todoListState";

const TodoResetButton = () => {
  const resetList = useResetRecoilState(todoListState);
  return <button onClick={resetList}>Reset</button>;
};

Jotai vs Recoil

FeatureJotaiRecoil
Concept원자(atom)을 기반으로 한 간단한 상태 관리.원자(atom)와 선택자(selector)를 활용한 상태 관리.
State Representation원자(atom)로 상태를 표현.원자(atom)와 선택자(selector)로 상태를 표현.
Derived Statederived atomuseAtom 훅을 사용해 계산된 값을 처리.선택자를 사용하여 유도된 상태를 처리.
API Complexity간단한 API, 최소화된 사용법.선택자와 비동기 지원을 포함한 더 복잡한 API.
Performance작은 앱에 최적화, 최소한의 리렌더링.복잡한 상태를 처리하는 대규모 앱에 최적화.
IntegrationReact 훅과 잘 통합됨.React 훅과 긴밀하게 통합됨.
Async Stateatom에서 promise를 사용하여 간단히 비동기 상태 관리.비동기 선택자를 위한 내장 지원.
Development Experience최소한의 보일러플레이트, 이해하기 쉬움.고급 기능을 가진 풍부한 생태계.
Community Support작은 커뮤니티, 아직 성장 중.더 큰 커뮤니티와 성숙한 생태계.
Size가볍고 작은 크기.Jotai보다 큰 크기.
Use Case간단한 앱과 가벼운 상태 관리에 적합.복잡한 상태 의존성이 있는 대규모 앱에 적합.

MobX - MobX 공식홈페이지

Valtio - Valtio 공식홈페이지

Valtio는 핀란드어로 '상태'

profile
성실(誠實)한 사람만이 목표를 성실(成實)한다

0개의 댓글