프로젝트를 진행하기 전에, 상태 관리 라이브러리를 선택하기 위해 작성하게 된 아티클입니다.
비교적 최신 라이브러리를 사용해보고 싶었고, 처음에는 Recoil도 언급되었으나 Recoil은 메타에서 개발했음에도 불구하고 업데이트가 많지 않아 장기적으로 가져갈 프로젝트에서는 부적합하다는 판정을 내렸습니다.
따라서 저는 Jotai와 Zustand 중 어느 라이브러리를 도입할 지 고민했습니다. (둘다 타입스크립트를 기반으로 제작되었습니다.)
npm trends
Redux는 2015년에 개발된 라이브러리 답게 여전히 많은 사용률을 자랑하네요.
Zustand는 2019년에 출시된 라이브러리이고, Jotai는 2020년에 출시된 라이브러리입니다.
Zustand 가 더 빨리 출시된 것이 이유인지, Jotai보다는 약 3배 가량 더 많은 사용률을 보여주네요.
참고로, Jotai와 Zustand는 모두 다이시 카토라는 일본 개발자에 의해 개발되고 관리되고 있습니다.
Jotai와 Zustand의 비교
Jotai Zustand
state 모델 | primitive atoms 형태 | 단일 스토어 형태 |
형태 | useState 의 인터페이스를 기반으로한 여러 util (예 : useAtom) | useStore 로 거의 단일화되어있음 |
방식 | Bottom-up | Top-down |
Provider | 일반적으로는 필요함 | 필요 없음 |
Vanila JS 지원 여부 | X (React 만 지원) | O |
Suspense와 연계 | O | X (문서에서 언급 X) |
Devtool | O | O |
Jotai → Recoil과 유사
Zustand → Redux와 유사 (Redux의 Devtool을 그대로 활용할 수도 있다.)
둘은 러닝커브가 낮은 편에 속하는데, 특히 Zustand는 공식 문서도 매우 짧을 만큼 러닝 커브가 낮다고 합니다. Jotai는 Atomic 한 방법으로 전역 상태를 관리하기 때문에 Bottom-up 방식, Zustand는 Store를 활용하여 전역 상태를 관리하기 때문에 Top-down 방식이라고 볼 수 있습니다.
💡 상태 관리를 위한 3가지 방식 💡
Flux : 저장소(store) / 액션함수(action) / 리듀서 등을 통해서 상태를 업데이트 하는 방식 → Redux, Zustand
Atomic : React에 사용되는 state와 비슷하게 리액트 트리 안에서 상태를 저장하고 관리하는 방식 → Jotai, Recoil
Proxy : 컴포넌트에 사용되는 일부 상태를 자동으로 감지해서 업데이트 하는 방식 → Mobx, Valtio
Zustand의 경우 Top-down 방식으로 전역 상태를 접근하기 때문에, 전체적인 오버뷰에서 디테일 세부 사항으로 Store 모델링을 활용하는 것이 좋습니다. 예를 들어 블로그 웹 사이트를 위한 하나의 중앙화된 Store를 활용하여 state를 관리하기 때문에, 블로그 → 포스트 → 작가, 제목, 내용 과 같은 식으로 설계하는 것이 좋습니다. 결국 Zustand는 Context에 맞게 여러 Store들을 선언하고, create를 통해 만든 useStore 훅을 이용하여 해당 Store를 이용하는 방식을 채택했다고 이해하시면 됩니다.
Jotai의 경우에는 Bottom-up 방식으로 전역 상태를 관리한다고 했습니다. 처음에 Atom을 정의하고, 그것을 차곡 차곡 쌓아 큰 조각의 상태들로 만들어나간다고 생각하시면 됩니다. Atom간의 소통(서로 값을 가져오는 것)도 가능합니다. React에 특화된 라이브러리라서, Context를 기반으로 사용하되 리렌더링 문제를 특히 신경 쓴 라이브러리입니다. (물론 Zustand도 리렌더링을 많이 고려했습니다.)
Zustand는 Vanila JS에도 적용할 수 있는 라이브러리이고, Jotai는 React에 특화된 라이브러리입니다. (애초에 React의 state 모델을 따르기 때문이죠) 그래서인지 Jotai는 다른 상태 관리 라이브러리들과의 Integration을 공식적으로 제공합니다. react-query, zustand, redux, valtio를 포함하여 immer,optics,xstate들의 라이브러리를 지원한다고 합니다. 확장성이 높은거죠. (그런데 곰곰히 생각해보면 Zustand도 Vanila JS에서 적용할 수 있기 때문에, 특정 라이브러리 종속성이 없다는 것이고, 그렇다면.. 확장성이 높은 것 아닌가 하는 생각도 드네요.)
그리고 Zustand는 구독 기반으로 리렌더링을 하기 때문에 상태 변경 시 필요한 컴포넌트만 리렌더링하여 성능을 챙기고, Jotai는 selectAtom이나 splitAtom등의 유틸리티를 사용하여 리렌더링을 최소화할 수 있기 때문에 성능 최적화의 문제는 둘다 걱정할 필요가 없습니다. 또한 Zustand는 비동기 작업을 간편하게 처리할 수 있고, API 호출이 많은 비동기 로직이 많은 애플리케이션에 적합한 장점이 있지만 Jotai도 마찬가지로 Suspence와 잘 통합되어 비동기 작업을 효율적으로 처리할 수 있다고 합니다.
비교하면 비교할수록 명확해지는 것은 하나 있습니다.
Zustand나 Jotai 중 더 나은 라이브러리를 구별하기는 어렵다!
라는 사실입니다. 둘 다 기능적으로 부족해보이지 않았고, 러닝 커브도 약간은 차이가 있겠지만 낮다는 점에서 도드라지는 강점이 보이지는 않았습니다.
Jotai와 Zustand 중에 사용할 것은?
결론부터 말하자면, 저는 Jotai를 선택했습니다.
- 언제든지 다른 상태 관리 라이브러리와 결합하여 사용할 수 있다는 점
- Atomic하게 State를 관리한다는 점
- 성능이 중요한 앱에서 많이 사용되는 Bottom-up 방식
이 3가지가 저희의 프로젝트에 알맞다고 생각했기 때문입니다.
저희는 학생 공연 티켓 플랫폼을 개발하려고 하고 있습니다. 단순히 프로젝트 경험을 쌓는 것이 아니라, 프로덕트를 더욱 길게 개발하고 실 사용자를 확보하는 것을 목표로 하고 있기에 그만큼 오래 프로젝트가 이어질 가능성이 크다고 생각합니다.
따라서 다른 라이브러리와의 연계 및 통합이 가능하다는 것은 발전 가능성을 열어두는 것이나 마찬가지라고 생각했습니다. (그럼 Zustand를 쓰다가 Jotai를 쓰는거나 Jotai를 쓰다가 Zustand를 쓰는 것이 차이가 없지 않는가? 라는 질문이 들어오면 할 말은 딱히… 없습니다.)
또한, 와이어프레임을 보며 저희의 프로젝트는 상태 관리가 생각보다 복잡할 것을 예상하고 있습니다. 리렌더링이 자주 일어나는 상태에서는 자동으로 리렌더링을 관리해주는 Zustand가 현명한 선택이고, 복잡한 상태를 관리하기 위해서는 Bottom-up 방식을 채택한 Jotai가 더욱 현명한 선택이라고 생각합니다.
그렇기에 복잡한 상태 관리가 필요한 애플리케이션을 위해,
Atomic한 상태 관리를 지원하는 Jotai를 선택하게 되었습니다.
추가적인 이유 중 하나는, 사실 Jotai가 조금은 더 어려워보였는데 Suspence나 직접 리렌더링 최적화를 한다는 점에서 상태 관리에 대한 이해가 더욱 깊어질 수도 있지 않을까.. 하는 생각에 챌린징해보고 싶었습니다.
Jotai 사용법
yarn add jotai
사용법은 그렇게 어렵지 않습니다. 우선 Atom에 대해 명확히 알아봅시다.
import { atom } from 'jotai'
const priceAtom = atom(10)
이런 식으로 atom 함수를 사용하여 atom 을 정의합니다. 참고로, atom 자체가 값을 지니게 되는 것이 아니라 atom의 value는 store에 저장되고, 이를 atom이 가리키는 개념이라고 이해하면 됩니다.
위의 경우는 자바스크립트의 경우라서 타입이 정의되어 있지 않은데, 이제부터 저는 타입스크립트의 경우로 설명할 예정이라 모든 코드에 타입 정의가 들어갈 예정입니다.
Atom의 종류, atom(), useAtom()
Atom은 크게 2가지 종류, 자세히는 4가지 종류로 나눌 수 있습니다.
- Primitive Atom (기본 아톰, 원시 아톰)
- Derived Atom (파생 아톰) - 원시 아톰에서 값을 가져오는 개념!
- Read-only Atom (읽기 전용 아톰)
- Write-only Atom (쓰기 전용 아톰)
- Read-Write Atom (읽고 쓰기가 가능한 아톰)
Primitvie Atom 타입 정의, 사용 예시
원시 아톰의 경우 오로지 단순 값만 담고 있습니다.
function atom<Value>(initialValue: Value): PrimitiveAtom<Value>
atom 함수를 사용하여 atom을 정의하고, useAtom을 사용하여 특정 atom을 불러와 atom의 value값, set 함수를 가져올 수 있습니다.
import React from 'react';
import { atom, useAtom } from 'jotai';
// 기본 아톰 정의
const countAtom = atom<number>(0);
const Counter: React.FC = () => {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
export default Counter;
Read-only Atom 타입 정의, 사용 예시
function atom<Value>(read: (get: Getter) => Value): Atom<Value>
Getter, Setter는 각각 Jotai에서 제공하는 함수입니다. Atom의 값을 불러오거나 설정하는데 사용됩니다.
import React from 'react';
import { atom, useAtom } from 'jotai';
// 기본 아톰 정의
const countAtom = atom<number>(0);
// 읽기 전용 아톰 정의
const doubleCountAtom = atom<number>((get) => get(countAtom) * 2);
const DoubleCounter: React.FC = () => {
const [doubleCount] = useAtom(doubleCountAtom);
return (
<div>
<p>Double Count: {doubleCount}</p>
</div>
);
};
const Counter: React.FC = () => {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<DoubleCounter />
</div>
);
};
export default Counter;
Write-only Derived Atom 타입 정의, 사용 예시
function atom<Value, Args extends unknown[], Result>(
read: Value,
write: (get: Getter, set: Setter, ...args: Args) => Result,
): WritableAtom<Value, Args, Result>
Args extends unknown[] 은 Args가 아직은 모르는 타입의 배열로 확장될 수 있다는 겁니다. (즉, 다양한 타입의 요소를 포함할 수 있는 배열 타입) 실제로 사용 예시를 보면 인자로 값 하나만 딱 받거나, 여러개의 값을 받는 것을 볼 수 있습니다.
또한, Write-only Derived Atom 을 정의하는 경우에는 첫 번째 인자로 null을 주는 것이 관습입니다.
import React from 'react';
import { atom, useAtom } from 'jotai';
// 기본 아톰 정의
const countAtom = atom<number>(0);
// 쓰기 전용 파생 아톰 정의
const decrementAtom = atom<null, [number], void>(
null,
(get, set, decrementValue) => {
set(countAtom, get(countAtom) - decrementValue);
}
);
const Decrementer: React.FC = () => {
const [, decrement] = useAtom(decrementAtom);
return (
<div>
<button onClick={() => decrement(1)}>Decrement</button>
</div>
);
};
const Counter: React.FC = () => {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<Decrementer />
</div>
);
};
export default Counter;
Read-Write Derived Atom 타입 정의, 사용 예시
function atom<Value, Args extends unknown[], Result>(
read: (get: Getter) => Value,
write: (get: Getter, set: Setter, ...args: Args) => Result,
): WritableAtom<Value, Args, Result>
import React from 'react';
import { atom, useAtom } from 'jotai';
// 기본 아톰 정의
const countAtom = atom<number>(0);
// 쓰고 읽기 가능한 파생 아톰 정의
const adjustableCountAtom = atom<number, [number], void>(
(get) => get(countAtom) * 2,
(get, set, newDoubleCount) => {
set(countAtom, newDoubleCount / 2);
}
);
const AdjustableCounter: React.FC = () => {
const [doubleCount, setDoubleCount] = useAtom(adjustableCountAtom);
return (
<div>
<p>Double Count: {doubleCount}</p>
<button onClick={() => setDoubleCount(doubleCount + 2)}>Increment Double Count</button>
</div>
);
};
const Counter: React.FC = () => {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<AdjustableCounter />
</div>
);
};
export default Counter;
💡 아래와 같은 모습으로 추후 setAtom 함수를 사용할 때 불러와지는 write 함수를 커스터마이징할 수 있다.
const countAtom = atom(1)
const derivedAtom = atom(
(get) => get(countAtom),
(get, set, action) => {
if (action.type === 'init') {
set(countAtom, 10)
} else if (action.type === 'inc') {
set(countAtom, (c) => c + 1)
}
},
)
derivedAtom.onMount = (setAtom) => {
setAtom({ type: 'init' })
}
- 전부 한번에 사용하는 예시
그리고 각 컴포넌트들에서 import {priceAtom} from “./atoms”; 를 통해 atom을 가져와서 사용한다.import { atom, useAtom } from 'jotai'; export const priceAtom = atom(100); // 기본 가격을 100으로 설정
readOnlyAtom.tsx -
const readOnlyAtom = atom((get) => get(priceAtom) * 2); function ReadOnlyComponent() { const [doublePrice] = useAtom(readOnlyAtom); return <p>Double Price: {doublePrice}</p>; }
writeOnlyAtom.tsx
const writeOnlyAtom = atom(
null,
(get, set, update) => {
set(priceAtom, get(priceAtom) - update.discount);
set(priceAtom, (price) => price - update.discount);
},
);
function WriteOnlyComponent() {
const [, updatePrice] = useAtom(writeOnlyAtom);
const applyDiscount = () => {
updatePrice({ discount: 10 });
};
return <button onClick={applyDiscount}>Apply Discount</button>;
}
readWriteAtom.tsx
const readWriteAtom = atom(
(get) => get(priceAtom) * 2,
(get, set, newPrice) => {
set(priceAtom, newPrice / 2);
},
);
function ReadWriteComponent() {
const [doublePrice, setDoublePrice] = useAtom(readWriteAtom);
const updatePrice = () => {
setDoublePrice(300); // 예시로 가격을 300으로 설정
};
return (
<div>
<p>Double Price: {doublePrice}</p>
<button onClick={updatePrice}>Set New Double Price</button>
</div>
);
}
App.tsx
function App() {
return (
<div>
<h1>Price Management</h1>
<ReadOnlyComponent />
<WriteOnlyComponent />
<ReadWriteComponent />
</div>
);
}
export default App;
const Atom1 = atom(initialValue)
const Atom2 = atom(read)
const Atom3 = atom(read, write)
const Atom4 = atom(null, write)
각각 무슨 아톰일까요?
- 정답
const primitiveAtom = atom(initialValue)
const derivedAtomWithRead = atom(read)
const derivedAtomWithReadWrite = atom(read, write)
const derivedAtomWithWriteOnly = atom(null, write)
주의사항 - Atom 정의는 어디에?
💡 Atom은 컴포넌트 외부에 정의하는 것이 안전합니다. 함수형 컴포넌트, 즉 렌더링 함수 내에서 Atom을 생성할 경우, 리렌더링 될 때마다 Atom이 생성되며 참조적 동일성이 유지되지 않습니다. 💡
참조적 동일성 : 같은 객체, 배열, 함수를 가리키지 않는다는 의미.
정 컴포넌트 내부에 정의하고 싶다면, useMemo 함수를 반드시 사용하여 useAtom을 할 때 생길 수 있는 무한 루프를 막아야합니다.
const Component = ({ value }) => {
const valueAtom = useMemo(() => atom({ value }), [value]);
const [state, setState] = useAtom(valueAtom);
// ... 나머지 컴포넌트 로직
};
이 코드는 의존성 배열안에 있는 value가 변할때만 첫번째 인자의 함수를 실행시킨다는 의미입니다. 참고로 useMemo는 함수의 실행 결과(보통 값)을 메모이제이션 한다고 생각하시면 됩니다.
Atom의 property
debugLabel property : 디버그할 때 유용하게 사용할 레이블 지정
- https://jotai.org/docs/guides/debugging
- 추후 사용하게 된다면 살펴봅시다!
onMount property : Provider 안에서 처음으로 구독될 때 호출되는 함수
onMount 프로퍼티에 할당할 함수의 리턴값에는 onUnmount 함수가 들어갑니다. 마치 useEffect의 클린업 함수와 비슷한 모습을 띄고 있네요.
const anAtom = atom(1)
anAtom.onMount = (setAtom) => {
console.log('atom is mounted in provider')
setAtom(c => c + 1) // increment count on mount
return () => { ... } // return optional onUnmount function (onUnmount 함수)
}
const Component = () => {
// `onMount` will be called when the component is mounted in the following cases:
useAtom(anAtom)
useAtomValue(anAtom) //아톰 값만 불러오는 것
// however, in the following cases,
// `onMount` will not be called because the atom is not subscribed:
useSetAtom(anAtom)
useAtomCallback( //Atom 내에 값을 가져오는 콜백함수를 정의해두는 것
//즉, 아톰의 상태를 가져오거나 설정하는 로직을 콜백 함수 안에서 캡슐화할 수 있게 만든다.
//이를 통해 컴포넌트 외부에서 아톰을 조작할 수 있다.
useCallback((get) => get(anAtom), []), //첫번째 인자는 get,set을 두가지를 인자로 받는 콜백함수, 두번째는 의존성 배열
)
// ...
}
Atom을 통한 간단한 비동기 액션
const urlAtom = atom('<https://json.host.com>')
const fetchUrlAtom = atom(async (get) => {
const response = await fetch(get(urlAtom))
return await response.json()
})
Suspense와 같이 사용하는 예시
아직 Suspense와 사용 해본 적 없어 어색하지만..
const countAtom = atom('')
const Layout = () => {
// 아래의 atom은 fallback을 트리거링 합니다.
const writeAtom = atom(null, async (get, set) => {
const response = await new Promise<string>((resolve, _reject) => {
setTimeout(() => {
resolve('some returned value')
}, 2000)
})
set(countAtom, 'The returned value is: ' + response)
})
return (<>
<button onClick={writeAtom}>increase</button>
</>)
}
const Component = () => {
const [, increment] = useAtom(asyncIncrementAtom)
}
const App = () => (
<Provider>
<Suspense fallback="Loading...">
<Layout />
</Suspense>
</Provider>
)
React- Query와 Integration 하는 예시
앞 전의 설명에서, 다른 상태 관리 라이브러리들과의 Integration을 공식적으로 제공한다고 했습니다. 이는 아래와 같이 사용합니다. jotai/query
import { useAtom } from 'jotai'
import { atomWithQuery } from 'jotai/query'
const idAtom = atom(1)
const userAtom = atomWithQuery((get) => ({
queryKey: ['users', get(idAtom)],
queryFn: async ({ queryKey: [, id] }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
return res.json()
},
}))
const UserData = () => {
const [data] = useAtom(userAtom)
return
}
Store와 Provider
Jotai에도 Store 개념, Context와 유사한 Provider 개념이 존재합니다. 다만, 만약 신경 쓰지 않고 Atom을 사용할 경우 해당 Atom은 기본적으로 DefaultStore에 저장됩니다.
Provider를 사용하지 않으면 기본 상태를 사용하는데, 이를 provider-less 모드라고 합니다.
import { atom, useAtom, Provider, createStore } from 'jotai';
// 원자 정의
const countAtom = atom(0);
// 첫 번째 스토어 생성
const firstStore = createStore();
firstStore.set(countAtom, 1);
// 두 번째 스토어 생성
const secondStore = createStore();
secondStore.set(countAtom, 2);
// 첫 번째 Provider와 연결된 컴포넌트
const FirstComponent = () => {
const [count] = useAtom(countAtom);
return <p>First Count: {count}</p>;
}
// 두 번째 Provider와 연결된 컴포넌트
const SecondComponent = () => {
const [count] = useAtom(countAtom);
return <p>Second Count: {count}</p>;
}
// 앱 컴포넌트
const App = () => (
<div>
<Provider store={firstStore}>
<FirstComponent />
</Provider>
<Provider store={secondStore}>
<SecondComponent />
</Provider>
</div>
);
export default App;
💡 그렇다면 Provider가 중첩되어 정의된다면?! 💡
가장 가까운 Provider의 Atom 값을 가져옵니다 !
import { atom, useAtom, Provider, createStore } from 'jotai';
// 원자 정의
const countAtom = atom(0);
// 첫 번째 스토어 생성
const outerStore = createStore();
outerStore.set(countAtom, 1);
// 두 번째 스토어 생성
const innerStore = createStore();
innerStore.set(countAtom, 2);
// 컴포넌트 정의
const DisplayCount = () => {
const [count] = useAtom(countAtom);
return <p>Count: {count}</p>;
}
const App = () => (
<Provider store={outerStore}>
<DisplayCount />
<Provider store={innerStore}>
<DisplayCount />
</Provider>
</Provider>
);
export default App;
Reference
https://yozm.wishket.com/magazine/detail/2233/
https://blog.hwahae.co.kr/all/tech/tech-tech/6099
https://programming119.tistory.com/263
https://velog.io/@iberis/상태관리-라이브러리-비교-Redux-vs-Recoil-vs-Zustand-vs-Jotai
https://blog.axlight.com/ → 제작자의 블로그
https://jotai.org/ → Jotai 공식 문서
https://docs.pmnd.rs/ → 상태 관리 라이브러리 관련된 문서 모음
https://medium.com/@ian-white/recoil-vs-jotai-vs-zustand-09d3c8bd5bc0
Recoil vs Jotai vs Zustand
Recoil과 Jotai는 Context와 Provider, 그리고 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는데 초점을 맞춘다.
medium.com
WEB_공준혁
공연 관리하기를 맡은 FE 개발자 공준혁입니다.
'WEB' 카테고리의 다른 글
프론트엔드에서 production 서버와 development 서버 분리하기! (0) | 2024.07.23 |
---|---|
캐러셀... 너 뭔데 나를 이렇게 힘들게 해... (0) | 2024.07.19 |
못말리는 Input 달래주기 2편 (이모지 입력) (1) | 2024.07.19 |
비트에서의 React-query 사용해보기! (3) | 2024.07.17 |
못말리는 Input 달래주기 1편 (숫자 입력) (2) | 2024.07.17 |