← cd ..

atomFamily의 동작 원리 분석, 그리고 deprecated된 이유

$ stat atom-family-deprecated.md
date: 2026-02-02  |  tags: jotai, react, memoization  |  4min read
$ cat atom-family-deprecated.md

atom-family

Jotai 라이브러리를 사용해서 배열 하나를 통째로 atom으로 관리하면, 아이템 하나만 바뀌어도 그 배열을 구독하는 모든 컴포넌트가 리렌더링된다. 배열 아이템 개수만큼 컴포넌트가 매핑되어 있다면 이 문제는 더 커진다. 이럴 때 아이템별로 독립된 atom을 만들어주는 게 바로 atomFamily 유틸리티다.

그런데 최근 이 atomFamily가 deprecated 상태가 됐다는 걸 알게 됐다. 이유가 궁금해서 소스 코드를 직접 뜯어봤다. atomFamily가 내부적으로 어떻게 메모이제이션을 수행해서 성능을 최적화하는지, 그리고 그 구조 때문에 뒤따르는 메모리 누수(Memory Leak) 문제까지 함께 정리해보았다.


atomFamily란?

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/utilsselectAtom)을 조합해도 리렌더링 최적화가 가능하다.

다만 이 방식은 컴포넌트 안에서 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 인스턴스는 유지되므로 상태가 초기화되지 않고 유지될 수 있는 것이다.

치명적인 단점: 메모리 누수 (Memory Leak)

하지만 이 완벽해 보이는 구조에는 심각한 함정이 있다. 바로 캐시된 Atom이 자동으로 삭제되지 않는다는 점이다.

  1. atomFamily를 이용해 id가 1~100인 아이템들을 렌더링하면, 내부 Map에 Atom 100개 생성됨
  2. 사용자가 id 1번 아이템을 삭제
  3. 원본 데이터인 elementsDataAtom에서는 id: 1이 사라졌고, 화면의 Element1 컴포넌트도 언마운트됨. 그러나 atomFamily 내부의 Map에는 id: 1에 해당하는 Atom이 여전히 남아있다.

이것이 바로 메모리 누수다. 가비지 컬렉터(GC)가 이 Atom들을 청소하지 못하는 이유는 atomFamily 내부의 Map이 이들을 강하게 참조하고 있기 때문이다. 리스트의 아이템이 자주 추가/삭제되는 서비스라면, 시간이 지날수록 불필요한 Atom들이 메모리에 계속 쌓이게 된다.

해결책?

이 문제를 해결하려면 개발자가 명시적으로 remove를 호출해줘야 한다.

// 아이템이 삭제될 때 반드시 실행해줘야 함
elementDataAtomFamily.remove(1);

하지만 비즈니스 로직 곳곳에서 아이템이 삭제되는 시점을 포착해 일일이 remove를 호출하는 건 실수하기 쉽고 번거로운 작업이다. 결국 remove()만으로는 이 구조적 문제를 완전히 해결하지 못하는 셈이다.


결론

atomFamily는 리스트 렌더링 성능 최적화를 위한 강력한 도구이지만, 그 대가로 메모리 관리의 책임을 개발자에게 지운다.

  • 장점: 파라미터별 독립된 Atom 생성으로 불필요한 리렌더링을 막는다.
  • 단점: 생성된 Atom이 내부 Map에 영구 보존돼, 명시적으로 remove하지 않으면 메모리 누수가 발생한다.

실제로 Jotai는 jotai/utilsatomFamily를 deprecated 처리하고, 이 기능을 jotai-family라는 별도 패키지로 분리했다. 공식적으로 밝힌 이유는 "코어 라이브러리를 가볍게 유지하기 위한" 구조적 결정에 가깝지만, 지금까지 살펴본 메모리 누수 문제 역시 atomFamily를 쓸 때 반드시 감안해야 할 구조적 약점이라는 사실은 변하지 않는다.

동적으로 생성되고 사라지는 데이터가 매우 많은 환경이라면, atomFamily(혹은 jotai-family)를 쓰되 메모리 누수 대응 전략을 확실히 세우거나, selectAtom처럼 컴포넌트 스코프에 종속되는 다른 유틸을 함께 검토해보는 걸 추천한다.