jotai 뜯어보기

비고
Tags
deep dive
Select
recommand

개요

  • atomic한 상태관리를 위한 상태관리 라이브러리 jotai 기본 동작원리에 대해 이해한다.
  • recoil과 동일한 컨셉인 만큼 리코일과 비교하면서 이해 해보자

추측해보기

notion image
  • 상태가 변하면 구독중인 곳을 모두 업데이트 해줘야 하므로, 옵저버 패턴을 쓸 거 같다.
  • atom을 선언하는 건 옵저버 생성을 의미할 것이다.
  • useAtom은 리액트를 위한 것으로, 상태 변경이 일어나면 리렌더링을 트리거 시키기 위한 용도다.
    • React 에서는 UI 업데이트를 위해선 상태를 써야 한다.
    • 따라서 내부적으로는 useEffectuseState를 사용할 것이다.
    • 옵저버의 listenersetState를 시키는 로직을 등록할 것이다.
  • atom 값이 바뀌면 등록된 listner를 모두 호출하게 되고, 그러면 각 컴포넌트 내부의 상태가 업데이트 되는 거니까 컴포넌트도 리렌더링 될 것이다.

주요 컨셉

jotai의 창시자의 트윗을 통해 jotai의 컨셉과 구현에 대해 대략적으로 알 수 있다.
atomic 한 상태라는 점에서 recoil과의 유사점도 많이 있다.

Atom

TwitterTwitterDaishi Kato on Twitter / X
notion image
앞서 추측했던 것과 크게 다르지 않다!
접근은 잘 한 거 같다.
 
대략적인 코드를 보면 atomStateMap 이라는 해쉬맵을 이용해서 상태를 구분,관리하고 있다.
전달되는 atom이 해쉬맵에 접근할 수 있는 key 역할을 하고, 생성 시 listeners를 생성한다.
옵저버 패턴인 점도 맞다.
그리고 atom 자체로는 react와 상관없고, 따라서 react의 state 역할을 하는 게 아니다.
 
상태로서 작용하려면 useAtom 훅을 통해서 react 생태계로 들어오게 된다.
리액트의 컨셉인 상태를 통해 UI를 트리거한다는 점을 거스를 순 없으므로 내부에선 useStateuseEffect를 이용하고 있음을 확인할 수 있고,
내부에서 useState로 상태를 선언하고, 이를 업데이트 하는 함수를 만들며,
useEffect를 통해 리스너에 등록하고 해제하는 것을 컴포넌트 라이프 사이클과 연관시키는 것도 예상했던 것과 일치한다!
const atomState = { value: atom.init, listeners: new Set(), }
📌
여기서 알 수 있는 점.
atom 자체로는 state가 아니다. 일반 객체다.
따라서 상태로 쓰지 않고, 단순히 현재 시점의 값을 참조하기만 하고 싶은 경우에도 그렇게 가능하다. 실제로 jotai 에서도 이 방법을 제공한다. const justValue = atom((get) => get(someAtom)) useEffect, useState를 활용하여 이러한 생명주기를 리액트 컴포넌트와 연결 시킨다.
 

derived Atom ( 리코일에서의 selector )

TwitterTwitterDaishi Kato on Twitter / X
notion image
이번에는 derived atom 이라고 표현했는데, 파생된 atom 이라는 뜻이고,
이는 atom의 값을 참조해서 새로운 atom 을 만들어내는 경우를 뜻한다.
recoil이 익숙하다면 recoilselector를 떠올리면 정확히 동일한 목적이다.
 
참조하고 있는 atom의 값이 바뀌면 이를 참조해서 연산하는 값도 업데이트 되어야 한다.
따라서 이 또한 리스너에 포함시켜야 한다.
  • atom을 useAtom으로 상태로써 사용하고 있는 것은 listners에 포함되고,
  • atom을 참조하고 있는 derived atomdependents에 포함된다.
const atomState = { value: atom.init, listeners: new Set(), dependents: new Set(), }
 
그리고 atom의 값이 변경되면 이 listnersdepedents모두 순회하면서 호출해줘야 하는데, 이 동작은 notify가 수행한다. 수도 코드로 좀 더 요약하면 아래와 같다.
// sudo code notify = () => { dependents.forEach( 모두 실행 ) // for derived atom listners.forEach( 모두 실행 ) // for components that using atom as a state }
 
notion image
derived atom을 이해하기 좋은 예시다. 딱 recoil의 selector가 떠오른다.
  • 버튼은 countAtom 이랑만 연동되어 있다.
  • doubleAtom은 countAtom을 이용해서 파생된 derived atom이다.
  • 버튼을 눌러 countAtom이 수정되면 dependents가 쭉 호출되며 notify 되고, 따라서 doubleAtom의 값도 수정되어 DobleCounter 컴포넌트도 업데이트 되는 것을 확인할 수 있다.
 

진짜 뜯어보기 (for Beginner)

라이브러리를 사용하는 입장에서는 위에서 이해한 만큼만 제대로 이해해도 jotai의 특성을 이용해서 충분히 잘 이용할 수 있고, 가능한 접근방식도 떠올릴 수 있다고 생각된다.
여기서 조금만 더 나아가서 실제 코드를 보면서 궁금증을 좀 더 해소시켜 보자.

궁금1. Store가 있겠지? (해쉬맵으로 관리되니까)

여러 군데에서 동일한 atom을 참조하고 이용할 수 있도록 하기 위해서 key-value 해쉬맵으로 관리된다.
사용자 입장에서는 아무대서나 atom을 선언하고 쓰면 돼서 개별 관리라는 느낌도 들지만,
실제로는 관리는 중앙집중형 방식으로 모든 atom이 한 곳에서 관리된다는 것을 유추할 수 있고, 따라서 이런 책임을 맡고 있는 Store가 있을 것을 쉽게 예상할 수 있고, 실제로도 있다.
type Store = { get: <Value>(atom: Atom<Value>) => Value set: <Value, Args extends unknown[], Result>( atom: WritableAtom<Value, Args, Result>, ...args: Args ) => Result sub: (atom: AnyAtom, listener: () => void) => () => void }
const buildStore = ( // store state atomStateMap: AtomStateMap = new WeakMap(), mountedMap: MountedMap = new WeakMap(), invalidatedAtoms: InvalidatedAtoms = new WeakMap(), changedAtoms: ChangedAtoms = new Set(), mountCallbacks: Callbacks = new Set(), unmountCallbacks: Callbacks = new Set(), storeHooks: StoreHooks = {}, // atom intercepters atomRead: AtomRead = (atom, ...params) => atom.read(...params), atomWrite: AtomWrite = (atom, ...params) => atom.write(...params), atomOnInit: AtomOnInit = (atom, store) => atom.unstable_onInit?.(store), atomOnMount: AtomOnMount = (atom, setAtom) => atom.onMount?.(setAtom), // building-block functions ...buildingBlockFunctions: Partial< [ ensureAtomState: EnsureAtomState, flushCallbacks: FlushCallbacks, recomputeInvalidatedAtoms: RecomputeInvalidatedAtoms, readAtomState: ReadAtomState, invalidateDependents: InvalidateDependents, writeAtomState: WriteAtomState, mountDependencies: MountDependencies, mountAtom: MountAtom, unmountAtom: UnmountAtom, ] > ): Store
Store에서 atom에 대한 해쉬맵만 있을 거라 생각했는데, 생각보다 많은 관리요소가 있다.
  • atom 상태와 관련된 atomStateMap - 이게 우리가 생각한 상태를 관리하는 해쉬맵
  • 컴포넌트 마운트 여부를 관리하는 mountedMap
    • mount, unmount 와 연관된다!
  • invalidatedAtoms 로 업데이트가 필요한 atom 관리
  • 상태가 변화했기 떄문에 전파가 필요한 상태들은 changedAtoms

궁금. 메모리 누수 관련 WeakMap

Store에서 보면 해쉬맵은 모두 WeakMap을 사용하고 있고, jotai 라이브러리에서도 강조하고 있다.
WeakMap은 뭐고, 왜 메모리 누수 예방이 된다는 걸까?

메모리 누수란?

메모리 누수(Memory Leak) : 더 이상 필요하지 않은 메모리가 해제되지 않고 계속 남아있는 현상
즉, 프로그램이 사용한 메모리를 해제하지 않아서 메모리 사용량이 점점 증가하고, 결국 시스템 성능이 저하되거나 프로그램이 비정상적으로 종료될 수 있다.

WeakMap이란?

WeakMap객체를 키로 사용하는 Map의 한 종류로, 키로 사용된 객체가 가비지 컬렉션(GC)의 대상이 되면 자동으로 제거된다. 더 이상 참조되지 않는 객체는 WeakMap에서도 자동으로 사라지므로 메모리 누수를 방지할 수 있다.
 
atom을 사용하는 쪽이 없더라도, 일반 전역 해쉬맵에 담아놨다면 GC 되지 않는다. (map이 계속 참조하고 있으니까 사용중이라고 판단하게 됨. ⇒ 따라서 사용중이지 않는데도 메모리에 남아있게 되어 메모리 누수가 발생.)
현재 해쉬맵은 오로지 atom의 관리 목적으로 있는 해쉬맵이기 때문에 weakMap을 쓰는 것이 좋다.
  • 관리하는 쪽 : weak reference
  • 사용하는 쪽 : strong reference
이렇게 해서 strong reference가 살아있는 동안은 메모리에 유지되고, 사용하는 곳이 없어지면 자동으로 GC의 대상이 되도록 하는 것으로, 상태 관리에 있어서 jotai에선 StoreweakMap을 쓰는 것이 중요한 부분이다.

어떤 게 자동으로 GC 되는 걸까?

사실 모듈 레벨에서 정의한 atom은 앱이 종료될 때까지 거의 GC 되지 않는다.
atom 자체가 소멸되어야 GC 되는 것이기 때문이다.
// 이런 atom은 보통 앱이 종료될 때까지 GC되지 않음 const globalAtom = atom(0)
function Component() { // 컴포넌트가 unmount되면 이 atom은 GC 대상이 됨 const dynamicAtom = atom(0) useAtom(dynamicAtom) }
const atomFamily = atomFamily((param: number) => atom(param)) function Component() { // 더 이상 참조되지 않는 atom은 GC 대상이 될 수 있음 const someAtom = atomFamily(1) // atomFamily는 자체 cleanup 메커니즘도 제공 atomFamily.setShouldRemove((createdAt, param) => { return Date.now() - createdAt > 1000 * 60 }) }

궁금. 해쉬맵이면 key가 있어야 하는데 어떻게 생성할까?

weakMap 이므로 일반 value는 key가 될 수 없고, referencekey가 되어야 한다.
// atom 객체가 생성됨 // 이 시점에는 아직 WeakMap에 등록되지 않음 const countAtom = atom(0)
atom 생성 자체는 위와 같이 하면 되는데, 그렇다고 weakMap에 바로 등록되는 건 아니다.
store 내에서 사용될 때가 있어야 ensureAtomState를 통해서 weakMap에 등록된다.
const ensureAtomState = buildingBlockFunctions[0] || ((atom) => { if (import.meta.env?.MODE !== 'production' && !atom) { throw new Error('Atom is undefined or null') } let atomState = atomStateMap.get(atom) if (!atomState) { atomState = { d: new Map(), p: new Set(), n: 0 } atomStateMap.set(atom, atomState) // 상태 해쉬맵에 추가! atomOnInit?.(atom, store) } return atomState as never })
atom을 그대로 key로 쓰는 모습을 볼 수 있다.
atom: Atom<Value> 타입이고,
export interface Atom<Value> { toString: () => string read: Read<Value> unstable_is?(a: Atom<unknown>): boolean debugLabel?: string /** * To ONLY be used by Jotai libraries to mark atoms as private. Subject to change. * @private */ debugPrivate?: boolean /** * Fires after atom is referenced by the store for the first time * This is still an experimental API and subject to change without notice. */ unstable_onInit?: (store: Store) => void }
이렇게 객체이므로 reference가 있고 따라서 weakMapkey가 될 수 있다.

궁금2. atom의 생명주기

type Mounted = { // 리스너 함수들을 뜻한다. - listners readonly l: Set<() => void> // 내가 참조하고 있는 atom들을 뜻한다. - dependencies readonly d: Set<AnyAtom> // 나를 참조하고 있는 atom들을 뜻한다. - dependents readonly t: Set<AnyAtom> // unmount 될 때 호출할 함수를 뜻한다. u?: () => void }
우선 mounted 타입을 이해해야 한다.
여기에 전파를 해줘야 하는 리스너들이 관리되기 떄문이다.
  • 이 atom을 사용하고 있는 것은 l(리스너)에 모이므로 값이 바뀌면 전파해줘야 하고,
  • 이 atom을 참조하고 있는 것은 t(dependents)에 담기고 역시 전파 해줘야 한다.
 
const mountAtom = buildingBlockFunctions[7] || ((atom) => { // atom이 있는지 확인하고 없으면 생성해서 atomStateMap 해쉬맵에 넣음 const atomState = ensureAtomState(atom) // 현재 마운트 되어 사용중인지 확인 let mounted = mountedMap.get(atom) // 마운트 되지 않았다면 마운팅 처리하면서 mountedMap에 넣음. if (!mounted) { // recompute atom state readAtomState(atom) // mount dependencies first for (const a of atomState.d.keys()) { const aMounted = mountAtom(a) aMounted.t.add(atom) } // mount self mounted = { l: new Set(), d: new Set(atomState.d.keys()), t: new Set(), } mountedMap.set(atom, mounted) storeHooks.m?.(atom) if (isActuallyWritableAtom(atom)) { ... } return mounted })
마운트 할 때 atom이 등록되지 않은 상태라면 생성해서 등록하며,
마운트 되지 않은 상태라면 mounted를 생성하고 mountedMap에도 등록하며, 마운팅 시 호출되는 훅들도 호출한다.
const unmountAtom = buildingBlockFunctions[8] || ((atom) => { const atomState = ensureAtomState(atom) let mounted = mountedMap.get(atom) // 마운트 되어있던 상태고, 상태를 쓰는 곳과, 상태를 참조하는 곳이 없다는 것이 확인되면 // unmount 처리한다. if ( mounted && !mounted.l.size && !Array.from(mounted.t).some((a) => mountedMap.get(a)?.d.has(atom)) ) { // unmount 될 때 호출할 함수 등록한 게 있다면 flush 할 때 호출될 수 있도록 추가 if (mounted.u) { unmountCallbacks.add(mounted.u) } // unmount self mounted = undefined mountedMap.delete(atom) storeHooks.u?.(atom) // unmount dependencies // 현재 atom이 참조하고 있던 것들도 다 해제 시킴 for (const a of atomState.d.keys()) { const aMounted = unmountAtom(a) aMounted?.t.delete(atom) } return undefined } return mounted })
마운트는 되어 있으나 리스너dependents가 하나도 없다는 의미는 이제 해당 atom을 사용하는 곳이 없어졌다는 의미이므로 unmount 처리해도 된다.
따라서 atom을 사용하는 컴포넌트가 unmount 될 때마다 호출되지만 이 세가지를 검사하고 unmount 처리하는 것을 볼 수 있다.

궁금4. 전파 - flushCallbacks

const flushCallbacks = buildingBlockFunctions[1] || (() => { let hasError: true | undefined let error: unknown | undefined // 중간에 에러가 발생하더라도 나머지 작업을 다 처리한 후에 에러를 다루기 위한 catch 처리 const call = (fn: () => void) => { try { fn() } catch (e) { if (!hasError) { hasError = true error = e } } } // changed, unmount, mount 중에 호출해야 하는 게 있다면 모두 호출 // 콜백 호출로 인해 또 바뀌는 changed가 있을 수 있기 떄문에 // do-while로 계속적으로 반복 do { if (storeHooks.f) { call(storeHooks.f) } // callbacks에 호출해야 하는 콜백들 모두 모으기 const callbacks = new Set<() => void>() const add = callbacks.add.bind(callbacks) changedAtoms.forEach((atom) => mountedMap.get(atom)?.l.forEach(add)) changedAtoms.clear() unmountCallbacks.forEach(add) unmountCallbacks.clear() mountCallbacks.forEach(add) mountCallbacks.clear() // callbacks 모두 호출! => 전파! callbacks.forEach(call) if (changedAtoms.size) { recomputeInvalidatedAtoms() } } while ( changedAtoms.size || unmountCallbacks.size || mountCallbacks.size ) // 작업 처리 과정에서 에러가 있었다면 이제 throw 해서 다룰 수 있도록 하기 if (hasError) { throw error } })
storeHooks.f은 나중에 다루고, 우리가 알고싶은 상태 전파 로직에 대해서 주석을 달았다.
결론부터 말하면 flushCallbacks를 호출함으로써 전파가 필요한 changed, mount, unmount 콜백들이 모두 일괄로 호출된다. 콜백들이 호출되는 과정에서 생기는 변화 또한 처리할 수 있도록 do-while로 반복문으로 처리된다.

setter가 상태 변화를 트리거 하잖아요

const store: Store = { get: (atom) => returnAtomValue(readAtomState(atom)), set: (atom, ...args) => { try { return writeAtomState(atom, ...args) } finally { recomputeInvalidatedAtoms() // 의존성 체크 flushCallbacks() // 전파! } }, sub: (atom, listener) => { const mounted = mountAtom(atom) const listeners = mounted.l listeners.add(listener) flushCallbacks() // 전파! return () => { listeners.delete(listener) unmountAtom(atom) flushCallbacks() // 전파! } }, }
flushCallbacks가 호출되는 로직은 여러군데 있지만,
대표적으로 setter를 보면 처리 후에 flushCallbacks를 호출하면서 전파를 일으킨다.
상태를 변화시킨 후, 변화가 생겼으니까 전파를 시켜주는 것이다.
subscription도 마찬가지!
 

더 깊게! ( TBD )

누구 더 궁금한 사람?!
  • 비동기 처리
  • Promise 처리 방식
  • Suspense 통합
  • 업데이트 최적화
  • 불필요한 재계산 방지
  • 의존성 그래프 최적화
  • React 통합
  • useAtom의 구현
  • 리렌더링 최적화