얼마 전, [모던 리액트 Deep Dive] 책을 읽고 회사에서 1월 사용하는 상태 관리 라이브러리를 zustand
로 변경하였습니다. 왜 zustand
를 선택하였고, 그 이야기를 해보려고 합니다.
여러분들은 redux
, mobx
, recoil
, jotai
, zustand
, valtio
이들 중 어떤 것을 쓰시나요?
혹시 valtio를 쓰시는 분이 있다면 굉장히 힙하다는 생각이 드네요.
회사에서 zustand
를 쓰기 전인 2022~2023년 약 2년 동안 recoil
을 사용하였습니다. 2021년에 입사했을 땐 redux
를 쓰고 있었구요.
redux
로 짜여진 프로덕트에서 거의 대부분의 상태가 redux
와 결합되어 매우 거대한 스토어를 이루고 있었습니다. 그것을 사용하기 위해 action
또한 너무 거대하다보니 복잡도가 너무 높아서 정신을 차리기 힘들 정도 였습니다.
제 실력이 너무나 부족하여 redux
의 결합도를 이해하지 못하고 코드를 고치다 보니 dispatch
들이 여기저기서 동작하기도 하며, 그것을 발견해서 막으면 저기가 뚫려서 하나를 고치면 사이드 이펙트가 많이 발생하는 상황이었습니다.
당시 새로운 구원자 분이 나타나 redux
를 recoil
로 바꾸기로 하였고, 그 사용법에 매료되었습니다. npm의 생태계를 잘 모르던 저는 META(전 facebook)에서 관리하는 상태관리 라이브러리’ 이기에 ‘가장 react와 잘 맞지 않을까?’ 했었습니다.
그렇게 recoil
로 전면 수정을 하였고, 관리도 단순해지니 프로덕트의 속도까지 향상되었습니다.
현재 recoil
은 처음 만났던 2년 전과는 달리 현재는 너무 다른 인식을 주고 있습니다. recoil
은 굉장히 무거운 라이브러리라는 것을 깨달았습니다.
아래는 bundlephobia에서 측정된 recoil @0.7.7 번들 사이즈입니다.
최적화를 시키면 79.4kB 이고 압축까지 하면 23.5kB 입니다! (…?)
문득 질문을 할 수 있습니다. “단위가 kB인데 뭐가 무겁다는거냐?” 라고 할 수 있습니다.
그래서 npm trends에서 상태 관리 라이브러리를 비교해보면 얼마나 무거운지 알 수 있습니다.
가장 우측의 압축까지 완료된 size를 보면 mobx를 제외하고는 recoil이 약 9~10배, 많게는 약 20배나 무겁다는 것을 알 수 있습니다.
아래는 저희 회사에서 next13 page-router로 개발된 WA.GG 프로덕트에서 recoil
을 zustand
로 수정한 빌드 결과를 비교한 것 입니다.
왼쪽: recoil
↔ 오른쪽: zustand
21kB 만 줄어든게 아니라 빌드된 페이지 폴더마다 약 21kb 씩 줄어든 것을 알 수 있습니다. 저는 이것을 통해 유의미한 경량화를 했다고 생각이 들었습니다.
작년에 AWSKRUG 프론트엔드 소모임 네트워킹 때, 한 개발자가 전역 상태 관리를 고민하여서 recoil을 추천했던 기억이 있는데… 이 내용을 써내려가면서 문득 부끄러움이 몰려드네요…
zustand는 어떻게 동작하는가?
zustand
는 어떻게 구성되어 있길래 가벼운지 살펴보면 zustand
의 핵심 파일인 vanilla.ts를 보면 알 수 있습니다.
/zustand/src/vanilla.ts
이 코드까지 어떻게 진입하는지 zustand demo를 통해서 살펴보겠습니다.
위 코드에서 컴포넌트 바깥에서 useStore
를 통해 zustand
의 create
함수를 사용하여 상태관리하는 것을 볼 수 있습니다.
/zustand/src/index.ts
zustand
의 진입점인 index.ts
에서는 default
로 ./react.ts
를 참조하고 있기에 ./react.ts
로 진입해보겠습니다.
/zustand/src/react.ts
여기에 저희가 찾고있던 create
함수가 있는 것을 알 수 있습니다.
create
함수는 createState
가 있으면 createImpl(createState)
실행 결과를 반환 받고, 아니면 createImpl
함수 자체를 반환합니다.
createImpl
함수 내부에서는 api
변수를 다음과 같이 선언하고 있습니다.
const api = createState가 함수인가?
true → createStore(createState)
함수를 실행하고 return 값을 할당합니다. false → createState
를 할당합니다.
여기서 createState
인수에는 아래와 같은 화살표 함수가 담겨져 있습니다.
api
변수는 createStore(createState)
함수를 실행하고 반환 값을 할당하게 됩니다. createStore
함수는 저희가 바라던 목적지인 ./vanilla.ts
를 import
해서 얹는 값임을 알 수 있습니다.
/zustand/src/vanilla.ts
createStore
함수는 인수로 받은 createState
가 값이 있으면 createStoreImpl(createState)
를 실행 후 그 결과를 반환합니다. 값이 없으면 createStoreImpl
함수 자체를 반환합니다.
저희는 createState
안에 화살표 함수가 있으니 createStoreImpl(createState)
를 실행하고 그 결과가 무엇인지 확인해보겠습니다.
createStoreImpl
함수에서 createState
가 state = createState(setState, getState, api)
로 할당되어지는 것을 알 수 있습니다. 이 식을 보면 조금 혼란스러울 수 있는데 풀어서 보겠습니다.
createState
는 하나의 인수를 가진 화살표 함수입니다. 그렇기에 동작하는 방식대로 코드를 작성하면 state
는setState, getState, api
중 setState
만 사용하게 됩니다. 그래서 createState(setState)
로 동작합니다. 최종적으로 state
에는 아래와 같이 담기게 됩니다.
이제 setState
가 어떻게 동작하는지를 살펴보겠습니다.
현재 partial
인수에는 (state) => ({ count: state.count + 1 })
로 된 화살표 함수가 담겨져 있습니다. 그러니 state.count
가 1이라고 가정했을 때, nextState
에는 { count: 2 }
객체가 담기게 됩니다.
다음 if문
에서 Object.is
로 객체 간의 얕은 비교를 수행하고, 비교 결과 같지 않을 경우 replace
에 따라 새 객체를 넣을 것인지 아니면 Object.assign({}, state, nextState)
를 통해 바뀐 것만 할당할 것인지 선택할 수 있습니다.
Object.assign
을 잘 사용할 일이 없어서 해당 함수가 어떻게 동작할까 살펴보았습니다.
nextState
의 값으로 할당되는 것을 확인 했습니다. 그런데 왜 Object.assign
을 선택하였을까 하니,
{}
(빈) 객체에 state
를 먼저 할당하고, 다음 nextState
를 할당하는데 그 중 키가 중복될 경우 nextState
에 있는 value로 덮어쓰기 방법을 통해 상태를 갱신하는 것을 알 수 있었습니다.
그리고 그 값을 마지막으로 listeners.forEach((listener) => listener(state, previousState))
를 통해 listener
를 호출하는 것을 알 수 있습니다.
다시 createStoreImpl
로 돌아와서 결과를 반환한 api 변수는 아래와 같은 값을 할당받게 됩니다.
createImpl
를 수정하여 대입하였습니다.
이제는 useBoundStore
함수가 무엇을 하는지 알아볼 차례입니다.
useBoundStore
에서는 useStore
를 이용한 중첩 함수임을 알 수 있습니다. useStore
에는 방금 전에 할당을 완료한 api
도 같이 보입니다.
useStore
는 useSyncExternalStoreWithSelector
를 이용하여 api
의 속성을 param
으로 받는 것을 알 수 있습니다.
useSyncExternalStoreWithSelector
는 react
에서 제공하는 useSyncExternalStore
API 입니다. store가 변경되면 api.subscribe
를 호출하면서 컴포넌트가 리렌더링되고 api.getState
를 반환하여 데이터를 갱신합니다.
slice
에는 api.getState
의 반환 값이 할당되고 useStore
의 반환 값으로 slice
가 반환되는 것을 알 수 있습니다.
slice
에는 아래와 같은 값이 담기게 됩니다.
useBoundStore
에 값이 할당되면 다음과 같습니다.
그럼 이제 useBoundStore
가 반환되고 create 함수의 역할이 끝나게 됩니다.
고로 이제 useState()
를 통해 count
와 inc
를 반환 받아서 사용할 수 있게 됩니다.
코드 수도 적고, 복잡하지도 않아서 라이브러리 탐구하는데 어려움은 크게 없었습니다.
저는 주변에 가끔 “리액트가 언제까지 생존할 수 있을 것 같은가?”에 대한 이야기를 합니다. 새로운 시대가 왔을 때, 리액트 보다 더 간편한 UI 라이브러리 또는 프레임워크가 나왔을 때 옮길 수 있으면 좋겠단 생각이 들었습니다.
그렇기 때문에 redux
를 잘 쓰는게 가장 좋은 방법이 아닐까 고민을 하고 있었습니다. 그러나 zustand
를 알게 되었고 문서를 살펴보던 중 아래와 같은 내용이 있었습니다.
어디에 종속되지 않는 특성을 가졌기 때문에 zustand
가 적합하는 생각을 하였습니다. 종속적이지 않기 때문에 라이브러리 또한 가볍다는 생각이 들었습니다.
제가 생각하는 zustand
의 장점으로 앞서 설명한 크기와 종속적이지 않는 것 등 있지만 무엇보다 추상화, 즉 의도를 명확히 할 수 있다는 점이 마음에 들었습니다.
2년간 recoil
을 썼을 때, 불편했던 점이라면 다른 사람이 쓴 setState
를 보면 어떤 일을 하는지 명확하지 않아서 지나치는 경우가 많았습니다.
const [value, setValue] = useRecoilState(ValueAtom);
useState
가 지역적일 때는 복잡하지 않았다면, 전역적일 때는 상태관리가 복잡해 질 수 있기에 단순하고 직관적이게 만드는 방법에 고민을 하게 되지만 zustand
에는 상태를 변경할 때 사용할 수 있는 정의할 수 있다는 점에서 저는 매력적으로 다가왔습니다.
그러나 단점이라고 지역으로 상태관리 하고 싶은 경우가 있는데, 찾아보니 Context API
를 사용하는 것도 있어서 Context API
의 단점인 상태가 변경될 경우, 하위가 모두 리렌더링이 되는 문제가 있었는데 이 문제를 해결하는지 직접 해봐야겠습니다.
여기서 TMI를 조금 던져보자면 zustand
를 따라 올라가다보면 Daishi Kato 님이 나오게 됩니다. Pinned 된 목록만 봐도 상태관리에 진심이신 것을 알 수 있습니다.
오늘 이야기한 zustand
, jotai
, valtio
모두 이 분이 관리한다고 하네요. Repositories를 보면 재밌는 것들이 많이 있으니 들어가서 봐도 좋을 것 같아서 잠깐 소개하였습니다.
코드를 전체를 다 파진 않았지만 zustand
가 어떻게 상태관리 하고 있는지 살펴볼 수 있었습니다. 그리고 얼마나 간단하게 만들어져 있는지 알 수 있었습니다. 저보다 더 설명을 잘한 블로그도 많아서 쓰지말까 했지만 제가 직접 파보는 시간을 따로 갖고 싶어서 실험도 해보고 재밌는 결과를 낼 수 있어서 좋은 경험이었습니다.
블로그를 쓰는 분들을 만나보면 많은 사람들이 “나보다 잘하는 사람이 좋은 글을 많이 써놨어”라고 합니다. 저도 그럴 때마다 글을 쓰기 전에 찾아보면서 “이 사람보다 잘 쓸 수 있을까?”하며 망설였습니다. (여전히 다른 분이 더 잘 썼지만..) 제가 블로그를 쓰면서 잘못된 정보를 전하지 않고자 찾아보는 과정에서 배우는 게 많다는 것을 깨달았습니다.