bandal.dev

React 상태 관리 라이브러리 비교하기

Context-API, Recoil, Redux, Zustand에 대해 비교해보겠습니다.

25
상태 관리
Props Drilling

들어가며

React는 컴포넌트 기반의 라이브러리여서, 작은 컴포넌트들이 모여서 복잡한 구조를 구성하게 됩니다.
앱의 규모가 커질수록 상태관리가 점점 복잡해져서 컴포넌트 간의 상태를 전달할 때 Props Drilling 이라는 문제가 발생합니다.
이러한 상태 관리의 복잡함을 해결하기 위해 상태관리 라이브러리를 사용할 수 있습니다.

상태 관리 라이브러리는 크게 3가지로 나눌 수 있습니다.

Reducer-based
신뢰 가능한 단일 출처 (single source of truth) 라고 불리며, Action을 Dispatcher를 통해 발생시킴으로 중앙 집중식으로 상태를 관리합니다.
해당 그룹에는 Redux, Zustand 등이 있습니다.

Atom-based
상태를 Atom 단위로 관리하며, React Hooks를 사용하여 상태를 읽고 쓰는 방식으로 사용합니다.
해당 그룹에는 Redoil, Jotai 등이 있습니다.

Mutable-based
Proxy를 활용하여 직접적으로 읽거나 쓸 수 있는 데이터 소스를 생성하는 방식으로 사용합니다.
해당 그룹에는 MobX, Valtio 등이 있습니다.
아래 글에서 해당 기반의 라이브러리는 다루지 않습니다.

이번 글에선 상태관리 라이브러리별 특징과 장단점을 비교해보도록 하겠습니다.

Context API

React에 내장되어 있는 라이브러리로 추가로 설치할 필요가 없습니다.
또한 컴포넌트에 의존성을 주입하는 방법중 가장 효과적인 방법입니다.

하지만 ContextAPI는 아래와 같은 주의 사항이 존재합니다.

  1. Provider의 컴포넌트 트리의 상위에서 상태 변경이 일어나면 하위에 Context를 구독하고 있는 모든 컴포넌트가 리렌더링 됩니다.
  2. 특정 Context.Provider에 의존하기 때문에 컴포넌트 간 결합도가 증가하여 재사용이 어려워 집니다.

아래는 간단한 예시입니다.

  1. 먼저 Context를 생성합니다.
import { Dispatch, PropsWithChildren, createContext, useContext, useState } from "react";
 
interface SettingContext {
    theme: [string | undefined, Dispatch<string | undefined>];
    locale: [string | undefined, Dispatch<string | undefined>];
}
 
const settingContext = createContext<SettingContext>( {} as SettingContext );
 
export function SettingProvider( props: PropsWithChildren )
{
    const theme = useState<string | undefined>( "dark" );
    const locale = useState<string | undefined>( "kr" );
 
    return <settingContext.Provider
        value={{
            theme,
            locale,
        }}
    >
        {props.children}
    </settingContext.Provider>;
}
 
export function useSettingContext( )
{
    const context = useContext( settingContext );
 
    return {
        context,
    };
}
  1. 이후 사용할 하위 컴포넌트를 Provider로 감싸줍니다.
import React from 'react';
import { SettingProvider } from './setting.Context';
 
const App: React.FC = () => {
  return (
    <SettingProvider>
      <div className="app">
        <h1>React Context API 예시</h1>
        <ThemeSelector />
        <LocaleSelector />
      </div>
    </SettingProvider>
  );
};
 
export default App;

그러면 하위 컴포넌트에서 useSettingContext를 통해 상태를 가져올 수 있습니다.

import React from 'react';
import { useSettingContext } from './setting.Context';
 
export default fucntion ThemeSelector()
{
  const { theme } = useSettingContext();
 
  return (
    <div>
      <span>테마</span>
      <Select option={ThemeOption} value={theme} />
    </div>
  );
};

React 공식 문서에서 언제 context를 써야 할까 를 참고하면 전역적 ( global )이라고 볼 수 있는 데이터를 공유하는 방법으로 사용하도록 고안된 방법이라 명시되어 있습니다.

context는 React 컴포넌트 트리 안에서 전역적(global)이라고 볼 수 있는 데이터를 공유할 수 있도록 고안된 방법입니다. 그러한 데이터로는 현재 로그인한 유저, 테마, 선호하는 언어 등이 있습니다.

Context는 전역적으로 데이터를 공유하는 API 입니다.
반복적이고 복잡한 업데이트에 사용할 경우 불필요한 리렌더링이 일어날 수 있다는 것을 인지해야 합니다.
때문에 아래의 경우 사용할 것을 권장합니다.

  • Component가 ContextAPI 에서 관리하는 상태에 의존성을 주입해야 할 경우
    • Provider 내부에만 존재할 수 있으므로, 전역 상태가 잘못 사용되는 것을 방지할 수 있습니다.
  • 낮은 빈도로 업데이트가 일어나는 데이터를 공유할 때

Recoil

Recoil은 React를 구현한 페이스북에서 직접 구현한 React 만을 위한 상태관리 라이브러리로 가장 큰 장점은 러닝커브가 낮습니다. API가 단순하고 hook 과 비슷한 사용경험을 제공합니다.
또한 React v18 부터 도입된 Concurrent Mode ( 동시성 모드 )와 개발 방향성이 같습니다. Recoil 에서 Transition을 지원하는 기능을 개발하여 업데이트가 무거운 컴포넌트의 경우 상태 업데이트 중 상위 Suspense를 호출하는 기능 등을 개발중에 있습니다.

Concurrent Mode 는 이전에 작성하였던 글을 참고해주세요

하지만 2024년 현재, Recoil의 포지션은 그다지 좋은 상태라고 볼 수 없습니다.
Recoil은 아직 정식 릴리즈가 되지 않았고, 마지막 업데이트는 2023년 4월 12일로 최근 1년간 업데이트가 되지 않았습니다.

아래부턴 코드 예시입니다.
Recoil을 사용하기 위해선 앱을 RecoilRoot로 감싸고, 데이터를 atom 단위로 선언하여 사용하면 됩니다.

import React from "react";
import ReactDOM from "react-dom";
import { RecoilRoot } from "recoil";
import App from "./App";
 
ReactDOM.render(
  <RecoilRoot>
    <App />
  </RecoilRoot>,
  document.getElementById("root")
);

Atoms

240220-215834

Atoms( 공유 상태 )은 상태의 단위 입니다. Atom을 업데이트하거나 구독할 수 있고, atom이 업데이트 되면 각각 구독된 컴포넌트는 새로운 값을 반영하여 다시 렌더링 됩니다.

atom을 생성하기 위해선 고유한 키 값과 디폴트 값을 설정해야 합니다.

import { atom } from "recoil";
 
export const counterState = atom({
  key: "counterState",
  default: 0,
});

이 후 컴포넌트에서 atom을 읽고 쓰려면 useRecoilState 훅을 사용하면 됩니다.

import React from "react";
import { useRecoilState } from "recoil";
import { counterState } from "./atoms";
 
export default function Counter() {
  const [count, setCount] = useRecoilState(counterState);
 
  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };
 
  const handleDecrement = () => {
    setCount((prevCount) => prevCount - 1);
  };
 
  return (
    <div>
      <h1>카운터: {count}</h1>
      <button onClick={handleIncrement}>증가</button>
      <button onClick={handleDecrement}>감소</button>
    </div>
  );
};

useRecoilState 외에 atom 값만 사용하기 위해선 useRecoilValue, setter만 사용하려면 useSetRecoilState Hook을 사용하면 됩니다.

atom with TypeScript

Recoil 은 타입스크립트를 지원합니다. 아래는 타입 스크립트를 사용해서 atom을 정의한 예시 입니다.

import { atom } from 'recoil';
import { User } from './types';
 
interface User {
  name: string;
  age: number;
}
 
export const userAtom = atom<User>({
  key: 'userAtom',
  default: { 
    name: '',
    age: 0
  },
});

selector

selector는 상태에서 파생된 데이터를 정의하는데 사용합니다. selector 를 사용하면 하나 이상의 'atom' 이나 selector를 기반으로 계산되는 상태를 만들 수 있습니다. selector는 구현한 함수에 따라 반환되는 객체가 다른데, get 함수만 제공되면 RecoilValueReadOnly, set 함수 또한 제공되면 RecoilState를 반환합니다.

읽기 전용 Selector

import { atom, selector } from 'recoil';
 
const number1State = atom({
  key: 'number1State',
  default: 0,
});
 
const number2State = atom({
  key: 'number2State',
  default: 0,
});
 
const sumSelector = selector({
  key: 'sumSelector',
  get: ({ get }) => {
    const number1 = get(number1State);
    const number2 = get(number2State);
    return number1 + number2;
  },
});

읽기만 가능한 selector는 의존하는 상태가 변경될 때만 재계산하여 리렌더링을 수행합니다.

쓰기 가능한 Selector

입력 값을 받아서 다른 Recoil State에 변경 사항을 전파하는 데 사용할 수 있습니다.

import {atom, selector, useRecoilState, DefaultValue} from 'recoil';
 
const tempFahrenheit = atom({
  key: 'tempFahrenheit',
  default: 32,
});
 
const tempCelcius = selector({
  key: 'tempCelcius',
  get: ({get}) => ((get(tempFahrenheit) - 32) * 5) / 9,
  set: ({set}, newValue) =>
    set(
      tempFahrenheit,
      newValue instanceof DefaultValue ? newValue : (newValue * 9) / 5 + 32,
    ),
});
 
function TempCelcius() {
  const [tempF, setTempF] = useRecoilState(tempFahrenheit);
  const [tempC, setTempC] = useRecoilState(tempCelcius);
  const resetTemp = useResetRecoilState(tempCelcius); // default 값으로 리셋합니다.
 
  const addTenCelcius = () => setTempC(tempC + 10);
  const addTenFahrenheit = () => setTempF(tempF + 10);
  const reset = () => resetTemp();
 
  return (
    <div>
      Temp (Celcius): {tempC}
      <br />
      Temp (Fahrenheit): {tempF}
      <br />
      <button onClick={addTenCelcius}>Add 10 Celcius</button>
      <br />
      <button onClick={addTenFahrenheit}>Add 10 Fahrenheit</button>
      <br />
      <button onClick={reset}>Reset</button>
    </div>
  );
}

섭씨/화씨를 표시하고 사용자가 변경할 수 있는 컴포넌트 입니다.

위 코드에서 setTempF 를 호출할 경우 tempCelcius Selector 의 get 에서 tempFahrenheit이 변경된 것을 탐지하여 리렌더링하여 tempF, tempC 모두 리렌더링 시킬 수 있습니다.

또한 setTempC를 호출할 경우에도 tempFahrenheit에 변경 사항을 전파하여서 상태를 업데이트 시키고, tempFahrenheit이 변경됨에 따라 tempCelcius Selector의 get을 수행시켜서 tempF, tempC 모두 리렌더링 시킬 수 있습니다.

비동기 Selector를 사용하여 비동기 작업 및 Suspense를 사용하여 데이터를 로딩하는 것도 가능합니다.
하지만 Store안에서 API Response와 전역 상태 관리 라이브러리를 같이 사용하는 경우는 드믈어서 설명을 생략하겠습니다.
비동기 처리는 다양한 상태와 캐싱 및 에러 핸들링을 제공하는 React-Query와 같은 라이브러리를 사용하는 것이 더 좋은 선택일 수 있습니다.

Redux

React가 출시한 당시엔 전역 상태를 관리하기 위한 라이브러리가 존재하지 않았습니다.
Redux는 그 당시 가장 처음으로 나온 상태 관리 라이브러리로, 복잡하다는 비판이 존재하지만 지금까지도 가장 높은 인기를 유지하고 있습니다.

우선 Redux는 리액트용이 아닌 JavaScript 상태 관리 라이브러리 입니다.
Redux 로 상태를 안정적으로 유지하기 위해선 Flux 패턴에 맞게 많은 반복적인 코드 구현이 필요한데 이를 Redux Boilerplate ( 리덕스 보일러플레이트 ) 라고 부릅니다.

Boilerplate 주요 요소

Action

  • 상태를 변화시키기 위해 발생시키는 이벤트로 type 필드를 반드시 가져야 합니다. type 에 따라서 어떤 이벤트를 발생시킬지 결정합니다.

Reducer

  • 상태가 변화하는 로직을 담당하는 함수입니다.

Dispatcher

  • 액션을 발생시키는 역할을 합니다. 액션을 생성하고, 생성된 액션을 Store로 보내 상태 변화를 요청합니다.

Store

  • 애플리케이션의 상태를 담고있는 객체입니다.

위 Boilerplate를 모두 구현하면 React에서 상태관리를 사용할 수 있습니다. 아래는 간단한 예시 입니다.
우선 액션을 정의합니다.

export const INCREMENT = "INCREMENT";
export const DECREMENT = "DECREMENT";
 
export const increment = () => ({
  type: INCREMENT,
});
 
export const decrement = () => ({
  type: DECREMENT,
});

리듀서를 작성하여 액션에 따른 상태 변화를 정의합니다.

import { INCREMENT, DECREMENT } from "./actions";
 
const initialState = {
  count: 0,
};
 
const counterReducer = (state = initialState, action) => {
  switch (action.type) {
    case INCREMENT:
      return {
        ...state,
        count: state.count + 1,
      };
    case DECREMENT:
      return {
        ...state,
        count: state.count - 1,
      };
    default:
      return state;
  }
};
 
export default counterReducer;

리듀서를 합치고 스토어를 생성합니다.

import { createStore } from "redux";
import counterReducer from "./reducers";
 
const store = createStore(counterReducer);
 
export default store;

프로바이더로 감싸서 스토어를 명시해줍니다.

import React from "react";
import { Provider } from "react-redux";
import store from "./store";
import Counter from "./Counter";
 
const App = () => {
  return (
    <Provider store={store}>
      <div className="app">
        <h1>Redux 카운터 앱</h1>
        <Counter />
      </div>
    </Provider>
  );
};
 
export default App;

위 과정을 전부 거치면 드디어 Redux로 상태관리를 할 수 있습니다.

import React from "react";
import { useSelector, useDispatch } from "react-redux";
import { increment, decrement } from "./actions";
 
const Counter = () =>
{
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();
 
  return (
    <div>
      <h1>카운터: {count}</h1>
      <button onClick={() => dispatch(increment())}>증가</button>
      <button onClick={() => dispatch(decrement())}>감소</button>
    </div>
  );
};
 
export default Counter;

useSelector를 사용하여 상태값을 가져오고, useDispatch 를 이용해서 액션을 수행시킬 수 있습니다.

React-Redux v6의 Context API 도입과 성능 최적화

Redux는 React 16.3에서 새로 도입된 createContext API를 도입하였습니다.
Redux Store State를 Context API를 통해 전파하였으나, 이는 이전 v5 대비 성능 저하를 일으켰습니다.
때문에 Redux v7 부터는 Store 내부적으로 상태를 관리할 시에만 Context API를 사용하는 방식으로 변경되었고, Store와 Component간 데이터 접근 시 React.memo를 사용하여 성능을 최적화 하였습니다.

Redux의 단점

  • Redux는 위처럼 간단 상태 변경을 위해 많은 코드를 작성해야 합니다.
  • 위는 핵심 내용만 적었을 뿐, Redux를 마스터하기 위해선 Saga, Thunk, Reselect, Immer, Redux Toolkit 등 다른 라이브러리와 함께 사용하는 방법을 익혀야 합니다.
  • TypeScript를 지원하지만, Action, Reducer, Selector에 대해 명시적으로 타입 지정이 필요합니다.

결론적으로 Learning Curve 가 상당히 높고 비용이 많이 들어서 Redux를 사용하기 위해 많은 시간을 투자해야 합니다.

Redux의 장점

하지만 그럼에도 가장 인기있는 이유는

  • 모든 앱의 상태가 중앙 집중식으로 관리되어, 상태의 변화를 예측 가능하게 만들어줍니다. 단일 상태 값은 하나의 UI만 생성하므로 특정 상태에서 일관되게 작동합니다.
  • 상태를 백업해두었다면, REVERT(pastState) { state = pastState } 와 같은 방식으로 상태를 되돌릴 수 있습니다.
  • 가장 강력한 DevTools를 제공합니다. 상태의 변화를 마치 Git commit log 처럼 확인할 수 있습니다.

Zustand

상태 관리 라이브러리의 추세는 계속 변화하고 있습니다. 이전까진 Redux가 대세였으나, 최근 동일한 Reducer-based 구조인 Zustand의 점유율이 높아지고 있습니다.

// Store 생성
interface SidebarStore {
  visible: boolean;
  toggle: () => void;
  close: () => void;
}
 
export const useSidebarStore = create<SidebarStore>((set) => ({
  visible: false,
  toggle: () => set((state) => ({ visible: !state.visible })),
  close: () => set({ visible: false }),
}));
 
// React Component에서 사용
export default function Sheet( props: PropsWithChildren ) {
  const {visible, toggle} = useSidebarStore();
    
  return (
    <>
      <Sheet open={visible} onOpenChange={toggle}>
        <SheetTrigger>
          button
        </SheetTrigger>
        <SheetContent>
          <Sidebar />
        </SheetContent>
      </Sheet>
    </>
  );
}

Zustand를 사용하기 위해 필요한 주요 코드는 위가 전부입니다.
개발자는 상태를 정의하고, 상태를 변경하는 함수를 정의하고, useStore를 통해 상태를 가져오기만 하면 됩니다.

Zustand는 Redux의 복잡한 구조를 간소화하고, Context API의 성능 문제를 해결하였습니다.

  • 중앙 집중식으로 상태를 관리합니다.
  • Redux와 달리 Boilerplate가 적어 러닝 커브가 낮고, 코드가 간결하여 쉽게 이해할 수 있습니다.
  • Redux DevTools를 지원합니다.
  • 번들 사이즈가 3.1 kb입니다. 이는 Recoil ( 79.4 kb ), react-redux ( 11.2 kb ) 보다 작습니다.
  • 컴포넌트 외부에서도 상태에 접근할 수 있습니다.
  • store의 state가 변경될 때 리렌더링됩니다. 이는 Context API와 달리 불필요한 리렌더링이 발생하지 않습니다.

Zustand 레시피

아래는 Zustand를 사용하기 전에 알아두면 좋은 팁들입니다.

불필요한 리렌더링 방지하기

Store를 구독하는 방식에 따라 불필요한 리렌더링을 방지할 수 있습니다.

// store 전체를 구독하게 되어 모든 경우에 리렌더링이 발생
const { nuts } = useBearStore()
 
// nuts만 구독하게 되어 nuts가 변경될 때만 리렌더링이 발생
const nuts = useBearStore((state) => state.nuts)
 
// 하나의 객체로 복수개의 state를 가져오려면
import { useShallow } from 'zustand/react/shallow'
 
// Object Pick
const { nuts, honey } = useBearStore(
  useShallow((state) => ({ nuts: state.nuts, honey: state.honey })),
)
 
// Array pick
const [nuts, honey] = useBearStore(
  useShallow((state) => [state.nuts, state.honey]),
)
 
// Mapped picks
const treats = useBearStore(useShallow((state) => Object.keys(state.treats)))

상태 덮어쓰기

set에는 기본적으로 false인 두번째 인자가 있습니다. true로 변경할 경우 기존 값에 merge하지 않고, 새로운 모델로 덮어쓰게 됩니다.
대신, action처럼 의존하는 부분을 지우지 않도록 주의해야 합니다.

const useFishStore = create((set) => ({
  salmon: 1,
  tuna: 2,
  deleteEverything: () => set({}, true), // clears the entire store, actions included
  deleteTuna: () => set((state) => omit(state, ['tuna']), true),
}))

Redux Devtools + Persist 묶어서 사용하기

Redux Devtools나, Persist 등의 미들웨어를 묶어서 사용할 수 있습니다.

interface BearState {
  bears: number
  increase: (by: number) => void
}
 
const useBearStore = create<BearState>()(
  devtools(
    persist(
      (set) => ({
        bears: 0,
        increase: (by) => set((state) => ({ bears: state.bears + by })),
      }),
      { name: 'bearStore' },
    ),
  ),
)
 
// 좀더 클린코드로 작성하려면
const myMiddlewares = (f) => devtools(persist(f, { name: 'bearStore' }))
 
interface BearState {
  bears: number
  increase: (by: number) => void
}
 
const useBearStore = create<BearState>()(
  myMiddlewares((set) => ({
    bears: 0,
    increase: (by) => set((state) => ({ bears: state.bears + by })),
  })),
)
 

필자의 선택

저는 각 상태 관리 라이브러리마다 장/단점이 존재한다고 생각합니다. Best Practice는 없으며, 프로젝트의 규모와 팀의 구성원에 따라 선택해야 합니다.
하지만 제가 프로젝트를 담당하게 된다면 Zustand + React Query 조합을 사용할 것입니다.

저는 DX도 프로젝트 진행에 있어서 중요하게 생각합니다. Zustand는 Redux에 비해 러닝 커브가 낮고, 코드가 간결하여 쉽게 이해할 수 있습니다.
Zustand를 사용하기 전엔 Recoil을 선호하였으나, Zustand로 변경하게된 이유는 아래와 같습니다.

  • 더이상 Key를 정의할 필요가 없습니다. 이름을 정하는 것은 머리아픈 일입니다. 심지어 Recoil의 키는 개발자가 꺼내쓸 일이 없습니다. Recoil 내부에서 Key를 랜덤으로 생성해주면 되는 일인데 왜 안해주는 걸까요? Recoil 팀의 답변이 존재하나 제 의견은 글쎼? 입니다.
  • Next.js와 사용할 경우 Hydration 과정으로 인해 "중복 Key 발생" Warning이 발생합니다. 중요 3rd-party 라이브러리와 호환성을 맞춰주지 않는 것은 큰 단점입니다.
  • Recoil의 JS 번들 사이즈는 지나치게 큽니다.

참조