Jotai
최근 프로젝트 하나가 끝났는데, 이 프로젝트 내내 서버 상태와 클라이언트 상태를 일치시키는 방법에 대해 고민하게 되었다. 오래 전부터 현업에서는 리덕스를 사용해 비동기 처리를 하는 곳들이 많았는데, 최근에는 서버 상태와 클라이언트 상태를 ─ 데이터의 출처에 따라 ─ 분리하고 전자의 경우 리액트 쿼리를 도입하고 있다고 알고 있다.
그러면 클라이언트 상태 추적은 뭐로 할 것이냐를 따져봐야 하는데, 전통의 강자는 flux - redux - zustand 계보로 이어져 내려오고 있는 action 방식의 상태 관리 도구가 있을 것이다. 이쪽은 뚜렷한 계보와 철학 속에서 계승 발전된 것처럼 느껴진다. 그러나 MobX - recoil - Jotai로 대표되는 아토믹 계열의 경우 단 하나의 핵심 아이디어를 제외한 나머지는 action 방식 만큼 뚜렷하게 계승되지는 않은 것 같다.
하지만 나는 그 단 하나의 핵심 아이디어에 끌렸다. 아토믹은 각각의 상태가 개별적인 아톰으로 전환되고, 필요한 곳에서 즉시 가져다 사용할 수 있다. 만약 프로젝트가 거대하고 수많은 상태를 개별적으로 관리해야 한다면 zustand를 채택하는 편이 더 낫지 않을까 생각하기도 한다. 그러나 이번 프로젝트는 내부에서 관리해야 할 상태가 그렇게 많지 않았고, useState와 비슷한 사용법 등으로 인해 러닝 커브도 낮을 거로 예상해 Jotai를 선택했다.
Atoms
Primitive atoms
const cartAtom = atom<item[]>()
Jotai 사용은 원시 아톰 생성부터라고 할 수 있겠다. 위에서 말한 것처럼 하나의 아톰은 하나의 상태를 대표한다. 초기값을 넣어주지 않으면 1개의 인자가 필요한 곳에 0개의 인자를 넣었다고 에러를 뱉지만, 제네릭 타입 값을 넣어주면 에러와 별개로 사용은 할 수 있는 것 같다.
Derived atoms
const progressAtom = atom((get) => {
const anime = get(animeAtom)
return anime.filter((item) => item.watched).length / anime.length
})
원시 아톰으로 생성된 값을 이용하여 새로운 아톰을 생성할 수도 있다. 공식 문서의 명칭을 따르자면 '파생형 아톰'이라고 불러야겠지만, 굉장히 많은 곳에서 '계산된 아톰computed atoms'라고도 부르는 듯하다. 당연?하게도 상위 아톰의 상태가 변경되면 이 파생형 아톰의 상태도 변경된다.
// 기본 아톰들 생성
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);
const tripleCountAtom = atom((get) => get(countAtom) * 3);
// 파생된 아톰 생성
const derivedAtom = atom((get) => ({
count: get(countAtom),
doubleCount: get(doubleCountAtom),
tripleCount: get(tripleCountAtom),
}));
파생된 아톰의 경우 여러 아톰 값을 가져다가 한 데 묶는 용도로도 사용할 수 있다. 위에 작성한 예시 코드는 전부 readOnly 상태이다. 쓰기도 가능하게 하려면 아래와 같이 두 번째 아규먼트로 set 할 수 있는 함수를 넣어주면 된다.
const progressAtom = atom(
(get) => {
const text = get(textAtom);
return text;
},
(get, set, update) => {
set(textAtom, update);
}
);
export default textAtom;
여기서는 반대로 파생된 아톰에서 값을 변경하면 원래의 아톰 값까지 변경된다. set 함수에는 targetAtom과 변경시킬 내용이 들어가고, 콜백함수의 update는 가장 최근의 단일 변경값이 들어간다.
Readonly atoms
const readOnlyState = readonlyAtom(initialValue);
어떤 상태는 변하지 않고도 모든 곳에서 사용되어야 한다. 이럴 때는 원시 아톰이 아니라 읽기 전용 아톰으로 생성해주어야 한다. 이러한 종류의 아톰은 생각보다 여러 이유로 유용하게 쓰이는데
- 데이터 불변성 유지 : 읽기 전용 아톰은 한 번 생성된 상태를 변경할 수 없기 때문에 데이터 불변성(immutability)을 유지한다. 이는 상태가 예측 가능하고 변화에 안전해 복잡성을 줄이고 상태 관리를 더 예측 가능하게 만든다.
- 보안 및 신뢰성 : 애플리케이션의 특정 부분에서는 데이터 수정이 불가능해야 하거나, 외부 요인에 의해 변경되면 안 되는 상황이 있을 수 있는데, 읽기 전용 아톰은 이러한 상태를 보호하고 안정성을 유지하는 데 도움이 된다.
- 성능 향상 : 읽기 전용 아톰은 상태가 변경되지 않기 때문에 React의 불필요한 렌더링을 방지한다.
Storage
atomWithStorage 함수로 아톰을 생성하면 브라우저의 로컬 스토리지에 저장된 값을 쉽게 관리할 수 있다. 몇몇 블로그 글을 보면 atomWithStorage 함수의 세 번째 인자를 통해 로컬 스토리지 외에도 세션 스토리지나 쿠키 스토리지에 저장된 값까지 관리할 수 있다고 되어있다.
그런데 Jotai가 버전업을 하면서 그 기능을 없앤 건지 아니면 내가 테스트 해보는 과정에서 무슨 실수가 있었던 건지는 모르겠지만, 세 번째 인자로 어떤 값을 넣어도 에러가 터졌다. 공식문서에도 특별히 세 번째 인자에 대한 언급은 없었다. 그러니 나는 이 아톰으로는 로컬 스토리지 밖에 못 건든다.. 라고 잠정 결론을 내린 상태이다.
const anAtom = atomWithStorage('key', initialValue);
atomWithStorage 함수는 로컬 스토리지에 일치하는 키가 없을 경우 initialValue의 값을 이용해 아톰을 초기화한다. 이때 꼭 기억해두어야 할 점은 키가 없다고 해서 initialValue의 값을 스토리지에 넣어주는 게 아니라는 것이다. 따라서 이때 클라이언트 상태는 존재하지만, 스토리지에 키와 값은 여전히 존재하지 않는 상태이다. 최초로 setAtom을 하면 그제야 처음으로 스토리지에 값을 넣어주고, 이후로는 initialValue가 아닌 스토리지에 있는 값을 참조해오게 된다.
Next.js에서 깡으로 로컬 스토리지에 접근하려면 useEffect를 사용해야 했지만, 이 방식을 사용하면 useEffect 없이도 잘 동작한다. 그래서 처음에는 내부적으로 useEffect 처리를 하고 있는 게 아닐까 생각했다. 근데 실제로 콘솔에 찍어보니까 useEffect보다 먼저 값을 집어오고 있었다! 도대체 어떻게 한 거지... ㄷㄷ
+ 컴포넌트 안에서 이 atomWithStorage 함수를 사용해 아톰을 생성하면 무한 호출 에러가 일어나는 듯하다. 꼭 컴포넌트 밖에서 선언하던가, 아니면 아예 다른 파일에서 선언하고 export 해오는 방식을 사용하도록 하자.
useAtom
const [cart, setCart] = useAtom(cartAtom)
아톰을 만들었으면 사용해야지! 내가 Jotai를 고른 가장 핵심적인 이유이기도 한 useAtom은 useState와 사용하는 법이 거의 동일하다. 그저 useState에 기본값을 넣어주던 곳에 생성한 아톰을 넣어주면 된다.
useState는 반드시 get과 set 쌍으로 이루어진 배열을 반환하지만, Jotai에서는 이를 분리하여 사용할 수도 있다. 가령 장바구니의 경우 어떤 페이지에서는 장바구니에 넣기만 하면 되고, 어떤 페이지에서는 장바구니에 넣은 제품만 확인해야 할 수도 있다. 이럴 때 get과 set 중 필요한 것만 호출해서 사용할 수 있는 것이다.
// getAtom
const cart = useAtomValue(cartAtom)
// setAtom
const setCart = useSetAtom(cartAtom)
이렇게 사용했더니 코드 자체가 좀 더 깔끔해지는 것은 물론이고, 선언하고도 사용하지 않은 변수가 페이지마다 못해도 두세개 씩은 줄어들었다.
Provider와 Store
Jotai는 추상화 되어 확인할 필요도 없는 어느 공간에서 context API의 가능성을 맥시멈으로 빨아먹으며 돌아가고 있다고 ─ 카더라 ─ 한다. 따라서 atom의 값은 글로벌에 존재하는 실제 atom에 저장되는 것이 아닌 provider를 기준으로 저장된다. 이는 곧 참조하는 provider가 다르면 같은 atom을 사용해도 값이 달라질 수 있다는 것이다.
별도로 provider를 지정해주지 않는다면 최상단 Node를 기준으로 provider가 설정되는데, 이를 공식 문서에서는 provider-less mode라고 부르는 듯하다. 이 모드에서 모든 아톰은 모든 컴포넌트에서 동일한 값으로 사용할 수 있다.
여기서부터는 기술 실증은 진행되었지만, 이론으로 확인되지는 않은 부분이다.
const storeA = createStore();
const storeB = createStore();
const CompA = () => {
return (
<>
<Provider store={storeA}>
<CompAA />
</Provider>
<Provider store={storeB}>
<CompBB />
</Provider>
<Link href="/CompB">B 컴포넌트로 가기</Link>
</>
);
};
위에서 atom의 값은 provider를 기준으로 저장된다고 했는데, 이 store가 바로 실제로 atom의 값이 저장되는 공간이 ─ 라고 일단은 생각되어진 ─ 다.
따라서 CompAA와 BB 모두 @/jotaiStaion/atom.ts에다가 생성해놓은 textAtom 아톰을 가져다가 useAtom 하고 있지만, useAtom이 상태값으로 집어오는 건 결국 store 객체 어딘가에 저장된 값인 것 같다. 이걸 확인하기 위해 store 객체를 콘솔에 찍어보았지만, 거기에는 무수히 많은 객체가 있었고ㅡ 들여다보았지만 돌아오는 건 주화입마 뿐이었다.
블로그의 정보
Ayden's journal
Beard Weard Ayden