개요추측해보기주요 컨셉Atomderived Atom ( 리코일에서의 selector )진짜 뜯어보기 (for Beginner)궁금1. Store가 있겠지? (해쉬맵으로 관리되니까)궁금. 메모리 누수 관련 WeakMap메모리 누수란?WeakMap이란?어떤 게 자동으로 GC 되는 걸까?궁금. 해쉬맵이면 key가 있어야 하는데 어떻게 생성할까?궁금2. atom의 생명주기궁금4. 전파 - flushCallbackssetter가 상태 변화를 트리거 하잖아요더 깊게! ( TBD )
개요
atomic
한 상태관리를 위한 상태관리 라이브러리jotai
기본 동작원리에 대해 이해한다.
recoil
과 동일한 컨셉인 만큼 리코일과 비교하면서 이해 해보자
추측해보기

- 상태가 변하면 구독중인 곳을 모두 업데이트 해줘야 하므로, 옵저버 패턴을 쓸 거 같다.
atom
을 선언하는 건 옵저버 생성을 의미할 것이다.
useAtom
은 리액트를 위한 것으로, 상태 변경이 일어나면 리렌더링을 트리거 시키기 위한 용도다.- React 에서는 UI 업데이트를 위해선 상태를 써야 한다.
- 따라서 내부적으로는
useEffect
와useState
를 사용할 것이다. - 옵저버의
listener
에setState
를 시키는 로직을 등록할 것이다.
atom
값이 바뀌면 등록된listner
를 모두 호출하게 되고, 그러면 각 컴포넌트 내부의 상태가 업데이트 되는 거니까 컴포넌트도 리렌더링 될 것이다.
주요 컨셉
jotai
의 창시자의 트윗을 통해 jotai의 컨셉과 구현에 대해 대략적으로 알 수 있다.atomic 한 상태라는 점에서 recoil과의 유사점도 많이 있다.
Atom

Daishi Kato on Twitter / X
Demystifying the internal of jotai https://t.co/16ydaf1Mxv, an atomic state management solution for @ReactJS.Here's a simplified version of the core. Is it readable? pic.twitter.com/TBpFDYNkeP— Daishi Kato (@dai_shi) January 22, 2022

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

Daishi Kato on Twitter / X
While nobody requested 😆, here's a longer version of the simplified jotai core. This version supports derived atoms. Hope someone enjoys it! https://t.co/gPLLSS5WCe pic.twitter.com/bpMLlyGAp6— Daishi Kato (@dai_shi) January 24, 2022

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

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에선
Store
가 weakMap
을 쓰는 것이 중요한 부분이다.어떤 게 자동으로 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가 될 수 없고, reference
가 key
가 되어야 한다.// 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
가 있고 따라서 weakMap
의 key
가 될 수 있다.궁금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의 구현
- 리렌더링 최적화