요즘IT
위시켓
최근 검색어
전체 삭제
최근 검색어가 없습니다.

웹 프론트엔드 개발자라면 한 번쯤 상태 관리 라이브러리에 대해 들어봤을 것이다. 리액트 생태계에서는 몇 년 전까지만 해도 리덕스(redux, react-redux)가 압도적으로 많이 쓰였다. 그 이후로는 몹엑스(MobX), 리코일(Recoil)도 많이 쓰였다가, 다시 2023년 기준으로는 주스탠드(Zustand) 사용자가 빠르게 늘고 있다. 이외 조타이(Jotai), 발티오(Valtio) 등 다양한 시도를 하는 라이브러리도 등장했다.

회원가입을 하면 원하는 문장을
저장할 수 있어요!

다음

회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!

확인

개발

리액트 상태 관리 라이브러리, 어떤 것을 써야 할까?

년차,
어떤 스킬
,
어떤 직무
독자들이 봤을까요?
어떤 독자들이 봤는지 궁금하다면?
로그인

웹 프론트엔드 개발자라면 한 번쯤 상태 관리 라이브러리에 대해 들어봤을 것이다. 리액트 생태계에서는 몇 년 전까지만 해도 리덕스(redux, react-redux)가 압도적으로 많이 쓰였다. 그 이후로는 몹엑스(MobX), 리코일(Recoil)도 많이 쓰였다가, 다시 2023년 기준으로는 주스탠드(Zustand) 사용자가 빠르게 늘고 있다. 이외 조타이(Jotai), 발티오(Valtio) 등 다양한 시도를 하는 라이브러리도 등장했다.

 

이미 쓰고 있는 라이브러리를 유지하는 것도 좋지만, 현재 어떤 도구들이 나오고 있는지 꾸준히 관심을 가지는 것도 중요하다. 이번 글에서는 비교적 최근에 등장한 리액트 상태 관리 라이브러리 4가지를 살펴보고자 한다.

 
리액트 상태 관리 라이브러리
최근 1년간 리액트 상태 관리 라이브러리별 npm 다운로드 숫자 통계 <출처: npm trends>

 

상태 관리 라이브러리 살펴보기

1) 리코일(Recoil)

리코일(Recoil)은 2020년 페이스북 팀의 한 엔지니어가 실험 단계로 컨퍼런스에서 공개하면서 세상에 알려지게 되었다. 기존에 리덕스 등 전역 상태 관리 도구는 리액트 라이브러리가 아니라, 리액트 내부 스케줄러에 접근할 수 없었다. 그래서 동시성 모드(Concurrent mode)가 등장했을 때 사용이 어려워지는 문제를 해결하기 위해 리코일이 만들어졌다.

 

그리고 기존의 리덕스와 같은 라이브러리는 기본적인 스토어(store) 구성을 위해서 많은 보일러 플레이트와 장황한 코드를 작성해야 했다. 이러한 러닝 커브를 낮춰주기 위해 쉽고 직관적인 라이브러리 리코일을 설계하게 된 것이다.

 

리코일의 주요 특징은 다음과 같다.

  • 리액트 문법 친화적이다. 리액트의 상태처럼 간단한 get/set 인터페이스로 사용할 수 있는 보일러 플레이트가 없는 API를 제공한다.
  • 비동기 처리를 추가적인 라이브러리 없이(e.g. redux-thunk, redux-saga) 리코일 안에서 가능하다.
  • 내부적으로 캐싱을 지원한다. 동일한 atom 값에 대한 내부적으로 메모이제이션된 값을 반환하여 속도가 빠르다.

 

기존에 리액트의 컨텍스트나 상태의 경우 Provider, 컴포넌트로 중첩해서 쌓으면 가장 아래에 해당 상태를 전달하기 위해 많은 관문을 거쳐야 했다. 이는 많은 리렌더링을 야기했고, 이 방식을 페이스북에서는 커플링이라고 문제를 인식했다. 이를 해결하기 위해 우리가 일반적으로 트리 형태로 컨텍스트나 상태를 관리할 때 2차원에서 생각했다면, 3차원에 직교해 존재하는 ‘아톰(Atoms)’이라는 개념을 고안했다.

 

리액트 상태 관리 라이브러리
Atoms의 개념 <출처: Recoil 공식 문서>

 

아톰(Atoms)은 상태 단위이며, 업데이트와 구독이 가능하다. 아톰이 업데이트됐을 때 해당 아톰을 구독하고 있는 컴포넌트들은 리렌더링이 일어나게 된다. 아톰은 다음과 같이 사용할 수 있다. 모든 아톰은 전역적으로 고유의 키값을 가져야 하고, 이 키값을 가지고 컴포넌트에서 useRecoilState로 불러올 수 있다.

 

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

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

 

리코일에는 셀렉터(Selectors)라는 개념이 있다. 셀렉터는 기본적으로 함수인데, 특징이 있다면 아톰이나 다른 셀렉터를 입력으로 받는 순수 함수라는 것이다. 입력으로 받는 아톰, 셀렉터가 업데이트되면 해당 셀렉터 함수도 업데이트가 된다. 그리고 이 셀렉터를 구독하고 있는 컴포넌트가 있다면 리렌더링이 일어난다.

 

폰트 사이즈에 의존하는 폰트 사이즈 라벨 셀렉터 예제를 살펴보자.

 

const fontSizeLabelState = selector({
  key: 'fontSizeLabelState',
  get: ({get}) => {
    const fontSize = get(fontSizeState);
    const unit = 'px';

    return `${fontSize}${unit}`;
  },
});

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  const fontSizeLabel = useRecoilValue(fontSizeLabelState);

  return (
    <>
      <div>Current font size: ${fontSizeLabel}</div>

      <button onClick={setFontSize(fontSize + 1)} style={{fontSize}}>
        Click to Enlarge
      </button>
    </>
  );
}

 

셀렉터에서는 get 인자를 통해 아톰, 다른 셀렉터를 접근할 수 있다. 이렇게 접근을 하면 종속 관계가 생기며, 참조한 아톰, 다른 셀렉터가 업데이트되면 이 함수도 다시 실행된다.

 

Selectors는 useRecoilValue()를 사용해서 값을 읽을 수 있다. 위의 예제에서 fontSizeLabelState는 writable하지 않기 때문에, useRecoilState()를 사용하지는 않는다.

 

또한 리코일은 비동기 함수도 리액트 컴포넌트 렌더 함수에서 사용하기 쉽게 해준다. Selectors의 데이터 그래프에서 동기, 비동기 함수들을 균일하게 혼합해 주며, 값이 아닌 프로미스(Promise)를 리턴하면 인터페이스도 정확하게 유지된다.

 

아래 예제는 DB에서 사용자 이름을 쿼리해야 하는 경우에 대한 셀렉터 함수다. 해당 예제 함수에서는 currentUserIDState에서만 의존성을 가지고 있음을 알 수 있는데, 이렇게 의존성에 변경이 생기면 Selectors는 새로운 쿼리를 다시 실행한다. 그리고 결과는 쿼리와 유니크한 인풋이 있을 때만 실행되도록 캐시된다.

 

const currentUserNameQuery = selector({
  key: 'CurrentUserName',
  get: async ({get}) => {
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    return response.name;
  },
});

function CurrentUserInfo() {
  const userName = useRecoilValue(currentUserNameQuery);
  return <div>{userName}</div>;
}

 

그리고 리코일은 비동기 처리 시 프로미스가 resolve 되기 전 보류 중인 경우, 리액트 서스펜스(Suspense)와 함께 동작하도록 디자인되어 있다.

 

그렇다면 리코일의 단점은 뭘까? 전역 상태를 아톰으로 관리하고 어느 컴포넌트에서도 바로 아톰을 구독해서 업데이트를 받다 보니, 이 아톰이 여러 군데에서 사용되면 사이드 이펙트가 발생할 수 있다.

 

간단한 구조라면 어떤 의존이 이루어지는지 파악하기 어렵지 않지만, 아톰과 셀렉터가 많아지면 의존성이 여러 방향으로 엮이면서 점점 예측이 어려워진다. 리코일을 도입하면서 이러한 이슈가 있다면, 컴포넌트를 컨테이너/프레젠테이션 방식으로 나누어 상태를 관리하는 부분/보여주기만 별도로 가져가는 방법도 고민해 볼 수 있다. 그리고 커스텀 훅(custom hook)으로 복잡한 비즈니스 로직을 처리해서 컨테이너 컴포넌트를 무겁지 않게 만들 수도 있다.

 

 

2) 주스탠드(Zustand)

리액트 상태 관리 라이브러리
<출처: zustand 공식 문서>

 

주스탠드(zustand)는 독일어로 ‘상태’라는 뜻을 가졌고, 간결한 플럭스(Flux) 원칙을 바탕으로 작고 빠르게 확장 가능한 상태 관리 라이브러리다. 조타이(Jotai)를 만든 카토 다이시가 주스탠드도 만들어 관리하고 있다. 주스탠드는 특정 라이브러리에 종속되어 만들어진 도구는 아니므로 바닐라 자바스크립트에서도 사용이 가능하다.

 

주스탠드는 발행/구독 모델(pub/sub)을 기반으로 이루어져 있다. 스토어의 상태 변경이 일어날 때 실행할 리스너 함수를 모아 두었다가(sub), 상태가 변경되었을 때 등록된 리스너에게 상태가 변경되었다고 알려준다(pub).

 

그리고 스토어를 생성하는 함수 호출 시 클로저를 사용한다. 이로 인한 특징으로 상태를 변경, 조회, 구독하는 인터페이스를 통해서만 상태를 다루고, 실제 상태는 생명 주기에 따라 처음부터 끝까지 의도하지 않는 변경에 대해 막을 수 있다는 점이 있다.

 

주스탠드 사용법은 정말 간단하다. 먼저 스토어를 만들고 그 안에 원시 타입, 객체, 함수 등을 넣는다.

 

import { create } from 'zustand'

const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

 

그다음 그걸 컴포넌트와 바인딩 하면 끝이다. 이때는 useBearStore()라는 API를 사용한다.

function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <h1>{bears} around here ...</h1>
}

function Controls() {
  const increasePopulation = useBearStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

 

확실히 러닝 커브가 낮은 점이 큰 장점이다. 컨텍스트처럼 Provider 같은 것으로 감싸줄 필요도 없다. 그리고 컨텍스트와 달리 오직 변화가 일어나는 경우에만 리렌더링이 일어난다.

 

리코일과 비교해 보면, 리코일에서는 setXXX 형태로 아톰을 바꿔주는 로직을 보통 컴포넌트 안에서 해주거나 커스텀 훅으로 빼서 해주는데, 주스탠드는 스토어에서 바로 할 수 있어 편리해 보인다. 이외 다양한 미들웨어도 지원한다. 예를 들어, immer를 주스탠드 차원에서도 미들웨어로 쓸 수 있다. 복잡한 객체의 업데이트를 비교적 깔끔하게 처리할 수 있는 것이다.

 

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

const useBeeStore = create(
  immer((set) => ({
    bees: 0,
    addBees: (by) =>
      set((state) => {
        state.bees += by
      }),
  }))
)

 

또한 persist라는 미들웨어도 지원하는데, 스토리지에 데이터를 저장할 수 있는 기능을 다음과 같이 간단하게 사용할 수 있다.

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

const useFishStore = create(
  persist(
    (set, get) => ({
      fishes: 0,
      addAFish: () => set({ fishes: get().fishes + 1 }),
    }),
    {
      name: 'food-storage', // unique name
      storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used
    }
  )
)

 

주스탠드의 다른 특징으로 일시적 업데이트(Transient Update)가 있는데, 상태가 자주 바뀌더라도 매번 업데이트가 일어나지 않고 리렌더링을 제어할 수 있는 기능이다. 리액트에 종속되지 않은 도구여서 가능한 점이다.

 

const useScratchStore = create(set => ({ scratches: 0, ... }))

const Component = () => {
  // 처음 상태를 패치한다
  const scratchRef = useRef(useScratchStore.getState().scratches)
  // 마운트 시 스토어에 연결, 언마운트 시 스토어에 연결 해제, 참조에서 상태 변화 감지
  useEffect(() => useScratchStore.subscribe(
    state => (scratchRef.current = state.scratches)
  ), [])
  ...

 

주스탠드는 설계적으로 탑다운 방식으로 전역 상태를 접근하기 때문에, 전체적인 오버뷰에서 디테일 세부사항으로 스토어 모델링을 하는 것이 좋다. 예를 들어, 블로그를 위한 스토어를 만든다고 하면 블로그 > 포스트 > 작가, 제목, 내용 이런 식으로 말이다.

 

이렇게 강력한 장점을 가진 주스탠드의 단점은 무엇일까? 일단 성능이 중요한 앱에서 주스탠드와 같은 탑다운 방식은 적합하지 않다. 그리고 비교적 생긴지 얼마 되지 않은 도구이기 때문에, IDE 등에서 쓸 수 있는 익스텐션, 플러그인, 스니펫 등이 많이 없다는 점도 단점이다. 공식 문서도 깃헙 리드미 정도만 있는데, 다른 도구들처럼 충분한 설명이 레퍼런스로 있으면 더욱 좋을 것 같다.

 

 

3) 조타이(Jotai) 

리액트 상태 관리 라이브러리
<출처: Jotai 공식 문서>

 

조타이(Jotai)는 아토믹 접근(Atomic Approach)을 가지고 만든 리액트 상태 관리 도구다. 아무래도 비슷한 패턴을 사용하는 리코일과 비교된다. 조타이의 가장 기본적인 단위는 아톰(Atom)이다. 원시 타입과 객체 타입을 담을 수 있고, 다른 아톰에서 값을 가져와서 만드는 것도 가능하다.

 

import { atom } from 'jotai'
const countAtom = atom(0)
const countryAtom = atom('Japan')
const citiesAtom = atom(['Tokyo', 'Kyoto', 'Osaka'])
const animeAtom = atom([
 {
   title: 'Ghost in the Shell',
   year: 1995,
   watched: true
 },
 {
   title: 'Serial Experiments Lain',
   year: 1998,
   watched: false
 }
])

const progressAtom = atom((get) => {
 const anime = get(animeAtom)
 return anime.filter((item) => item.watched).length / anime.length
})

 

이렇게 만든 아톰을 useState와 비슷하게 useAtom으로 컴포넌트에서 사용할 수 있다. 전반적으로 러닝 커브가 굉장히 낮다는 느낌이 들었다.

 

const readOnlyAtom = atom((get) => get(priceAtom) * 2)
const writeOnlyAtom = atom(
  null, // 첫번째 인자로 전달하는 초기값은 null로 전달
  (get, set, update) => {
    // update는 atom을 업데이트하기 위해 받아오는 값
    set(priceAtom, get(priceAtom) - update.discount)
  }
)
const readWriteAtom = atom(
  (get) => get(priceAtom) * 2,
  (get, set, newPrice) => {
    set(priceAtom, newPrice / 2)
    // set 로직은 원하는 만큼 지정할 수 있다.
  }
)

 

조타이의 아톰은 세 가지 케이스로 나눠볼 수 있다. 1) Read-only 2) Write-only 3) Read-Write. atom()에서 첫 번째 인자로 read할 값을, 두 번째 인자로 write하는 함수를 작성할 수 있다.

 

조타이도 여러 가지 유틸을 지원하는데 로컬스토리지나 세션스토리지에 저장된 값을 생성하는 atomWithStorage가 대표적이며, SSR을 지원하는 useHydrateAtoms 같은 유틸도 있다.

 

import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

const darkModeAtom = atomWithStorage('darkMode', false)

const Page = () => {
 const [darkMode, setDarkMode] = useAtom(darkModeAtom)

 return (
   <>
     <h1>Welcome to {darkMode ? 'dark' : 'light'} mode!</h1>
     <button onClick={() => setDarkMode(!darkMode)}>toggle theme</button>
   </>
 )
}

 

조타이는 주스탠드와는 반대로 바텀업 방식으로 설계되어 있다. 처음에 아톰을 정의하고, 그것을 차곡차곡 큰 조각의 상태들로 만들어 나간다. 이러한 바텀업 방식은 성능이 중요한 앱에서 많이 사용된다.

 

조타이는 기본적으로 리코일에서 영감을 받기도 했고, 두 라이브러리 모두 아토믹 접근 방식으로 만들어졌기 때문에 비교가 많이 되는 편이다. 조타이의 장점은 리렌더링을 줄여주는 selectAtom이나 splitAtom과 같은 유틸에 대한 지원이 많다는 점, 그리고 서스펜스(Suspense)를 적용하는 부분에서 적합하게 설계가 되었다는 점이다. 다만 아직 다른 경쟁 도구들에 비해 사용자 수가 많지 않고, 레퍼런스가 부족하다는 점이 아쉽다.

 

 

4) 발티오(Valtio)

리액트 상태 관리 라이브러리
<출처: Valtio 공식 문서>

 

발티오는 프록시(proxy) 기반의 리액트 상태 관리 라이브러리이다. 프록시는 보통 프록시 서버로 많이들 알고 있는 용어로 두 서버 간에 통신을 주고받을 때, 그 사이에서 통신을 중계하는 대리인 역할을 수행한다. 발티오도 이러한 중계를 상태 관리에서 한다고 이해하면 좋다.

 

발티오는 처음에 상태를 proxy라는 API로 객체를 만들어서 초기 선언해 준다. 그다음 어디에서든지 그 상태를 자유자재로 핸들링 할 수 있다.

 

import { proxy, useSnapshot } from 'valtio'
const state = proxy({ count: 0, text: 'hello' })
setInterval(() => {
  ++state.count
}, 1000)

 

이러한 변화를 감지하고 싶을 때는 useSnapshot을 사용하면 된다. 해당 프록시 상태의 스냅샷을 렌더 함수에서 읽을 수 있게 해준다. 상태가 바뀌는 경우에만 컴포넌트에서 리렌더링이 일어난다.

 

// state.count의 변화에서는 리렌더링이 일어나지만, state.text 변화에서는 리렌더링이 일어나지 않는다.
function Counter() {
  const snap = useSnapshot(state)
  return (
    <div>
      {snap.count}
      <button onClick={() => ++state.count}>+1</button>
    </div>
  )
}

 

그리고 subscribe를 통해 컴포넌트 바깥에서도 상태 변화를 구독할 수 있다.

 

// 모든 상태 변화를 구독한다.
import { subscribe } from 'valtio'

const unsubscribe = subscribe(state, () =>
  console.log('state has changed to', state)
)
// 결과를 호출함으로서 구독을 해지한다.
unsubscribe()

 

발티오는 반응형(reactive) 프로그래밍에 익숙하거나 흥미가 있는 사람에게 적합할 것 같다. 직관적으로 반응형 데이터를 만들어 줄 수 있고, 변화되는 상태들도 객체에 메시지를 전달해 알려줄 수 있기 때문이다.

 

발티오는 최소한의 기능으로 동작하고, 어느 쪽에 치우치지 않은 유연한 라이브러리라는 점에서 강점이 있다. 서스펜스와 리액트 18에 대한 지원도 제공하며, 바닐라 자바스크립트에 대한 사용도 가능하다. 단점은 오늘 소개한 라이브러리 중에서 가장 사용자 수가 적어서 커뮤니티가 작다는 점이다. 레퍼런스가 아직 많이 부족하고, 상대적으로 기능이 적어서 복잡한 서비스에 도입하기엔 적합하지 않을 수도 있다.

 

 

마치며

오늘은 리액트 상태 관리 라이브러리 4가지에 대해서 살펴보았다. 패턴으로 보면 리코일과 조타이가 비슷하고, 주스탠드는 리덕스, 발티오는 몹엑스와 비슷하다. 오늘 살펴본 라이브러리 중 사용자 수는 주스탠드(Zustand)가 가장 많지만, 앞으로 어떤 라이브러리가 새롭게 등장할지는 아무도 모른다.

 

개인적으로는 프로덕션 환경에서 안정적인 서비스를 만들 때는 주스탠드가 이슈가 제일 적고 레퍼런스가 있는 편이라 권장하고 싶다. 리코일은 아직 실험 버전이고 이슈가 많아서 프로덕션에 도입한다면 신중하게 결정할 필요가 있다.

 

그리고 실험적인 것들을 만들어 보고 싶을 땐 조타이나 발티오를 써보는 것도 좋다. 성능을 챙기고 싶다면 바텀업 방식의 조타이를 권장하며, 바닐라 JS로 작업할 일이 있다면 주스탠드나 발티오가 도움이 될 수 있다.

 

그리고 리액트 쿼리(React Query)와 같은 데이터 패칭 도구와도 같이 쓸 수 있다. 예를 들어, 주스탠드 + immer + 리액트 쿼리와 같은 조합을 만들어 써 보는 것도 도움이 될 것이다. 필자 역시 앞으로 기회가 될 때마다 다양한 도구들을 학습해 써볼 예정이다.


<참고>

 

요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.

좋아요

댓글

공유

공유

댓글 0
웹프론트엔드 엔지니어 데브오웬입니다
55
명 알림 받는 중

작가 홈

웹프론트엔드 엔지니어 데브오웬입니다
55
명 알림 받는 중
안녕하세요. 개발을 통해 비즈니스 임팩트를 내는 것에 관심이 많은 3년차 웹 프론트엔드 엔지니어 데브오웬입니다. 현재는 헬스케어 스타트업 굿닥에서 웹 프론트엔드 엔지니어로 근무하고 있으며, 병원과 환자를 위한 B2B, B2C 기반의 웹, 모바일 앱 제품을 만들고 있습니다.

동료와 함께, 때로는 혼자 세상의 다양한 문제들을 해결할 수 있는 제품을 만들고 이에 몰입할 수 있습니다. 공부하는 내용을 타인과 공유하며 서로 성장하는 것을 좋아합니다. 이를 위한 커뮤니티, 스터디, 컨퍼런스, 해커톤 등의 활동들도 다양하게 참여하고 있습니다.

블로그: devowen.com
링크드인 : linkedin.com/in/wonjongoh

좋아요

댓글

스크랩

공유

공유

지금 회원가입하고,
요즘IT가 PICK한 뉴스레터를 받아보세요!

회원가입하기
요즘IT의 멤버가 되어주세요! 요즘IT의 멤버가 되어주세요!
요즘IT의 멤버가 되어주세요!
모든 콘텐츠를 편하게 보고 스크랩해요.
모든 콘텐츠를 편하게 보고 스크랩 하기
매주 PICK한 콘텐츠를 뉴스레터로 받아요.
매주 PICK한 콘텐츠를 뉴스레터로 받기
로그인하고 무료로 사용하기