
Jotai 라이브러리를 사용해서 배열 하나를 통째로 atom으로 관리하면, 아이템 하나만 바뀌어도 그 배열을 구독하는 모든 컴포넌트가 리렌더링된다. 배열 아이템 개수만큼 컴포넌트가 매핑되어 있다면 이 문제는 더 커진다. 이럴 때 아이템별로 독립된 atom을 만들어주는 게 바로 atomFamily 유틸리티다.
그런데 최근 이 atomFamily가 deprecated 상태가 됐다는 걸 알게 됐다. 이유가 궁금해서 소스 코드를 직접 뜯어봤다. atomFamily가 내부적으로 어떻게 메모이제이션을 수행해서 성능을 최적화하는지, 그리고 그 구조 때문에 뒤따르는 메모리 누수(Memory Leak) 문제까지 함께 정리해보았다.
atomFamily를 한마디로 정의하면 메모이제이션이 적용된 아톰 팩토리(Factory)함수이다. 파라미터(매개변수)를 받아서 그에 해당하는 Atom을 생성하거나, 이미 생성된 게 있다면 그걸 반환해주는 역할을 한다.
다음과 같은 대량의 데이터 리스트가 있다고 가정해보자.
// 전체 데이터를 담고 있는 거대한 Atom
const elementsDataAtom = atom([
{ id: 1, data: "A" },
{ id: 2, data: "B" },
// ... 1000개의 데이터
]);
만약 atomFamily 없이 이 리스트를 직접 구독해서 렌더링한다면 어떻게 될까?
const ItemComponent = ({ id }) => {
const elements = useAtomValue(elementsDataAtom);
const myData = elements.find(el => el.id === id);
return <div>{myData.data}</div>;
}
이 방식의 문제는 id: 1의 데이터만 변경되어도 elementsDataAtom 자체가 업데이트된 것이므로, id: 2, id: 3을 보고 있는 모든 컴포넌트가 불필요하게 리렌더링된다는 점이다.
사실 useAtomValue와 selector 패턴(ex. jotai/utils의 selectAtom)을 조합해도 리렌더링 최적화가 가능하다.
다만 이 방식은 컴포넌트 안에서 useMemo로 atom을 만들기 때문에, 그 atom은 해당 컴포넌트 인스턴스에 종속된 지역 atom이다.
즉 같은 id를 보는 컴포넌트가 여러 개 있어도 각자 별도의 atom을 만들어 따로 계산한다. 반면 atomFamily는 같은 id로 호출하면 어디서든 동일한 atom 인스턴스를 반환한다. 즉 같은 id를 구독하는 컴포넌트가 여러 개라도 atom과 그 계산 결과를 공유할 수 있다.
const elementDataAtomFamily = atomFamily((id: number) =>
atom((get) => get(elementsDataAtom).find((el) => el.id === id))
);
const Element1 = () => {
// ✅ 효율적: id=1에 해당하는 특정 atom만 구독
const data1 = useAtomValue(elementDataAtomFamily(1));
return (...);
}
그렇다면 atomFamily는 어떻게 매번 같은 파라미터에 대해 같은 Atom을 반환할까? 내부 구현을 단순화해서 보면 다음과 같은 구조를 띠고 있다.
export function atomFamily(initializeAtom, areEqual) {
// 1. 캐시 저장소 (Key: 파라미터, Value: [아톰, 생성시간])
const atoms: Map<Param, [AtomType, CreatedAt]> = new Map();
const createAtom = (param) => {
// 2. 캐시 조회
let item;
if (areEqual === undefined) {
item = atoms.get(param); // O(1) 조회
} else {
// 커스텀 비교 함수가 있다면 전체 순회
for (const [key, value] of atoms) {
if (areEqual(key, param)) {
item = value;
break;
}
}
}
// 3. 캐시 히트 (Cache Hit)
if (item !== undefined) {
return item[0]; // 기존에 만들어둔 Atom 반환
}
// 4. 캐시 미스 (Cache Miss) -> 새로운 Atom 생성 및 저장
const newAtom = initializeAtom(param);
atoms.set(param, [newAtom, Date.now()]);
return newAtom;
}
// ... remove 로직 등
return createAtom;
}
핵심은 내부에 존재하는 Map 객체에 있다.
elementDataAtomFamily(1)을 호출하면 내부 Map을 뒤지고, 없으면 initializeAtom을 실행해 새 Atom을 만들고 Map에 저장한다.elementDataAtomFamily(1)을 호출하면, Map에 저장된 그 Atom을 그대로 반환한다.하지만 이 완벽해 보이는 구조에는 심각한 함정이 있다. 바로 캐시된 Atom이 자동으로 삭제되지 않는다는 점이다.
atomFamily를 이용해 id가 1~100인 아이템들을 렌더링하면, 내부 Map에 Atom 100개 생성됨elementsDataAtom에서는 id: 1이 사라졌고, 화면의 Element1 컴포넌트도 언마운트됨. 그러나 atomFamily 내부의 Map에는 id: 1에 해당하는 Atom이 여전히 남아있다.이것이 바로 메모리 누수다. 가비지 컬렉터(GC)가 이 Atom들을 청소하지 못하는 이유는 atomFamily 내부의 Map이 이들을 강하게 참조하고 있기 때문이다. 리스트의 아이템이 자주 추가/삭제되는 서비스라면, 시간이 지날수록 불필요한 Atom들이 메모리에 계속 쌓이게 된다.
이 문제를 해결하려면 개발자가 명시적으로 remove를 호출해줘야 한다.
// 아이템이 삭제될 때 반드시 실행해줘야 함
elementDataAtomFamily.remove(1);
하지만 비즈니스 로직 곳곳에서 아이템이 삭제되는 시점을 포착해 일일이 remove를 호출하는 건 실수하기 쉽고 번거로운 작업이다. 결국 remove()만으로는 이 구조적 문제를 완전히 해결하지 못하는 셈이다.
atomFamily는 리스트 렌더링 성능 최적화를 위한 강력한 도구이지만, 그 대가로 메모리 관리의 책임을 개발자에게 지운다.
실제로 Jotai는 jotai/utils의 atomFamily를 deprecated 처리하고, 이 기능을 jotai-family라는 별도 패키지로 분리했다. 공식적으로 밝힌 이유는 "코어 라이브러리를 가볍게 유지하기 위한" 구조적 결정에 가깝지만, 지금까지 살펴본 메모리 누수 문제 역시 atomFamily를 쓸 때 반드시 감안해야 할 구조적 약점이라는 사실은 변하지 않는다.
동적으로 생성되고 사라지는 데이터가 매우 많은 환경이라면, atomFamily(혹은 jotai-family)를 쓰되 메모리 누수 대응 전략을 확실히 세우거나, selectAtom처럼 컴포넌트 스코프에 종속되는 다른 유틸을 함께 검토해보는 걸 추천한다.