상태관리에 대해서 알아보자 (feat. recoil, zustand, jotai)

정도영·2024년 6월 5일
0
post-thumbnail

들어가기 전

솝트를 하면서 좋은 기회가 되어서 명예 OB분들에게 미미나를 많이 들었다!
항상 어떤 기술을 도입할 때 왜 이 기술이 필요한지 그리고 이 기술은 다른 기술보다 어떤 장점이 있는지, 그리고 어떤 단점이 있는지를 생각하면서 많은 고민을 하기를 추천해주셨다.

나는 기존에 여러 상태 관리 라이브러리들 중 recoil 밖에 사용해보지 못했고, 이번 아티클을 쓰면서 recoil과 비슷한 jotai 그리고 요즘 핫한 zustand에 대해서 알아보려고 한다.

상태관리 라이브러리를 왜 사용할까?

리액트에서의 상태는 즉, state인데, 애플리케이션이 커질수록 점점 이러한 상태관리가 복잡해진다!

리액트는 컴포넌트 단위의 블록을 조립하는 것과 같이 개발을 진행하게 된다.
예를 들어, 게시판을 만든다고 했을 때, 아래와 같이 게시판의 글 한 줄, 한줄에 해당하는 컴포넌트를 따로 만들고, 그 컴포넌트를 모아 게시판 하나를 완성하게 된다.

const Board = () => {
  const [posts, setPosts] = useState([]);
  return (
  <table>
    <th>제목</th>
    <th>작성자</th>
    {posts.map((post) => <Post props={post} />)}
  </table>
  )
}

const Post = (props) => {
  return(
    <tr>
      <td>{props.title}</td>
      <td>{props.writer</td>
    </tr>
  )
}

그리고 리액트는 이 state에 변동이 생길 시에 re-rendering 되어 페이지에 변동이 일어나게 되는데, 이 state는 오로지 자식 컴포넌트에게만 전달해줄 수 있다. 그런데 여기서 state를 자식에게, 그 자식의 자식에게, 그 자식의 자식의 자식... 이렇게 계속 이어서 전달해주면 어떻게 될까?

이렇게 되면 state를 하위 컴포넌트에 계속해서 내려줘야하고, 즉 리액트의 상태 관리가 굉장히 복잡해질 것이다!

위 사진에서 D 컴포넌트가 A 컴포넌트의 state 안에 있는 상태를 쓰고 싶다면, 그 사이의 모든 컴포넌트(B, C)에 해당 상태를 전달해주어야한다. 심지어 그 사이의 모든 컴포넌트는 해당 상태가 필요없음에도 하위에 전달해주기 위해 상태를 받아야한다. 이 문제를 바로 props Drilling Issue라고 한다.

상태관리 라이브러리는 이러한 복잡성을 줄이고, 무수히 많은 데이터를 관리하기 위해서 나타나게 되었다!

이러한 라이브러리를 사용하면 state를 전역 변수처럼 만들어 사이에 있는 모든 컴포넌트에 상태를 전달해주지 않고도 어떤 컴포넌트에서든 바로 state에 접근이 가능하게 된다!

그럼 이제부터 recoil, jotai, zustand 이 세 상태관리 라이브러리에 대해서 알아보자!

라이브러리 비교

다음은 npm trends에서 검색한 세 상태관리 라이브러리 최근 1년간 다운로드 수이다.

나는 사실 상태관리 라이브러리에서 recoil이 가장 다운로드 수가 많을 것으로 생각했는데,,, 실제로 다운로드 수에서 zustand가 압도적으로 많았고, Star수도 가장 많았다.

그리고, 최근에는 recoil보다 jotai가 올라오는 추세였다!

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

1) 리코일(Recoil)

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

그리고 기존의 리덕스와 같은 라이브러리는 기본적인 스토어(store) 구성을 위해서 많은 보일러 플레이트와 장황한 코드를 작성해야 했다.

이러한 러닝 커브를 낮춰주기 위해 쉽고 직관적인 라이브러리인 Recoil이 나타나게 되었다.

그럼 리코일의 특징을 몇 가지 알아보자면!

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

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

아톰(Atoms)은 상태 단위이며, 업데이트와 구독이 가능하다. 아톰이 업데이트됐을 때 해당 아톰을 구독하고 있는 컴포넌트들은 리렌더링이 일어나게 된다.

사용법

그럼 Recoil과 아톰의 사용법도 간단하게 알아보자!

모든 아톰은 전역적으로 고유의 키값을 가져야 하고, 이 키값을 가지고 컴포넌트에서 useRecoilState로 불러올 수 있다.

폰트 사이즈 atom에 대한 예시를 한 번 살펴보자.

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

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

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

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

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

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

const 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>
	</>
  );
}

리코일은 이전에 주로 사용되던 라이브러리인 redux, mobx 처럼 복잡한 상태 구조를 가지고 있지 않으며 사용법 또한 매우 간단한 편이다.

2) 조타이(jotai)


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

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, 
  (get, set, update) => set(priceAtom, get(priceAtom) - update.discount) 
);

const readWriteAtom = atom(
  (get) => get(priceAtom) * 2,
  (get, set, newPrice) => set(priceAtom, newPrice / 2) 
);

조타이의 아톰은 세 가지 케이스로 나눠볼 수 있다.
1) Read-only
2) Write-only
3) Read-Write

atom()에서 첫 번째 인자로 read할 값을, 두 번째 인자로 write하는 함수를 작성할 수 있다.

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

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)를 적용하는 부분에서 적합하게 설계가 되었다는 점이다. 다만 아직 다른 경쟁 도구들에 비해 사용자 수가 많지 않고, 레퍼런스가 부족하다는 점이 아쉽다.


3) Zustand

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

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

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

사용법

그럼 간단하게 주스탠드도 사용법을 알아보자!
먼저 스토어를 만들고 그 안에 원시타입, 객체, 함수 등을 넣는다.

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

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

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

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

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

리코일과 비교해보면,
리코일에서는 setXXX 형태로 아톰을 바꿔주는 로직을 보통 컴포넌트 안에서 해주거나 커스텀 훅으로 빼서 해주는데, 주스탠드는 스토어에서 바로 할 수 있어 편리하다.

이외 다양한 미들웨어도 지원한다.
immer를 주스탠드 차원에서 미들웨어로 쓸 수 있다.
복잡한 객체의 업데이트를 비교적 깔끔하게 처리할 수 있는 것이다.

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

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

const useFishStore = create(
  persist(
    (set, get) => ({
      fishes: 0,
      addAFish: () => set((state) => ({ fishes: state.fishes + 1 })),
    }),
    {
      name: 'food-storage',
      storage: createJSONStorage(() => sessionStorage),
    }
  )
);

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

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  temporaryChange: () => {
    set({ temp: true }); // 일시적으로 상태 변경
    setTimeout(() => set({ temp: false }), 2000); // 2초 후 원래 상태로 복원
  },
}));
  ...

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

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

실습

https://silk-title-f5a.notion.site/Zustand-c6c29fc8d69449568e94a879fc08a7c1?pvs=4

마무리

나는 기존에 상태관리 라이브러리 Recoil만 사용했는데 그 이유는 Redux처럼 복잡하게 사용하기 싫어서였다...

이번 기회를 통해서 여러 라이브러리들을 알아볼 수 있어서 기존의 내가 보던 시야보다 훨씬 더 많은 것들을 알게된 것 같다. 그리고 라이브러리들의 사용법도 경험해볼 수 있어서 너무 좋았다!
앞으로 더 생각하는 사람이 되자~!

profile
대한민국 최고 개발자가 될거야!

2개의 댓글

comment-user-thumbnail
2024년 6월 6일

세 가지 전역 상태 라이브러리를 다루시느라 너무 고생많았습니다!
처음에 개인적으로 리덕스로 투두리스트를 만들어봤던 기억이 나네요.
진짜 너무 러닝커브가 높다 라는 생각이 들어 너무너무 하기싫었거든요 ㅋㅋㅋㅋ

리코일과 조타이는 atom을 기준으로 사용되는 등 방법이 유사한 점이 인상깊었습니다ㅎㅎ!
이번에 저와 같이 심화스터디 플젝을 하게 되셨잖아요!
zustand를 도입하기로 결정했는데, 너무너무 기대됩니다!!
저는 리코일밖에 사용해보지 않아서, 다른 라이브러리들이 너무 궁금했거든요 ㅠ
zustand에서 주장하는 것이, 우리의 핵심 로직은 단 40줄이다! 라고 알고있거든요!
이번에 도영님과 같이 협업하는 기회가 되어 너무 좋습니다!!
플젝하면서 단순 사용법을 넘어 zustand의 코드를 뜯어보는 시간이 되었으면 좋겠네요 ㅎㅎ
좋은 아티클 너무 감사합니다 !

답글 달기
comment-user-thumbnail
2024년 6월 7일

ㅋㅋㅋㅋㅋ결론이 굉장히 귀엽네요 .. 앞으로 더 생각하는 사람이 되자 ~!
화랑이 아티클에서 jotai에 대해 중점적으로 읽어서, 도영이 아티클에서는 zustand를 위주로 읽어보았는데, 확실히 redux에 비해서 두개 다 러닝커브가 확 낮아지는 것 같네요 ! 저도 redux를 사용해보면서 어질어질했던 기억이 있어서 상태관리 라이브러리를 도입할 때 굉장히 신중하게 .. 그리고 웬만해서는 context api로 해결하려고 하는데, jotai와 zustand는 한 번 도입해볼만한 것 같아요 ! zustand 관해 읽으면서 가장 우와 했던 부분은 sub와 pub 파트가 나뉘어있고, 그에따라 상태관리가 이루어지기 때문에 생명 주기에 따라 처음부터 끝까지 의도하지 않는 변경에 대해 막을 수 있다라는 점이었어요 ! 아무래도 context api는 불필요한 렌더링을 요하기 때문에 의도하지 않은 렌더링이 일어날 수 있다는 부분이 늘 사용하면서 신경써야 했던 부분이었는데, zustand의 위와같은 부분들이 context api의 단점을 막아줄 수 있다는 생각이 드네요 !
자유주제스터디 기간이라 아티클 쓰는게 부담이 되었을 수도 있는데 잘 정리해주셔서 감사합니다 ! 덕분에 그동안 궁금했던 상태관리 라이브러리들을 찍먹할 수 있는 기회였던 것 같아요 ! 이 참에 저도 상태관리 라이브러리에 대해서 좀 .. 덜 회의적으로 접근할 것 같네요 !! 좋은 아티클 감사합니다 ! 수고 많으셨어요 :)

답글 달기