5.2.4 상태 관리 라이브러리 Recoil, Jotai, Zustand 살펴보기
- 지금까지 직접 만든 상태 관리 라이브러리에 대해 알아봤으니 이번에는 리액트 생태계에서 많은 개발자들이 사용하고 있는 상태 관리 라이브러리에 대해 알아보고자 한다. 여기서는 비교적 나온 지 오래됐고, 널리 사용되고 있으며, 관련 문서와 유스케이스도 다양한 리덕스와 MobX에 대해서는 다루지 않는다. 그 대신 비교적 최근에 나왔고 앞선 두 라이브럴리의 대안으로 각광받고 있는 Recoil, Jotai, Zustand에 대해 살펴보고자 한다.
- Recoil과 Jotai는 Context와 Provider, 그리고 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 데 초점을 맞추고 있다. 그리고 Zustand는 리덕스와 비슷하게 하나의 큰 스토어를 기반으로 상태를 관리하는 라이브러리다. Recoil, Jotai와는 다르게 이 하나의 큰 스토어는 Context가 아니라 스토어가 가지는 클로저를 기반으로 생성되며, 이 스토어의 상태가 변경되면 이 상태를 구독하고 있는 컴포넌트에 전파해 리렌더링을 알리는 방식이다.
- 여기서는 실제 세 라이브러리를 어떻게 사용해야 하는지, 또 제공하고 있는 API는 무엇인지 등을 자세히 소개하지 않는다. 그 대신 세 상태 관리 라이브러리가 지향하는 목적은 무엇이고, 라이브러리 내부에서는 어떻게 상태를 관리하며, 나아가 이 상태를 컴포넌트로 어떻게 전파해 렌더링을 일으키는지와 같은 핵심적인 요소만 살펴보고자 한다. 세 라이브러리에 대한 자세한 API 및 사용 예제는 각 라이브러리의 홈페이지와 깃허브 저장소를 참고하기 바란다.
페이스북이 만든 상태 관리 라이브러리 Recoil
- Recoil은 리액트를 만든, 페이스북에서 만든, 리액트를 위한 상태관리 라이브러리다. 리액트의 훅의 개념으로 상태 관리를 시작한 최초의 라이브러리 중 하나이며, 최소 상태 개념인 Atom을 처음 리액트 생태계에서 선보이기도 했다. 2020년 처음 만들어졌지만 깃허브 주소에서도 알 수 있는 것처럼 아직 정식으로 출시한 라이브러리가 아니라 실험적으로 개발되고 운영되는 라이브러리다. 비교적 오랜 시간이 흘렀음에도 여전히 실험 단계인 점, 즉 1.0.0이 배포되지 않아 많은 개발자로 하여금 실제 운영되고 있는 서비스에 Recoil 설치를 머뭇거리게 하고 있다. Recoil 팀에서는 리액트 18에서 제공되는 동시성 렌더링, 서버 컴포넌트, Streaming SSR 등이 지원되기 전가지는 1.0.0을 릴리스하지 않을 것이라고 밝힌 바 있다. 따라서 Recoil은 실제 프로덕션에 사용하기에는 안정성이나 성능, 사용성 등을 보장할 수 없으며, 유의적 버전에 따라 부(micor) 버전이 변경돼도 호환성이 깨지는 변경사항이 발생할 수도 있는 위험을 안고 있다. 그럼에도 간혹 실제 프로젝트에서 Recoil에서 제공하는 개념과 구현 방식은 여타 라이브러리에도 많은 영향을 끼쳤기 때문에 이번 절에서는 가장 먼저 Recoil을 먼저 살펴보고자 한다.
- 가장 먼저 Recoil이 어떻게 작동하는지 직접 소스코드를 통해 살펴보자. Recoil의 핵심 API인 RecoilRoot, atom, useRecoilValue, useRecoilState를 살펴보고 Recoil에서는 상태값을 어디에 어떻게 저장하고, 또 컴포넌트의 렌더링은 어떻게 발생시키는지 그 원리를 알아보자. (이번 절의 내용은 2022년 9월 기준으로 Recoil 최신 버전인 0.7.5를 기준으로 작성됐다.)
RecoilRoot
- 가장 먼저 확인할 것은 RecoilRoot다. Recoil을 사용하기 위해서는 RecoilRoot를 애플리케이션의 최상단에서 선언해 둬야 한다.
export default function App() {
return <RecoilRoot>{/* some components */}</RecoilRoot>;
}
- RecoilRoot의 용도는 무엇이길래 최상위 컴포넌트에서 선언해야 하는 것일까? Recoil의 소스코드를 살펴보면 RecoilRoot애서 Recoil에서 생성되는 상태값을 저장하기 위한 스토어를 생성하는 것을 확인할 수 있다.
function RecoilRoot(props: Props): React.Node {
const { override, ...propsExceptOverride } = props;
const ancestorStoreRef = useStoreRef();
if (override === false && ancestorStoreRef.cureent !== defaultStore) {
// If ancestorStoreRef.current !== defaultStore, it means that this
// RecoilRoot is not nested within another.
return props.children;
}
return <RecoilRoot_INTERNAL {...propsExceptOverride} />;
}
- 여기서 주목할 것은 useStoreRef다. useStoreRef로 ancestorStoreRef의 존재를 확인하는데, 이는 Recoil에서 생성되는 atom과 같은 상태값을 저장하는 스토어를 의미한다. 그리고 이 useStoreRef가 가리키는 것은 다름 아닌 AppContext가 가지고 있는 스토어다.
const AppContext = React.createContext<StoreRef>({ current: defaultStore });
const useStoreRef = (): StoreRef => useContext(AppContext);
- 그리고 스토어의 기본값을 의미하는 defaultStore는 다음과 같다.,
function notInAContext() {
throw err("This component must be used inside a <RecoilRoot> component.");
}
const defaultStore: Store = Object.freeze({
storeID: getNextStoreID(),
getState: notInAContext,
replaceState: notInAContext,
getGraph: notInAContext,
subscribeToTransactions: notInAContext,
addTransactionMetadata: notInAContext,
});
- 스토어를 살펴보면 크게 다음과 같이 나눠볼 수 있다. 스토어의 아이디 값을 가져올 수 있는 함수인 getNextStoreID()와 스토어의 값을 가져오는 함수인 getState, 값을 수정하는 함수인 replaceState 등으로 이뤄져 있다. 그리고 해당 스토어 아이디를 제외하고는 모두 에러로 처리돼 있는데, 이를 미루어 보아 RecoilRoot로 감싸지 않은 컴포넌트에서는 스토어에 접근할 수 없다는 것을 알 수 있다.
- 그리고 또 한가지 흥미로운 것은 replaceState에 대한 구현이다.
const replaceState = (replacer: (TreeState) => TreeState) => {
startNextTreeIfNeeded(storeRef.current);
// Use replacer to get the next state:
const nextTree = nullthrows(storeStateRef.current.nextTree);
let replaced;
try {
statereplacerIsBeingExecuted = true;
replaced = replacer(nextTree);
} finally {
stateReplacerIsBeingExecuted = fasle;
}
if (replaced === nextTree) {
return;
}
// ...생략
// Save changes to nextTree and schedule a React update:
storeStateRef.current.nextTree = replaced;
if (reactMode().early) {
notifyComponents(storeRef.current, storeStateRef.current, replaced);
}
// ...
};
- 앞서 직접 구현한 예제와 마찬가지로 상태가 변할 때 이 변경된 상태를 하위 컴포넌트로 전파해 컴포넌트에 리렌더링을 일으키는 notifyComponents가 있는 것을 혹인할 수 있다. 이 notifyComponents의 구조도 살펴보자.
function notifyComponents(
store: Store,
storeState: StoreState,
treeState: TreeState,
): void {
const dependentNodes = getDownstreamNodes(
store,
treeState,
treeState.dirtyAtoms,
);
for (const key of dependentNodes) {
const comps = storeState.nodeToComponentsSubscriptions.get(key);
if (comps) {
for (const [_subID, [_debugName, callback]] of comps) {
callback(treeState);
}
}
}
}
- notifyComponents는 store, 그리고 상태를 전파할 storeState를 인수로 받아 이 스토어를 사용하고 있는 하위 의존성을 모두 검색한 다음, 여기에 있는 컴포넌트들을 모두 확인해 콜백을 실행하는 것을 볼 수 있다. 값이 변경됐을 때 콜백을 실행해 상태 변화를 알린다는 사실은, 앞서 구현해 본 바닐라 스토어와 크게 다르지 않다는 것을 알 수 있다.
- 지금까지 RecoilRoot의 구조를 대략 파악한 바로 알아볼 수 있는 사실은 다음과 같다.
- Recoil의 상태값은 RecoilRoot로 생성된 Context의 스토어에 저장된다.
- 스토어의 상태값에 접근할 수 있는 함수들이 있으며, 이 함수를 활용해 상태값에 접근하거나 상태값을 변경할 수 있다.
- 값의 변경이 발생하면 이를 참조하고 있는 하위 컴포넌트에 모두 알린다.
atom
- 그 다음으로 Recoil의 핵심 개념인 atom을 살펴보자. atom은 상태를 나타내는 Recoil의 최소 상태 단위다. atom은 다음과 같은 구조로 선언할 수 있다.
type Statement = {
name: string;
amount: number;
};
const InitialStatments: Array<Statement> = [
{ name: "과자", amount: -500 },
{ name: "용돈", amount: 10000 },
{ name: "네이버페이충전", amount: -5000 },
];
// Atom 선언
const statementsAtom = atom<Array<Statement>>({
key: "statments",
default: InitialStatments,
});
- atom은 key 값을 필수로 가지며, 이 키는 다른 atom과 구별하는 식별자가 되는 필수 값이다. 이 키는 애플리케이션 내부에서 유일한 값이어야 하기 때문에 atom과 selector를 만들 때 반드시 주의를 기울여야 한다. 그리고 default는 atom의 초기값을 의미한다. 이 atom의 값을 컴포넌트에서 읽어오고 이 값의 변화에 따라 컴포넌트를 리렌더링하려면 다음 두 가지 훅을 사용하면 된다.
useRecoilValue
- useRecoilValue는 atom의 값을 읽어오는 훅이다. 이 훅을 사용하면 다음과 같이 atom의 값을 가져올 수 있다.
const statements = useRecoilValue(statementsAtom);
- 이 useRecoilValue 훅은 어떻게 구현돼 있는지 살펴보자.
// useRecoilValue
function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
if (__DEV__) {
// __DEV__ 변수는 개발 환경에서 특정 확인 또는 동작을 활성화 또는 비활성화하는 데 사용
validateRecoilValue(recoilValue, "useRecoilValue");
}
const storeRef = useStoreRef(); // Recoil 스토어에 대한 참조. atom 및 selector
const loadable = useRecoilValueLoadable(recoilValue); // Loadable: 로드 가능한 상태. Recoil 값의 비동기 상태를 나타내며 로드 중인지, 오류가 있는지, 값이 있는지를 포함
return handleLoadable(lodable, recoilValue, storeRef); // Recoil 값의 다양한 상태(로딩 중, 오류, 값이 있는 상태)를 처리하고 적절한 결과를 반환
}
// .....
// useRecoilValueLoadable
// Recoil 라이브러리를 사용하는 React 애플리케이션에서 Recoil 값의 로드 가능한(Loadable) 상태를 처리
function useRecoilValueLoadable_LEGACY<T>(
recoilValue: RecoilValue<T>
): Lodable<T> {
// Recoil 값의 상태가 변경시 강제로 컴포넌트를 다시 렌더링하기 위한 용도로 사용
const storeRef = useStoreRef<T>;
const [, forceUpdate] = useState([]);
// 컴포넌트 이름 및 로드 가능한 상태 얻기
const componentName = useComponentName(); // 현재 컴포넌트의 이름
// getLoadable : 현재 Recoil 값의 로드 가능한(Loadable) 상태를 얻기 위한 용도
const getLoadable = useCallback(() => {
if (__DEV__) {
recoilComponentGetRecoilValueCount_FOR_TESTING.current++;
}
const store = storeRef.current;
const storeState = store.getState();
const treeState = reactMode().early
? storeState.nextTree ?? storeState.currentTree
: storeState.currentTree;
return getRecoilValueAsLoadable(store, recoilValue, treeState);
}, [storeRef, recoilValue]);
// 로드 가능한(Loadable) 상태 및 이전 로드 가능한 상태 기록
// 이후에 Recoil 값의 변경을 감지하고 적절한 액션을 수행
const loadable = getLoadable();
const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLodableRef.current = loadable;
});
// Recoil 값의 변경을 구독하고, 변경이 감지될 때 컴포넌트를 다시 렌더링
useEffect(
() => {
const store = storeRef.current;
const storeState = store.getState();
// 현재 Recoil의 값을 구독하는 함수
const subscription = subscribeToRecoilValue(
store,
recoilValue,
(_state) => {
// 렌더링을 억제하는 설정이 되어있지 않다면
if (!gkx("recoil_suppress_render_in_callback")) {
return forceUpdate([]);
}
const newLodable = getLoadable();
// is는 두 객체가 같은지 비교하고, 다르다면 렌더링을 유도한다.
if (!prevLoadableRef.current?.is(newLodable)) {
forceUpdate(newLodable);
}
prevLodableRef.current = newLoadable;
},
componentName
);
// 큐 및 콜백 설정
if (storeState.nextTree) {
// Recoil의 다음 트리가 있다면
// 배열에 콜백을 추가하고, 이 콜백에서 이전 로드 가능한 상태를 초기화하고 컴포넌트를 강제로 다시 렌더링
// queuedComponentCallbasks_DEPRECATTED 배열이 deprecated (더 이상 사용 X)
store.getState().queuedComponentCallbasks_DEPPRECATTED.push(() => {
prevLodableRef.current = null;
forceUpdate([]);
});
} else {
if (!gkx("recoil_suppress_render_in_callback")) {
// 렌더링을 억제하는 설정이 되어있지 않다면
return forceUpdate([]);
}
const newLodable = getLodable(); // 현재 Recoil이 가지고 있는 상태값을 가지고 있는 클래스인 lodable을 반환
// 값 비교 - 다르다면 forceUpdate를 실행한다.
if (!prevLodableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable);
}
prevLodableRef.current = newLoadable;
}
// 클린업 함수에 subscribe 해체하는 함수를 반환한다.
return subscription.release;
},
componentName,
getLoadable,
recoilValue,
storeRef
);
return loadable;
}
- useRecoilValue와 useRecoilValueLoadable 코드다. 코드를 직관적으로 이해할 수 있게 useRecoilValue_LEGACY를 가져왔다.
- 먼저 getLoadable은 현재 Recoil이 가지고 있는 상태값을 가지고 있는 클래스인 loadable을 반환하는 함수다. 그리고 이 값을 이전값과 비교해 렌더링이 필요한지 확인하기 위해 렌더링을 일으키지 않으면서 값을 저장할 수 있는 ref에 매번 저장하는 것을 볼 수 있다.
- 그리고 useEffect를 통해 recoilValue가 변경됐을 때 forceUpdate를 호출해 렌더링을 가제로 일으킨다. forceUpdate는 말 그대로 렌더링을 강제로 실행시키기 위한 함수다.
- 단순히 useState의 두 번째 인수로, useState 값을 사용하기 위함이 아닌 말 그대로 렌더링만 발생시키기 위해 실행하는 것을 확인할 수 있다. 이외에도 코드 중간중간 Recoil에서 넣은 최적화 코드들이 보이지만 '외부의 값을 구독해 렌더링을 강제로 일으킨다'라는 원리는 이전 예제와 동일한 것을 알 수 있다.
useRecoilState
- useRecoilValue는 단순히 atom의 값을 가져오기 위한 훅이었다면 useRecoilState는 좀 더 useState와 유사하게 값을 가져오고, 또 이 값을 변경할 수도 있는 훅이다. useRecoilState를 살펴보자.
function useRecoilState<T>(
recoilState: RecoilState<T>
): [T, SetterOrUpdater<T>] {
if (__DEV__) {
validateRecoilValue(recoilState, "useRecoilState"); // Recoil 상태가 유효한지 확인
}
return [useRecoilValue(recoilState), useSetRecoilState(recoilState)];
}
- 먼저 useRecoilState는 useState와 매우 유사한 구조로 작성돼 있음을 알 수 있다. 먼저 현재 값을 가져오기 위해 이전에 작성한 훅인 useRecoilValue를 그대로 사용하고 있으며, 상태를 설정하는 훅으로 useRecoilState 훅을 사용하고 있다. 이 훅은 내부에서 먼저 스토어를 가져온 다음에 setRecoilValue를 호출해 값을 업데이트하고 있다.
// useSetRecoilState
/*
Returns a function that allows the value of a RecoilState to be updated, but does not subscribe the component to changes to that RecoilState
*/
function useSetRecoilState<T>(recoilState: RecoilState<T>): SetterOrUpdate<T> {
if (__DEV__) {
validateRecoilValue(recoilState, "useRecoilState"); // Recoil 상태가 유효한지 확인
}
const storeRef = useStoreRef();
// Setter 함수 반환
return useCallback(
(newValueOrUpdater: ((T) => T | DefaultValue) | T | DefaultValue) => {
setRecoilValue(storeRef.current, recoilState, newValueOrUpdater);
},
[storeRef, recoilState]
);
}
- setRecoilValue 내부에서는 queueOrPerformStateUpdate 함수를 호출해 상태를 업데이트 하거나 업데이트가 필요한 내용을 등록하는 것을 확인할 수 있다.
//setRecoilValue
function setRecoilValue<T>(
store: Store,
recoilValue: AbstractRecoilValue<T>,
valueOrUpdater: T | DefaultValue | ((T) => T | DefaultValue)
): void {
queueOrPerformStateUpdate(store, {
type: "set",
recoilValue,
valueOrUpdater,
});
}
- 지금까지 간단하게 Recoil을 살펴본 바를 종합하면 다음과 같이 요약할 수 있다. 먼저 애플리케이션의 최상단에 <RecoilRoot />를 선언해 하나의 스토어를 만들고, stom이라는 상태 단위를 <RecoilRoot />에서 만든 스토어에 등록한다. atom은 Recoil에서 관리하는 작은 상태 단위이며, 각 값은 고유한 값이 key를 바탕으로 구별된다. 그리고 컴포넌트는 Recoil에서 제공하는 훅을 통해 atom의 상태 변화를 구독(subscribe)하고, 값이 변경되면 forceUpdate 같은 기법을 통해 리렌더링을 실행해 최신 atom값을 가져오게 된다.
간단한 사용법
- 지금까지 API를 살펴본 내용을 바탕으로, 간단하게 Recoil을 사용하는 코드를 작성해 보자.
- 앞서 설명한 API외에 selector라는 함수를 사용한 것을 볼 수 있다. selector는 한 개 이상의 atom 값을 바탕으로 새로운 값을 조립할 수 있는 API로, useStoreSelelctor와 유사한 역할을 수행하는 것을 확인할 수 있다.
- 이외에도 atom에 비동기 작업도 추가할 수 있으며, sueRecoilStateLoadable, waitForAll, waitForAny, waitForAllSettled와 같이 강력한 비동기 작업을 지원하기 위한 API도 지원한다.
특징
- 지금까지 간략하게 Recoil을 어떻게 사용하는지 살펴봤다. Recoil은 메타(페이스북) 팀에서 주도적으로 개발하고 있기 때문에 앞으로도 리액트에서 새롭게 만들어지는 기능을 그 어떤 다른 라이브러리보다 잘 지원할 것으로 기대된다. 실제로도 리액트 18에서 새롭게 추가된 기능들을 Recoil에서도 확실하게 지원하고 있으며, 이를 토대로 서버 컴포넌트와 같이 추가적인 API나 기능도 잘 대응해 주리라고 기대해도 좋을 것이다. 또한 selector르 필두로 다양한 비동기 작업을 지원하는 API를 제공하고 있기 때문에 리덕스와 달리 redux-saga나 redux-thunk 등 추가적인 미들웨어를 사용하지 않더라도 비동기 작업을 수월하게 처리할 수 있다. 그리고 리액트와 비슷하게 Recoil에서도 자체적인 개발 도구를 지원해 Recoil을 기반으로 개발하는 데 많은 도움을 얻을 수 있다.
- Recoil에서 한 가지 불확실한 점은 앞서도 언급했던 것처럼 정식 버전인 1.0.0의 출시 시점이다. 0.x.x 버전은 다른 주 버전과 다르게 부 버전이 변경돼도 호환성이 깨지는 변경사항이 발생할 수 있는 초기 버전으로 간주되어 라이브러리르 사용할 때에 추가적인 주의가 필요하다. 이는 안정적인 서비스를 만들고자 하는 개발자들에게 Recoil을 선뜻 채택하기 어렵게 만드는 큰 걸림돌이 되고 있다. 특히 상태 관리 라이브러리는 애플리케이션의 비즈니스 로직에 아주 깊숙하게 관여됐을 가능성이 크기 때문에 호환성이 깨지는 사소한 변경사항이라도 개발자 입장에서 큰 부담이 될 수 있다. 얼마전에 오랜 0.x 버전을 깨고 정식 릴리스 한 Axios처럼 조만간 라이브러리 설계를 마무리 짓고 1.0.0을 릴리스할 날이 오기를 바란다.
Recoil에서 영감을 받은, 그러나 조금 더 유연한 Jotai
- Jotai는 공식 홈페이지에도 나와있는 것처럼, Recoil의 atom 모델에 영감을 받아 만들어진 상태 관리 라이브러리다. Jotai는 상향식(bottom-up) 접근법을 취하고 있다고 나와 있는데 이는 리덕스와 같이 하나의 큰 상태를 애플리케이션에 내려주는 방식이 아니라, 작은 단위의 상태를 위로 전파할 수 있는 구조를 취하고 있음을 의미한다. 또한 앞서 언급했던 리액트 Context의 문제점인 불필요한 리렌더링이 일어난다는 문제를 해결하고자 설계되어 있으며, 추가적으로 개발자들이 메모이제이션이나 최적화를 거치지 않아도 리렌더링이 발생되지 않도록 설계되어 있다. Recoil과 동일하게, Jotai를 구성하는 주요 API를 살펴보고 어떤 방식으로 상태 관리를 하고 있는지 알아보자. (이번 절에서 다루는 내용은 2022년 9월 기준으로 Jotai의 최신 버전인 1.8.3을 기준으로 한다.)
atom
- Recoil에서 영감을 받았다고 언급한 것처럼 Jotai에도 atom 개념이 존재한다. atom은 Recoil과 마찬가지로 최소 단위의 상태를 의미한다. Recoil과는 다르게, atom 하나만으로도 상태를 만들 수도, 또 이에 파행된 상태를 만들 수도 있다. atom이 최소한의 상태 단위라는 것까지는 동일하지만 atom 하나로 파생된 상태까지 만들 수 있다는 점에서 차이가 있다.
- 먼저 atom을 만들어보자.
- 이렇게 만든 textAtom에는 다음과 같은 정보가 담긴다.
- 이 atom은 어떤 구조를 가지고 있을까? Jotai의 내부 atom 구현을 살펴보자.
- atom이라는 이름과 개념적인 원리는 Recoil에서 받았지만 구현 자체에는 약간의 차이가 있을을 확인할 수 있다. 먼저 각 atom을 생성할 때마다 고유한 key를 필요로 했던 Recoil과는 다르게, Jotai는 atom을 생성할 때 별도의 key를 넘겨주지 않아도 된다. atom 내부에는 key라는 변수가 존재하긴 하지만 외부에서 받는 값은 아니며 단순히 toString()을 위한 용도로 한정돼 있다. 그리고 config라는 객체를 반환하는데, 이 config에는 초기값을 의미하는 init, 값을 가져오는 read, 값을 설정하는 write만 존재한다. 즉, Jotai에서의 atom에 따로 상태를 저장하고 있지 않다. 그렇다면 이 상태는 어디에 저장해 두는 것일까? 그 해답은 useAtomValue에 있다.
useAtomValue
- 다음의 Jotai의 useAtomValue 구현이다.
- 여기서 눈여겨봐야 할 것은 useReducer다. useReducer에서 반환하는 상태값은 3가지로 [version, valueFromReducer, atomFromReducer]인데, 첫 번째는 store 버전, 두 번째는 atom에서 get을 수행했을 때 반환되는 값, 세 번째는 atom 그 자체를 의미한다. Recoil과는 다르게, 컴포넌트 루트 레벨에서 Context가 존재하지 않아도 되는데, Context가 없다면 앞선 예제에서처럼 Provider가 없는 형태로 기본 스토어를 루트에 생성하고 이를 활용해 값을 저장하기 때문이다. 물론 Jotai에서 export하는 Provider를 사용한다면 앞선 예제에서 여러 개의 Provider별로 다른 atom 값을 관리할 수도 있다.
- 그리고 이 atom의 값은 store에 존재한다는 것을 알 수 있다. store에 atom 객체 그 자체를 키로 활용해 값을 저장한다. 이러한 방식을 위해 WeakMap이라고 하는, 자바스크립트에서 객체만을 키로 가질 수 있는 독특한 방식의 Map을 활용해 recoil과는 다르게 별도의 key를 받지 않아도 스토어에 값을 저장할 수 있다.
- 마지막으로 눈여겨봐야 할 것은 리렌더링을 일으키기 위해 사용하는 rerenderIfChanged다. rerenderIfChanged가 일어나는 경우는 첫째, 넘겨받은 atom이 Reducer를 통해 atom과 달라지는 경우, 둘째 앞선 예제에서 구현해 본 것처럼 subscribe를 수행하고 있다가 어디선가 이 값이 달라지는 경우다. 이러한 로직 덕분에 atom의 값이 어디서 변경되더라도 useAtomValue로 값을 사용하는 쪽에서는 언제든 최신 값의 atom을 사용해 렌더링할 수 있게 된다.
useAtom
- useAtom은 useState와 동일한 형태의 배열을 반환한다. 첫 번째로는 atom의 현재 값을 나타내는 useAtomValue 훅의 결과를 반환하며, 두 번째로는 useSetAtom 훅을 반환하는데, 이 훅은 atom을 수정할 수 있는 기능을 제공한다.
- setAtom으로 명명돼 있는 콜백 함수 내부에서 사용하고 있는 write 함수를 살펴보면, write 함수는 스토어에서 해당 atom을 찾아 직접 값을 업데이트 하는 것을 볼 수 있다. 그리고 스토어에서 새로운 값을 작성한 이후에는 해당 값의 변화에 대해 알고 있어야 하는 listener 함수를 실행해 값의 변화가 있음을 전파하고, 사용하는 쪽에서 리렌더링이 수행되게 한다.
간단한 사용법
- 다음 코드는 Jotai에서 간단한 상태를 선언하고, 만들어진 상태로부터 파생된 상태를 사용하는 예제다.
- 먼저 Jotai에서 상태를 선언하기 위해서는 atom이라는 API를 사용하는데, 이 API는 리액트의 useState와는 다르게 컴포넌트 외부에서도 선언할 수 있다는 장점이 있다. 또한 atom은 값뿐만 아니라 함수를 인수로 받을 수 있는데, 이러한 특징을 활용해 다른 atom 값으로부터 파생된 atom을 만들 수도 있다. 그리고 이 atom은 컴포넌트 내부에서 useAtom을 활용해 useState와 비슷하게 사용하거나 useAtomValue를 통해 getter만 가져올 수 있다. 이렇게 기본적인 API 외에도 localStorage와 연동해 영구적으로 데이터를 저장하거나, Next.js, 리액트 네이티브와 연동하는 등 상태와 관련된 다양한 직업을 Jotai에서 지원한다.
특징
- 아무래도 라이브러리의 태생 자체가 Recoil에서 많은 영감을 받은 만큼, Recoil과 유사한 면이 많이 보임과 동시에 Recoil이 가지고 있는 몇 가지 한계점을 극복하기 위한 노력이 엿보인다.
- 가장 먼저 Recoil의 atom 개념을 도입하면서도 API가 간결하다는 점을 꼽을 수 있다. Recoil의 atom에서는 각 상태값이 모두 별도의 키를 필요로 하기 때문에 이 키를 별도로 관리해야 하는데, Jotai는 이러한 부분을 추상화해 사용자가 키를 관리할 필요가 없다. Jotai가 별도의 문자열 키가 없이도 각 값들을 관리할 수 있는 것은 객체의 참조를 통해 값을 관리하기 때문이다. 객체의 참조를 WeakMap에 보관해 해당 객체 자체가 변경 되지 않는 한 별도의 키가 없이도 객체의 참조를 통해 값을 관리할 수 있다.
- 그리고 Recoil에서는 atom에서 파생된 값을 만들기 위해서는 selector가 필요했지만, Jotai에서는 selector가 없이도 atom만으로 atom 값에서 또 다른 파생된 상태를 만들 수 있다. 이는 Recoil에 비해 간격하다고 볼 수 있다.
- Jotai 자체도 여타 다른 라이브러리와 마찬가지로 타입스크립트로 작성돼 있어 타입을 잘 지원하고 있으며, 이는Flow로 작성되어 별도로 d.ts를 제공하는 Recoil 대비 장점으로 볼 수 있다.
- 마지막으로, Jotai 또한 리액트 18의 변경된 API를 원활하게 지원하며, 현재 V2.x 버전까지 정식으로 출시돼있어 실제 서비스 하는 애플리케이션에서도 무리 없이 사용할 수 있을 것으로 보인다.
- 이러한 Recoil 대비 여러 가지 장점으로 인해 Recoil의 atom 형태의 상태 관리를 선호하지만, 아직 정식 버전이 출시되지 않아 사용이 망설여지는 많은 개발자들이 Jotai를 채택해 개발하고 있다.
작고 빠르며 확장에도 유연한 Zustand
- Jotai가 Recoil의 영감을 받아 만들어졌다면, Zustand는 리덕스에 영감을 받아 만들어졌다. 즉, atom이라는 개념으로 최소 단위의 상태를 관리하는 것이 아니라 Zustand에서는 하나의 스토어를 중앙 집중형으로 활용해 이 스토어 내부에서 상태를 관리하고 있다. 따라서 Zustand에서는 하나의 스토어를 중앙 집중형으로 활용해 이 스토어 내부에서 상태를 관리하고 있다. 따라서 Zustand를 이해하려면 하나의 큰 스토어가 어떻게 만들어지는지를 먼저 살펴봐야 한다. 스토어를 시작으로, 각 상태값을 어떻게 참조하는지, 또 리렌더링은 어떻게 유도하는지 알아보자.(이번 절의 내용은 2022년 9월 기준으로 Zustand의 최신 버전인 4.1.1을 기준으로 표현한다.)
Zustand의 바닐라 코드
- 먼저 Zustand에서 스토어를 만드는 코드를 살펴보자.
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>;
type Listener = (statse: TState, prevState: TState) => void;
let state: TState;
const listeners: Set<Listener> = new Set();
};
const setState: SetStateInternal<TState> = (partial, replace) => {
const nextState =
typeof partial === "function"
? (partial as (state: TState) => TState)(state)
: partial;
if (nextState !== state) {
const previousState = state;
state =
replace ?? typeof nextState !== "object"
? (nextState as TState)
: Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
};
const getState : () => TState = () => state
const subscribe : (listener : Listener) => () => void = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const destory : () => void = () => listener.clear()
const api = { setState, getState, subscribe, destory }
state = (createState as PopArgument<typeof createState>)(
setState, getState, api
)
return api as any
}
`getState` : 현재 상태 반환
`subscribe` : 상태 변경감지 리스너 추가 , 해당 리스터 제거
`destory` : 모든 리스너 제거
- 스토어의 구조가 앞서 5.2.2절에서 만들어본 스토어와 유사하게 state의 값을 useState 외부에서 관리하는 것을 볼 수 있다. state라고 하는 변수가 바로 스토어의 상태값을 담아두는 곳이며, setState는 이 state값을 변경하는 용도로 만들어졌다. 한 가지 특이한 것은 partial과 replace로 나눠져 있다는 것인데, partial은 state의 일부분만 변경하고 싶을 때 사용하고, replace는 state를 완전히 새로운 값으로 변경하고 싶을 때 사용한다. 이로써 state의 값이 객체일 때 필요에 따라 나눠서 사용할 수 있을 것으로 보인다.
- getState는 클로저의 최신 값을 가져오기 위해 함수로 만들어져 있다. subscribe는 listener를 등록하는데, listener는 마찬가지로 Set 형태로 선언되어 추가와 삭제, 그리고 중복 관리가 용이하게끔 설계되어 있다. 즉, 상태값이 변경될 때 리렌더링이 필요한 컴포넌트에 전파될 목적으로 만들어졌음을 알 수 있다. destroy는 listener를 초기화하는 역할을 한다. createStore는 이렇게 만들어진 getState, setState, subscribe, destroy를 반환하고 있다. 매우 간단하다.
- 이 스토어 코드가 있는 파일을 들어가서 유심히 살펴보면 재밌는 사실 몇 가지를 발견할 수 있다. 이 파일은 ./src/vanila.ts인데, 이 파일에서 export하는 유일한 함수 및 변수는 바로 이 createStore이며, 그 외에는 모두 이 createStore를 이요하는 데 필요한 타입뿐이다. 그리고 또 하나 특이한 점은 그 어떤 것도 import 하고 있지 않다는 사실인데, 즉 이 store는 리액트를 비롯한 그 어떤 프레임워크와는 별개로 완전히 독립적으로 구성돼 있다는 것을 의미한다. 따라서 이 store 파일의 이름처럼, 순수하게 자바스크립트 환경에서도 사용할 수 있다.
type CounterStore = {
count: number;
increase: (num: number) => void;
};
const store = createStore<CounterStore>((set) => ({
count: 0,
increase: (num: number) => set((state) => ({ count: state.count + num })),
}));
store.subscribe((state, prev) => {
if (state.count !== prev.count) {
console.log("count has been changed", state.count);
}
});
store.setState((state) => ({ count: state.count + 1 }));
store.getState().increase(10);
- 한 가지 눈여겨볼 것은 createStore로 스토어를 만들 때 set이라는 인수를 활용해 생성할 수 있다는 것이다. 이는 앞선 Zustand의 createStore 예제 코드에서 살펴볼 수 있는 것처럼 state를 생성할 때 setState, getState, api를 인수로 넘겨줬기 때문에 가능하다. set을 통해 현재 스토어의 값을 재정의할 수도 있고, 두 번째 인수로 get을 추가해 현재 스토어의 값을 받아올 수도 있다.
- 이렇게 생성된 스토어는 getState와 setState를 통해 현재 스토어의 값을 받아오거나 재정의할 수 있다. 또한 subscribe를 통해 스토어의 값이 변경될 때마다 특정 함수를 실행할 수도 있다. 이 subscribe는 현재 값과 이전 값 둘 다 확인할 수 있으므로 특정 값이 변경될 때만 실행되게끔 최적화할 수도 있다.
Zustand의 리액트 코드
- 바닐라 자바스크립트에서 Zustand를 사용하는 것도 좋지만 Zustand를 리액트에서 사용하기 위해서는 어디선가 store를 읽고 리렌더링을 해야 한다. Zustand 스토어를 리액트에서 사용할 수 있도록 도와주는 함수들은 ./src/react.ts에서 관리되고 있다. 타입을 제외하고 여기에서 export하는 함수는 바로 useStore와 create다. 먼저 useStore를 살펴보자.
export function useStore<TState, StateSlice>(
api: WithReact<StroeApi<TState>>,
selector: (state: TState) => StateSlice = api.getState as any,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
selector,
equalityFn
);
useDebugValue(slice);
return slice;
}
- useStore 코드 또한 매우 간결하다. useSyncExternalStoreWithSelector를 사용해서 앞선 useStore의 subscribe, getState를 넘겨주고 끝난다. useSyncExternalStoreWithSelector는 useSyncExternalStore와 완전히 동일하지만 원하는 값을 가져 올 수 있는 selector와 동등 비교를 할 수 있는 equalityFn 함수를 받는다는 차이가 있다. 즉, 객체가 예상되는 외부 상태값에서 일부 값을 꺼내올 수 있도록 useSyncExternalStoreWithSelector를 사용했다.
- useSyncExternalStore는 리액트 18에서 새롭게 만들어진 훅으로, 리액트 외부에서 관리되는 상태값을 리액트에서 사용할 수 있도록 도와준다.
- 또 한가지 ./src/react.ts에서 export하는 변수는 바로 create인데, 이는 리액트에서 사용할 수 있는 스토러을 만들어주는 변수다.
const createImpl = <T>(createState : StateCreator<T, [], []>) => {
const api = typeof createState === 'function' ? createStore(createState) : createState
const useBoundStore : any (selector? : any, equalityFn?: any) => useStore(api, selector, equalityFn)
Object.assign(useBoundStore, api)
return useBoundStore
}
const create = (<T>(createState : StateCreator<T, [], []> | undefined) => createState ? createImpl(createState) : createImpl) as Create
export default create
- 리액트 create는 바닐라의 createStore를 기반으로 만들어졌기 때문에 거의 유사하다고 볼 수 있다. 다만 차이점은 useStore를 사용해 해당 스토어가 즉시 리액트 컴포넌트에서 사용할 수 있도록 만들어졌다는 것이다. 또한 useBounceStore에 api를 Object.assign으로 복사했는데, 이는 useBounceStore에 api의 모든 함수를 복사해서 api도 동일하게 사용할 수 있게 제공했다.
- 이러한 간결한 구조 덕분에 리액트 환경에서도 스토어를 생성하고 사용하기가 매우 쉽다.
interface Store {
count: number;
text: string;
increase: (count: number) => void;
}
const store = createStore<Store>((set) => ({
count: 0,
text: "",
increase: (num) => set((state) => ({ count: state.count + num })),
setText: (text) => set({ text }),
}));
const counterSelector = ({ count, increase }: Store) => ({
count,
increase,
});
function Counter() {
const { count, increase } = useStore(store, counterSelector);
function handleClick() {
increase(1);
}
return (
<>
<h3>{count}</h3>
<button onClick={handleClick}>+</button>
</>
);
}
const inputSelector = ({ text, setText }: Store) => ({
text,
setText,
});
function Input() {
const { text, setText } = useStore(store, inputSelector);
useEffect(() => {
console.log("Input Changed");
});
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setText(e.target.value);
}
return (
<div>
<input value={text} onChange={handleChange} />
</div>
);
}
- 스토어 생성 자체는 앞선 예제와 동일하다. 그리고 useStore를 사용하면 이 스토어를 리액트에서 사용할 수 있게 된다. 물론 create를 사용해 스토어를 만들면 useStore를 굳이 사용하지 않더라도 바로 사용할 수 있다.
간단한 사용법
- Zustand로 간단하게 스토어를 만들어보고, 이를 사용하는 방법을 알아보자. 리액트에서 Zustand를 사용한다고 가정하고, 다음과 같이 코드를 작성한다.
import { create } from "zustand";
const useCounterStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter(){
const { count, inc, dec } = useCounterStore()
return(
<div class = "counter">
<span>{count}</span>
<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
</div>
)
}
- 예제에서는 Zustand의 create를 사용해 스토어를 만들고, 반환 값으로 이 스토어를 컴포넌트 내부에서 사용할 수 있는 훅을 받았다. 그리고 이 훅을 사용하면 스토어 내부에 있는 getter와 setter 모두에 접근해 사용 할 수 있게 된다.
- 또, 리액트 컴포넌트 외부에 store를 만드는 것도 가능하다. 다음 예제를 보자.
import { createStore, useStore } from 'zustand'
const CounterStore = createStore((set) => ({
count : 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}))
function Counter(){
const { count, inc, dec } = useStore(counterStore)
return(
<div class = "counter">
<span>{count}</span>
<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
</div>
)
}
- createStore를 사용하면 리액트와 상관없는 바닐라 스토어를 만들 수 있으며, 이 바닐라 스토어는 useStore 훅을 통해 접근해 리액트 컴포넌트 내부에서 사용할 수 있게 된다.
특징
- 앞서 살펴본 예제와 더불어 Zustand의 특징을 정리해 보자. 먼저 Zustand는 특별히 많은 코드를 작성하지 않아도 빠르게 스토어를 만들고 사용할 수 있다는 큰 장점이 있다. 스토어를 만들고 이 스토어에 파생된 값을 만드는 데 단 몇 줄의 코드면 충분하다. 이는 리덕스 대비 확실히 구별되는 특징으로 볼 수 있으며, 간단하고 빠르게 상태를 정의할 수 있어 상태를 관리하는 입장에서 한결 가볍고 편리하다. 이 가볍게 쓸수 있다는 장점을 만들어 주는 것은 Zustand 자체의 라이브러리 크기도 한몫한다. Bundlephobia 기준으로 79.1kB인 Recoil, 13.1kB인 Jotai와 다르게 Zustand는 고작 2.9kB밖에 되지 않는다. 작은 크기 답게, 내부 코드 역시 초보자들이 보기에도 간단하게 작성돼 있다. 즉, API가 복잡하지 않고 사용이 간단해 쉽게 접근할 수 있는 상태 관리 라이브러리로 Zustand를 손꼽을 수 있다.
- 그리고 Jotai와 마찬가지로 타입스크립트 기반으로 작성돼 있기 때문에 별도록 @types를 설치하거나 임의로 d.ts에 대한 우려 없이 타입스크립트를 자연스럽게 쓸 수 있다.
- 또한 Zustand는 리덕스와 마찬가지로 미들웨어를 지원한다. create의 두 번째 인수로 원하는 미들웨어를 추가하면 되는데, 스토어 데이터를 영구히 보존할 수 있는 persist, 복잡한 객체를 관리하기 쉽게 도와주는 immer, 리덕스와 함께 사용할 수 있는 리덕스 미들웨어를 사용하면 상태를 sessionStorage에 추가로 저장하는 등의 기본적인 상태 관리 작동 외에 추가적인 작업을 정의할 수도 있다.
'FE > 모던 리액트 Deep Dive' 카테고리의 다른 글
| 5. 리액트와 상태관리 라이브러리 - 5 (0) | 2025.10.08 |
|---|---|
| 5. 리액트와 상태관리 라이브러리 - 3 (0) | 2025.10.07 |
| 5. 리액트와 상태관리 라이브러리 - 2 (0) | 2025.10.06 |
| 5. 리액트와 상태관리 라이브러리 - 1 (0) | 2025.10.05 |
| 3. 리액트 훅 깊게 살펴보기 - useState (0) | 2025.09.18 |