상태관리, 잘하고 싶다.

상태관리, 잘하고 싶다.

새로운 프로젝트에 들어가기 전, 상태관리 라이브러리 선택을 위해 Jotai, Zustand, Recoil 에 대해 공부하고 비교를 해봤습니다.

·

10 min read


이번에 새로 들어가는 프로젝트에서, 새로운 상태관리 라이브러리 도입을 하는 것으로 결정이 되어서 여러가지 이야기가 나왔는데 연휴 간 서치해보고 장단점을 충분히 고려해보기로 했다. 이번에 후보로 올라온 Recoil, Jotai, Zustand 에 대해 간단하게 알아보고 결정을 해보려고 한다.

🤚🏻 들어가기 전에,

프론트엔드 개발을 시작할 때, 정말 존경하던 기술 매니저님께서 “잘 하는 프론트엔드는, 상태관리를 잘한다는 의미입니다.”라고 말씀해주셨던 것이 아직도 기억이 난다. 그만큼 프론트엔드 개발에 있어서 상태관리는 중요한 영역 중 하나이다.

FE 에 개발에 있어서 상태를 관리하는 방법은 여러가지 방법이 있다.

먼저, 상태관리 라이브러리를 사용하지 않고 직접 state 를 관리하는 방법이 있다. useState 훅을 사용해서 해당 state 가 선언된 지역 내에서 상태관리를 할 수 있으며 주로 작은 프로젝트에서 사용이 된다. 만약, 규모가 커지거나 컴포넌트의 깊이가 깊어진다면 state 를 공유하는 컴포넌트의 깊이도 깊어질 수 있으며, 이는 곧 props drilling 으로 이어질 수도 있고 컴포넌트 간의 의존도가 커지거나 state 추적이 어려울 수도 있다.

React 자체적으로 제공하는 Context API 를 사용하는 방법도 있다. 이는 React 팀에서 공식적으로 출시한 상태관리 API 이며, 사용이 굉장히 간편하다.

마지막으로 외부 상태관리 라이브러리를 사용하는 방법이 있다. React 에서는 주로 react-redux 를 많이 사용해왔고, 이 외에는 mobx, recoil, jotai, zustand 등 많은 종류가 있다.


npm trend

현재 많이 사용되고 있는 라이브러리의 npm trend 를 살펴보면 위와 같은 추이를 확인할 수 있다. 각각의 라이브러리마다 많은 차이가 보이는데, redux 와 같이 라이브러리가 개발된 시기가 오래되었을수록 더 오래 사용해왔고 그만큼 숙련도가 있기 때문에 꾸준하게 사용되고 있는 점이 눈에 띈다.


React 에 대해 먼저 이해를 해보자.

React 를 처음 공부할 때, 가장 먼저 확인할 수 있었던 키워드 중 하나는 “단방향 바인딩” 이었다. 즉, React 에서는 데이터가 단방향 - 부모에서 자식 컴포넌트로 내려가는 방식으로 전달된다. 이 패턴은 Flux 패턴에 의해 적용된 방식이다.

Flux Pattern ?

React 공식문서나 벨로퍼트님의 글을 보면, “리액트는 자바스크립트 라이브러리로 사용자 인터페이스를 만드는 데 사용합니다. 구조가 MVC, MVW 등인 프레인워크와 달리, 오직 V(View) 만 신경쓰는 라이브러리 입니다.” 라는 문구를 확인할 수 있다. 그럼 리액트는 어떤 패턴을 추구할까?

단방향 바인딩의 특성을 가진 리액트는 Flux 패턴을 추구한다. 위 그림과 같이 View 에서 어떠한 액션이 일어난다면, reducer 에 의해 store 에서 state 가 변경이 되고 View 에 다시 반영이 되는 방식이다. 그럼 이 패턴을 생각하면서 상태관리 라이브러리들을 살펴보자. 가장 React 에 어울릴만한 녀석은 어떤 친구일까? 그 중에서도 내가 곧 착수하게될 프로젝트와 맞는 것은 어떤 것일까?

물론 Flux 패턴을 적용한 상태관리 라이브러리로 인지도가 높은 Redux 가 있지만, 보일러 플레이트가 상당하고 코드가 복잡해져서 빠르게 달려야하는 우리 프로젝트의 성격에는 맞지 않다는 판단이 있었기에 과감하게 생략하고 recoil, zustand, jotai 세 가지만 비교 하고자 한다.

디자인 패턴에 대해서 더 자세히 알아보려면, 아래 포스트를 참고하자.

https://leetrue-log.vercel.app/react-design-pattern


Flux vs Atomic

  • Flux Pattern : Redux, Zustand

  • Atomic Pattern : Recoil, Jotai

Flux 패턴은 일반적으로 action 을 통해 상태 변화가 일어나고, 컴포넌트에서는 selector 를 사용해 전역 상태의 일부를 구독(sub)하는 형태로 돌아간다.

Atomic 패턴 같은 경우에는 React 의 state 를 생각하면 좋은데, 리액트 트리 안에서 상태를 저장하고 관리하는 방법이다.


⚛️ Recoil

Recoil 은 페이스북 팀에서 새롭게 만든 라이브러리이다. 이 친구는 React 전용으로 제작된 라이브러리이며 그만큼 React에 최적화 되어있다.

Recoil은 React 전용인만큼 React 내부의 접근성이 용이하다. 특히, React 동시성 모드나 Suspense 등을 지원해서 유저 경험적인 관점에서도 매우 유리하다.

특히나 전역 상태에 대한 설정과 정의가 정말 쉽고, 라이브러리에서 지원하는 hooks 를 통해 데이터를 가져오므로 React 문법과 사용법이 매우 유사하다. Redux 에서 주요 문제점으로 이야기되는 보일러 플레이트의 양히 현저히 감소되었으며, 사용 방법도 굉장히 쉬워서 러닝 커브가 낮은 편이다.

Recoil 공식 문서에 따르면, Recoil 의 출시 컨셉은 아래와 같다.

  • Boilerplage-free API 제공

  • Concurrent Mode(동시성 모드)를 비롯한 새로운 React 기능들과의 호환 가능성

  • Code Splitting - 상태 정의에 대한 증분 및 분산 가능

  • 상태에서 파생된 데이터 사용

  • 파생된 데이터에 대한 동기/비동기 모두 가능

  • 캐싱

Recoil 은 atom(공유하게 될 state)에서부터 selector(순수 함수)를 통해 컴포넌트까지 아래로 흘러가는 데이터 플로우 그래프를 만든다. atom 은 컴포넌트들이 구독할 수 있는 상태의 단위를 의미하며, selector 는 상태를 동기/비동기적으로 변경시킬 수 있는 순수 함수를 의미한다.

install Recoil

npm install recoil
# or
yarn add recoil

Atoms

Atoms 는 Recoil 상태의 단위이다. 컴포넌트 사이에서 이 상태는 공유가 가능하며, 구독 및 업데이트도 가능하다. 만약 atom 의 상태가 업데이트 된다면, 이를 구독하고 있는 컴포넌트들은 모두 리렌더링 된다.

// src > recoil > index.ts
import { atom } from 'recoil';

type PokemonStateType = string[];
export const PokemonState = atom<PokemonStateType>({
    key: 'pokemon',
    default: ["이상해씨"]
});
  • key : 고유 key 값으로 해당 atom 생성에 대한 변수 명으로 지정한다.

    • key 는 중첩되어서는 안된다.
  • default : atom 의 initialState

Selector

Atoms 또는 다른 Selector 상태를 입력받아서 동적 데이터를 반환하는 순수 함수이다. 상태값에서 파생된 데이터를 생성할 때 사용되며, atom 과 같이 컴포넌트에서는 이를 구독할 수 있다. 단, 이는 readonly value 이다.

만약 Selector 가 참조하던 다른 상태가 변경된다면, 이것도 함께 업데이트가 되어 Selector 를 바라보던 컴포넌트들은 모두 리렌더링된다.

// src > recoil > index.ts
import { selector } from 'recoil';

type PokemonStateType = string[];
export const PokemonState = atom<PokemonStateType>({
    key: 'pokemon',
    default: ["이상해씨"]
});

export const PokemonTotalCountState = selector<PokemonStateType>({
    key: 'pokemonCount',
    get: ({ get }) => {
        const total = get(PokemonState)?.length;
        return total;
    }
});

hooks

전역 상태에 대해 get / set 을 하기 위해서는 recoil 에서 자체적으로 제공하는 훅을 사용할 수 있다.

useRecoilState()

useState() 와 유사하며, 인자에는 Atoms 또는 Selector 를 넣는다.

import { PokemonState } from '../recoil/index';

...
const [pokemon, setPokemon] = useRecoilState(PokemonState);
...

useRecoilValue()

전역상태의 state 상태값을 참조하기 위해 사용되는 훅으로 선언된 변수에 할당해 사용한다.

import { PokemonState } from '../recoil/index';

...
const pokemonArray = useRecoilValue(PokemonState);
console.log(pokemonArray);
...

useSetRecoilState()

전역상태의 setter 함수 활용에 사용된다.

import { PokemonState } from '../recoil/index';

...
const setPokemonArray = useSetRecoilState(PokemonState);
const addPokemon = (pokemon) => {
    setPokemonArray([...pokemon]);
};
...

useResetRecoilState**()

전역상태를 초깃값으로 돌리는 데에 사용된다.

import { PokemonState } from '../recoil/index';

...
const resetPokemon = useResetRecoilState(PokemonState);
const cleanUpPokemon = (pokemon) => {
    resetPokemon();
};
...

👻 Jotai

Primitive and Flexible state management for React “

Jotai 는 Conext 의 리렌더링 문제 해결을 위해 만들어진 React 에 특화된 상태관리 라이브러리로 recoil 에서 영감을 받아 제작되었다. 그렇기 때문에 recoil 의 대표적 특징 중 하나인 atomic 한 패턴의 상태관리를 사용한다. (bottom-up)

😀 Jotai 의 장점을 꼽으라면 아래와 같다.

  • Primitive : 리액트의 useState 와 유사한 인터페이스

  • Flexible : atom 사이에서 서로 결합 및 상태에 관여가 가능하며 다른 라이브러리들과의 원활한 결합을 지원

  • 리렌더링 문제의 최적화 및 최적화를 위한 유틸 지원 (selectAtom, splitAtom)

  • 보일러 플레이트의 감소

  • React 주요 feature 가 될 Suspense 적용에 적합하게 설계

👻 Jotai 와 Recoil ⚛️

Jotai는 Recoil 과 조금 더 비교가 필요할 것이라는 생각이 들었다. 비슷한 atomic pattern 이라면, 이 둘 사이에서의 차이점은 어떤 것들이 있고 어떤걸 고려하면 좋은지에 대한 지표가 필요했다.

Jotai(830kb) 는 Recoil(2.17mb) 의 번들 사이즈에 비해 1/6 정도의 사이즈를 가지고 있다. 또한 Recoil에서 사용되는 key 를 따로 설정해주지 않아도 되는 점은 Recoil 에 비해 어느정도 코드가 줄어드는 장점이 될 수 있을 것 같다.

무엇보다도 Jotai 는 리렌더링에 대한 최적화가 큰데, 원래 대용량 객체나 배열 형태의 state에서 변경사항이 생기면 일반적으로 해당 상태를 사용하는 모든 컴포넌트에서 리렌더링이 발생하지만 이러한 문제점을 optics 라는 외부 라이브러리를 사용해 해결했다고 한다.

다만 타 라이브러리들에 비해 참고할 만한 소스들이 많지 않은게 흠이라면 흠일 수 있겠다.

install Jotai

npm install jotai
# or
yarn add jotai

how to use Jotai

Jotai 사용을 간단하게 정리하면 뭔가 “ atom 생성 & useState 처럼 사용 ” 정도로 이야기할 수 있겠다. 물론 추가적인 유틸이나 기능적인 부분들을 더 공부하면 내용이 많겠지만..!

import { atom } from "jotai";

export const todoAtom = atom<TodoType[]>([
  {
    title: "할 일",
    contents: "내용 내용",
    id: 0,
  },
]);
const [todoList, setTodoList] = useAtom(todoAtom);

  const onAddTodo = () => {
    setTodoList([...todoList, { title, contents, id: Math.random() }]);
  };

🐻 Zustand

zustand 는 공식문서에서 그에 대해 이렇게 소개한다. 단순화된 Flux 패턴을 사용하는 작고 빠르고 확장가능한 상태관리 솔루션이며, Hooks 를 기반으로하는 간편한 API가 있다.

zustand 는 독일어로 ‘상태’라는 뜻을 가졌으며, Jotai 를 만든 카토 다이시가 제작에 참여하고, 적극적으로 관리되고 있는 라이브러리이다.

🥳 zustand 는 아래와 같은 특징을 가졌다.

  • 특정 라이브러리에 엮이지 않음

  • 하나의 중앙에 집중된 스토어 구조를 활용, 상태를 정의하고 사용하는 방법이 단순

  • Context API 와는 다르게, 상태 변경 시 불필요한 리렌더링을 일으키지 않도록 할 수 있음

  • 자주 바뀌는 상태에 대해 직접 제어할 수 있는 방법을 제공 (Transient Update)

  • 핵심 로직 코드 수가 적어, 동작을 위해 알아야하는 코드 양이 굉장이 적은 편

🔎 zustand 동작 원리

Flux pattern

zustand 는 Flux 패턴을 사용한다.

그렇기 때문에 각각의 요소들은 단방향 흐름에 따라 순서대로 그 역할을 수행하며, 결과적으로 예외 없이 데이터 처리가 가능하다.

Sub / Pub Model

zustand 는 기본적으로 sub/pub model 기반으로 이루어졌다. 그래서 스토어에서 상태 변경이 일어날 때 실행시킬 listener 를 구독(sub) 해두었다가 상태 변경시 등록된 listener 에게 상태 변경에 대해 알려줘서(pub) 화면을 렌더링 시켜준다.

클로저

zustand 는 스토어 생성 함수를 호출할 때 클로저를 활용한다. 클로저는 함수와 그 함수가 선언될 당시의 lexcial environment을 기억하는 것으로, 스토어의 상태는 스토어 조회나 변경을 해주는 함수 외부 스코프에서 항상 유지되도록 만들어졌다.

그렇게 되면, 상태의 변경, 조회, 구동 등을 통해서만 스토어를 다루고 실제 상태는 애플리케이션 생명주기 동안 의도치 않게 변경되는 것을 방지시킬 수 있다.

install zustand

npm install zustand
# or
yarn add zustand

how to use

1. 스토어 만들기

redux 의 스토어를 만드는 것 처럼, .ts 파일을 하나 만들어서 관리하고자 하는 state 의 스토어를 만들어준다. 보일러 플레이트는 아래 코드를 보면 알 수 있듯이 매우 적다.

interface TodoInterface {
  state: TodoType[];
  setState: (newState: TodoType[]) => void;
}

const initialState = [
  {
    id: 0,
    title: "투두리스트",
    contents: "오늘 이거 끝내야지",
  },
];

export const zustandTodo = create<TodoInterface>((set) => ({
  state: initialState,
  setState: (newState: TodoType[]) => set({ state: newState }),
}));

위에서 작성했던 코드를 응용하면 아래와 같은 방식으로도 작성해볼 수 있다. 상태를 내가 원하는 방향대로 변경할 수 있는 함수를 만들어줄 뿐이라 어려울 것은 없다.

export const zustandTodo = create<TodoInterface>((set) => ({
  state: initialState,
  addTodo: (newTodo: TodoType) =>
    set((prev) => ({
      state: [...prev.state, newTodo],
    })),
  deleteTodo: (id: number) =>
    set((prev) => ({
      state: [...prev.state.filter((value) => value.id !== id)],
    })),
}));

2. 사용하기

스토어를 생성하면서 만들었던 함수 또는 훅을 컴포넌트에서 호출해, 필요한 것들을 비구조화해서 가져와 원하는 곳에 사용하면 된다. 스토어 내에서 상태를 변경시켜주는 로직에 대한 선언이 가능해서, 컴포넌트 내에 불필요한 로직이 들어가지 않아도 되는 점이 너무 좋았다.

const ZustandlList = () => {
    /* 필요한 것들은 아래와 같이 비구조화로 불러와서 사용하면 된다. */
  const { state } = zustandTodo();

  return (
    <Space>
      <Space>{/* <p>{todoCount}</p> */}</Space>
      {state?.map((value, index) => {
        return (
          <TodoCard
            key={`recoil-${value.id}-${index}`}
            title={value.title}
            contents={value.contents}
            id={value.id}
          />
        );
      })}
    </Space>
  );
};
const TodoCard = ({ title, contents, id }: TodoCardInterface) => {
  const { deleteTodo } = zustandTodo();

  return (
    <Card
      title={title}
      extra={<Button onClick={() => deleteTodo(id)}>삭제</Button>}
    >
      <p>{contents}</p>
    </Card>
  );
};

🤔 recoil vs jotai vs zustand

⚛ recoil

atom 단위의 상태관리가 가능한, bottom-up 방식을 사용한다. 최소단위의 atom 생성을 위해서 key 를 사용하며, Provider 가 필요하다.

👻 jotai

recoil 에 영향을 받아 만들어진 라이브러리로, recoil과 마찬가지로 atom 단위의 bottom-up 방식 상태관리를 사용한다. 다만, recoil 과는 다르게 atom key 가 필요 없다. 리액트의 useState 와 유사하게 사용이 가능하며 일반적으로 Provider 가 필요하다.

🐻 zustand

사용하면서 가장 크게 느꼈던 점은 보일러 플레이트가 거의 없는 redux 느낌의 상태관리 라이브러리다. 그 때문에 redux 사용에 익숙한 나에게는 사용이 편리했다. 스토어 형태를 가졌으며, Provider 는 따로 필요없다. 그렇기 때문에 앱이 래핑되지 않으므로 불필요한 리렌더링이 최소화된다.


👩🏻‍🌾 그래서 어떤게 좋을까?

나도 공부를 하면서도 어떤 아이가 우리가 활용하고자하는 방향에 맞을지, 그리고 어떤 점이 편한지에 대해 느껴보고 싶어서 간단하게 나마 Create, Delete 만 들어간 또두리스트를 종류별로 만들어봤다. (기능적인 부분만 보고 싶어서 따로 UI를 만지진 않았다.)

각각의 라이브러리를 사용해 투두리스트를 간단히 만들어본 개인적인 소감으로는 recoil, jotai 보다 어느정도의 보일러 플레이트가 있을 수는 있지만 그 양이 아주 많지도 않아서 큰 차이를 느끼지 못했던 🐻 zustand 가 훨씬 사용하기 편하다고 느껴졌다. 팀에 신입분들이 후에 배치될 것을 고려하면 redux 에 익숙한 분들이 많을 것으로 판단이 되어 훨씬 쉽게 온보딩이 될 수도 있겠다.

또한 지금 만들고자 하는 프로젝트가 하나의 리포지토리에서 여러 페이지를 바라보고 각각 배포를 하는 방식으로 작업될 예정이라 규모가 엄청 커질 예정이 이, 런 규모가 큰 프로젝트라면 🐻 zustand더욱 맞겠다는 생각이 든다. 큰 틀을 잡고 하나씩 작은 모델을 만들어나가며 각 배포 라인에 따라 관리가 되는 것이 아토믹 패턴 보다는 맞겠다는 판단이다.

물론 이렇게 공부한 부분은 다음주 출근하면 미팅 때 말씀 드리고 함께 결정할 부분이겠지만 🙂


참고