Jotai가 atom 값을 어떻게 비교하는지, 언제 selectAtom이 적절한 해법인지, 언제 구조적 공유가 더 나은 선택인지 설명합니다.
지난주에 jotai-tanstack-query를 기본 useQuery와 비교 벤치마킹하다가, no-op refetch 상황에서 한 컴포넌트가 React Query 대응 코드보다 44배 더 많이 커밋되는 것을 봤습니다. 쿼리도 같고, 응답도 같고, 렌더 결과도 같았는데, React 작업만 훨씬 많았습니다. 원인은 jotai가 atom 값을 비교하는 방식에 있는 아주 기계적인 한 가지 요소였고, 해결책은 팀들이 selectAtom으로 답하려고 하는 경우를 자주 보지만 사실 그러면 안 되는 문제입니다.
이 글은 그 메커니즘이 무엇인지, 언제 selectAtom이 올바른 답인지, 그리고 언제 아닌지를 다룹니다.
Jotai는 새로운 값이 이전 값과 Object.is 기준으로 다를 때 파생 atom 값을 구독자에게 전파합니다. 그게 전부입니다. 얕은 비교도 없고, 구조 비교도 없고, 일반적인 atom(g => ...)에 사용자 정의 비교기를 끼워 넣는 방법도 없습니다.
그래서 원시값에는 완벽하게 잘 동작합니다:
const countAtom = atom((g) => g(cartAtom).items.length)
length는 숫자입니다. 연속된 두 번의 읽기 결과가 7이면, jotai는 알림을 건너뜁니다. 공짜입니다.
그리고 안정적인 객체 참조 에도 잘 동작합니다:
const userAtom = atom((g) => g(rawAtom).user)
rawAtom의 값이 업데이트 사이에서도 user를 같은 참조로 유지한다면, jotai는 Object.is가 참이라고 판단하고 건너뜁니다. 이것도 공짜입니다.
문제는 읽기에서 매번 새 값을 구성하는 순간 바로 발생합니다:
const summaryAtom = atom((g) => ({
count: g(cartAtom).items.length,
total: sum(g(cartAtom).items),
}))
이 객체 리터럴은 읽을 때마다 새로 만들어집니다. Object.is({...}, {...})는 false입니다. count와 total이 바뀌지 않았더라도, cartAtom의 모든 알림은 summaryAtom의 모든 소비자를 리렌더링시킵니다.
같은 문제가 jotai-tanstack-query 벤치마크에도 나타납니다. 기반 atom은 전체 QueryObserverResult를 들고 있고, TanStack은 no-op refetch를 포함한 모든 observer 알림마다 새로운 래퍼 객체를 만듭니다. Jotai는 새 객체를 보고 구독자를 실행하고, React는 커밋합니다. 반면 useQuery는 기반 QueryObserver를 구독하고 컴포넌트가 실제로 읽는 필드만 꺼내기 때문에, 이 경우보다 44배 더 많이 커밋됩니다.
selectAtomjotai/utils의 selectAtom(source, selector, equalityFn = Object.is)는 읽기 단계의 중복 제거 원시 도구입니다. 이전에 선택된 값을 저장해 두고, 상위 알림이 올 때마다 equalityFn(prev, next)를 실행한 뒤, 같다면 이전 참조를 반환해서 하위의 Object.is 검사가 통과하도록 만듭니다.
요약값 예시에서는 이렇게 됩니다:
import { selectAtom } from 'jotai/utils'
import { shallow } from 'jotai/utils'
const summaryAtom = selectAtom(
cartAtom,
(cart) => ({ count: cart.items.length, total: sum(cart.items) }),
shallow,
)
작동은 합니다. 하지만 거의 대부분의 경우 올바른 선택은 아닙니다. 더 깔끔한 버전은 파생 atom 두 개입니다:
const countAtom = atom((g) => g(cartAtom).items.length)
const totalAtom = atom((g) => sum(g(cartAtom).items))
둘 다 원시값입니다. 둘 다 Object.is 아래에서 공짜입니다. 소비자는 useAtomValue를 두 번 호출해 둘 다 읽으면 되고, 실제로 둘 중 하나가 바뀔 때만 리렌더링됩니다. selectAtom도 없고, shallow도 없고, 유지해야 할 equality 함수도 없습니다.
일반 규칙은 이렇습니다: 프로젝션이 원시값이나 원본에 이미 존재하는 참조를 반환한다면, 일반 파생 atom을 쓰면 됩니다. 새 래퍼를 만든다면, 프로젝션을 여러 개의 원시 atom으로 쪼개세요. 정말로 그럴 수 없을 때만 selectAtom이 대안입니다.
selectAtom이 실제로 값을 하는 경우쪼개기가 도움이 되지 않고 selectAtom + shallow가 진짜 해결책인 경우는 두 가지입니다:
리스트에 대한 .filter / .map. 모든 요소의 참조가 안정적이어도 매번 새 배열이 만들어집니다:
const activeIdsAtom = selectAtom(
usersAtom,
(users) => users.filter((u) => u.active).map((u) => u.id),
shallow,
)
이건 “쪼갠 버전”이 없습니다. 중복 제거 훅이 필요하고, 그걸 가능하게 하는 것이 shallow입니다.
하위에서 정체성이 중요한 집계값. 소비자가 그 프로젝션을 memoized child나 context에 넘긴다면, 내용이 바뀌지 않았을 때 래퍼가 같은 정체성을 유지해야 할 필요가 실제로 있습니다. 그 경우에도 종종 더 나은 답은 개별 atom 읽기 위에 호출 지점에서 useMemo를 쓰는 것이지만, selectAtom + shallow는 정당한 선택입니다.
이게 전부입니다. 두 경우뿐입니다. 나머지는 모두 쪼개는 쪽을 우선하세요.
selectAtom이 해결하지 못하는 경우이제 원래 문제로 돌아가 봅시다. jotai-tanstack-query가 useQuery보다 44배 더 많이 커밋되는 이유는 소스 atom (QueryObserverResult를 들고 있는 atom)이 모든 observer 알림마다 새 객체를 내보내기 때문입니다. 모든 소비자를 selectAtom으로 감싸면 읽는 위치에서는 문제를 해결할 수 있지만, 구독자가 N명이면 그 비용을 N번 치르게 되고, 곳곳에 selectAtom을 써야 합니다.
실제 해결책은 상류에 있습니다. React Query는 이미 내부적으로 그렇게 합니다. 구조적 공유(structuralSharing: true, 기본 활성화)라는 기능이 있어서, 이전 응답과 새 응답을 비교하며 replaceEqualDeep 순회를 수행합니다. 바뀌지 않은 서브트리는 이전 참조를 유지하고, 바뀐 서브트리는 새 참조를 가집니다. 그 결과 하류 소비자는 자신이 관심 있는 트리 부분에 대해 안정적인 참조를 보게 되고, 모든 중복 제거에 Object.is만으로 충분해집니다.
이건 selectAtom 없이도 순수 jotai에서 재현할 수 있습니다:
function atomWithStructuralSharing<T>(initial: T) {
const inner = atom(initial)
return atom(
(g) => g(inner),
(g, s, next: T) => s(inner, replaceEqualDeep(g(inner), next) as T),
)
}
set의 인터셉터는 이전 값과 새 값을 순회하면서 구조적으로 같은 곳에는 이전 참조를 다시 끼워 넣습니다. 그러고 나면 atom(g => g(users)[3].name), atom(g => g(users).filter(u => u.active))(이 경우는 여전히 새 배열입니다. 위를 보세요), atom(g => g(users).length) 같은 모든 하위의 일반 파생 atom이 공짜로 그 이점을 얻습니다.
최소한의 replaceEqualDeep 구현은 대략 서른 줄 정도이고, TanStack의 구현은 @tanstack/query-core에 있으며, 이 경로를 택한다면 복사해 두고 싶을 까다로운 경우들(class instances, Dates)을 처리합니다.
atomWithStructuralSharing을 꺼내 들기 전에, 먼저 atom을 더 작은 atom들로 분해할 수 있는지 자문해 보세요. 구조적 공유는 실제 도구이지만, 특정한 상태 형태에서만 제값을 합니다. 형태를 따라가 봅시다:
원시 atom. Object.is가 동작합니다. 할 일이 없습니다.
이름 있는 필드를 가진 객체 atom. 필드별 atom을 파생시키세요:
const userAtom = atom({ name: '', email: '', age: 0 })
const nameAtom = atom((g) => g(userAtom).name)
const emailAtom = atom((g) => g(userAtom).email)
리프는 원시값입니다. 중복 제거는 공짜입니다. 매 업데이트마다 전체 객체 리터럴을 통째로 교체하더라도, 바뀐 필드를 소비하는 컴포넌트만 리렌더링됩니다. 여기서 atomWithStructuralSharing은 아무것도 더해주지 않습니다.
리스트 atom. 여기서 분해가 어려워집니다. 원소 수가 동적이라 row0Atom, row1Atom처럼 손으로 쓸 수 없습니다. 실제 선택지는 세 가지입니다:
하나의 리스트 atom, 공유 없음. 가장 단순합니다. 모든 업데이트가 리스트를 구독하는 모든 소비자(혹은 원시값으로 귀결되지 않는 그 위의 모든 파생 atom)를 리렌더링합니다. 작은 리스트이거나 구독자가 적다면 괜찮습니다.
하나의 리스트 atom + 구조적 공유. 쓰기는 편합니다(set(usersAtom, newList)). 대신 매 업데이트마다 replaceEqualDeep 순회를 수행합니다. 행 단위 파생 atom(atom(g => g(usersAtom)[3]))은 참조 안정성을 얻고, 실제로 바뀐 행만 구독자에게 전파됩니다. React Query가 내부적으로 하는 방식이 바로 이것입니다.
id 배열 + id 키 기반 atomFamily. 정규화된 저장소 접근 방식입니다:
const userIdsAtom = atom<string[]>([])
const userByIdAtom = atomFamily((id: string) => atom<User | null>(null))
순서와 멤버십은 id 배열에 있고, 각 행은 자기만의 atom입니다. 업데이트는 외과적으로 이루어집니다(set(userByIdAtom(id), nextRow)). walker가 필요 없고, 순회 비용도 없습니다. 대신 데이터 계층이 정규화를 해야 합니다. 서버에서 리스트를 받았다면 직접 펼쳐야 합니다. id 배열을 쓰고, 각 행의 atom도 써야 합니다. 또한 atomFamily의 수명 주기도 관리해야 합니다(행이 제거될 때 .remove(id)를 호출하거나, 남아 있는 엔트리를 감수해야 합니다).
이 세 가지의 트레이드오프는 이렇습니다: 1번은 코드가 가장 적지만 리렌더링이 가장 많고, 2번은 쓰기당 순회 비용과 맞바꾸어 행 단위 중복 제거를 얻고, 3번은 정규화된 데이터 계층과 맞바꾸어 외과적 업데이트를 얻습니다.
이미 React Query를 쓰고 있다면, 2번은 공짜입니다. RQ가 대신 해주고, 여러분은 리스트 atom을 자연스럽게 작성하면 됩니다. 소켓이나 직접 만든 fetch 코드로 atom에 값을 넣고 있다면, 3번이 장기적으로 더 깔끔한 형태인 경우가 많습니다. 중복 제거를 매 쓰기마다 실행되는 walker가 아니라 atom 그래프 자체로 밀어 넣기 때문입니다.
제 생각엔 이유가 두 가지입니다.
첫째, atom은 의도적으로 최소한의 원시 도구입니다. 내장 구조적 공유 walker는 일반 객체, class instances, Dates, Maps, Sets를 어떻게 다룰지 입장을 정해야 하고, 원시값이나 1분에 한 번 바뀌는 값만 들고 있는 경우까지 포함해 모든 사용자에게 쓰기당 비용을 강제하게 됩니다. “Recoil보다 더 작다”를 내세우는 라이브러리의 기본값으로는 잘못된 선택입니다.
둘째, 올바른 답은 전적으로 데이터 계층에 달려 있습니다. 소스가 React Query라면 RQ가 이미 처리합니다. 소스가 JSON 스냅샷을 밀어 넣는 WebSocket이라면, 입력 시점에 replaceEqualDeep가 필요합니다. 소스가 Zustand 스타일 저장소라면, setter 안에 있어야 합니다. Jotai가 이 모두에 맞는 단일 훅을 제공할 수는 없지만, 조합 방식(atomWithStructuralSharing, 혹은 atomEffect, 혹은 그냥 업데이트 함수 안에서 처리하기)은 두 줄이면 되고 데이터가 실제로 앱에 들어오는 위치에 둘 수 있습니다.
그래서 selectAtom은 소스를 고칠 수 없을 때의 읽기 측 원시 도구입니다. 구조적 공유는 소스를 고칠 수 있을 때의 쓰기 측 규율입니다. 대부분의 팀은 두 번째를 해야 할 때 첫 번째로 손이 갑니다.
jotai-tanstack-query를 쓰고 있다면, 절대 useAtomValue(rawQueryAtom)를 직접 호출하지 마세요. 실제로 읽는 필드를 파생시키세요.
벤치마크에서 44배를 1배로 만든 것이 바로 그것이었습니다. 중간에 파생 atom 하나만 두면, jotai는 실제로 사용할 수 있는 Object.is를 얻게 됩니다.