Jotai Atom의 스코프 관리하기

둘 이상의 영역에서 비슷한 용도로 사용되는 아톰을 관리하는 방법을 살펴봅니다

2024-06-03에 씀

들어가며


캐릭터 옷 입히기 서비스를 만들고 있다고 해보자. 좌측 패널에서는 캐릭터에게 입혀줄 수 있는 다양한 아이템을 볼 수 있고, 우측 캔버스에서는 캐릭터가 옷을 입은 모습을 확인할 수 있다. 좌측 패널에서는 패널마다 검색 기능을 제공하여 사용자가 원하는 아이템을 쉽게 찾을 수 있게 해준다. 이때, 사용성을 위해 다른 탭으로 갔다가 돌아오더라도 원래 입력했던 검색어는 유지되어야 한다.

사용자가 다양한 아이템을 편하게 검색해 볼 수 있도록, 캐릭터가 착용하고 있는 아이템과 비슷한 아이템을 검색할 수 있는 기능을 넣기로 했다. 캐릭터가 착용한 아이템을 클릭하면 캔버스 하단에 ‘비슷한 아이템 검색’ 버튼이 노출되고, 이 버튼을 누르면 해당 아이템의 탭으로 이동한 후 현재 입은 아이템의 키워드로 검색한다.

즉, ‘비슷한 아이템 찾기’ 버튼이 해야 하는 일은 아래와 같다.

현재 애플리케이션의 컴포넌트 구조는 아래와 같은 형태이다.

이러한 구조에서, ‘비슷한 아이템 찾기’ 버튼, 즉 FindSimilarItemButton 컴포넌트는 TabTriggers, HatPanel, ClothesPanel 세 컴포넌트의 상태를 변경시켜야 한다. 이 컴포넌트들의 공통 부모는 root 노드에 해당되는 App 컴포넌트 뿐이므로, ‘선택된 탭’과 ‘각 패널의 검색어’는 App이 관리해야 하는 상태가 된다.

App 컴포넌트에서 모든 상태를 관리하기 보다는, ‘선택된 탭’과 ‘각 패널의 검색어’를 전역 상태로 두고 관리하는 것이 낫겠다고 판단했다. 이런 상태들은 컴포넌트 렌더링과 밀접히 관련이 있고, 컴포넌트 UI로부터 파생된 상태이므로 Jotai가 제공하는 atom으로 관리하기로 했다.

먼저, 선택된 패널 상태와 각 패널의 키워드에 대한 아톰을 생성했다.

1const PanelTypeList = ['HAT', 'CLOTHES'] as const;
2export type PanelType = (typeof PanelTypeList)[number];
3
4export const selectedPanelAtom = atom<PanelType>('HAT');
5export const clothesPanelSearchKeywordAtom = atom('');
6export const hatPanelSearchKeywordAtom = atom('');

이 아톰을 사용해서, 비슷한 아이템 찾기 버튼을 구현해봤다.

1const ItemTypeList = ['HAT', 'CLOTHES'] as const;
2export type ItemType = (typeof ItemTypeList)[number];
3
4const itemTypeToKrMap: Record<ItemType, string> = {
5 CLOTHES: '옷',
6 HAT: '모자',
7};
8
9export const FindSimilarItemButton = ({ selectedItem }: { selectedItem: { itemType: ItemType; keywords: string[] } }) => {
10 const setPanelType = useSetAtom(selectedTabAtom);
11 // 특정 패널에 검색어를 설정하는 훅
12 const setSearchKeywordInPanel = useSetSearchKeywordInPanel();
13
14 const onSearchSimilarItem = () => {
15 const panelType = getPanelTypeByItemType(selectedItem.itemType);
16
17 // 비슷한 아이템 찾기 버튼을 누르면, 패널을 이동한 후 그 패널에 키워드를 설정한다
18 setPanelType(panelType);
19 setSearchKeywordInPanel(panelType, selectedItem.keywords[0]);
20 };
21
22 return (
23 <Button onClick={onSearchSimilarItem}>
24 비슷한 {itemTypeToKrMap[selectedItem.itemType]} 검색
25 </Button>
26 );
27};
28

특정 패널에 키워드를 설정하는 로직은 다른 곳에서도 재사용될 수 있으므로, 이 로직은 커스텀 훅으로 만들었다.

1export const useSetSearchKeywordInPanel = () => {
2 const setHatKeyword = useSetAtom(hatPanelSearchKeywordAtom);
3 const setClothesKeyword = useSetAtom(clothesPanelSearchKeywordAtom);
4
5 return useCallback(
6 (panelType: PanelType, keyword: string) => {
7 switch (panelType) {
8 case 'HAT':
9 setHatKeyword(keyword);
10 break;
11 case 'CLOTHES':
12 setClothesKeyword(keyword);
13 break;
14 }
15 },
16 [setClothesKeyword, setHatKeyword],
17 );
18};
19

코드를 보고 의아함을 느끼셨을 것 같다. 이 훅의 특이한 점은, useSetAtom 훅을 통해 hatPanelSearchKeywordAtomclothesPanelSearchKeywordAtom 의 setter를 모두 가져오고 있다는 점이다.

이렇게 코드가 작성된 이유는 panelType 값을 콜백을 호출하는 시점에 받아오기 때문에, 훅을 호출하는 시점에는 어떤 패널에 키워드를 설정해줘야 할지 알 수 없기 때문이다. 그리고 리액트 훅 규칙으로 인해, 조건부로 훅을 호출하거나 useCallback 안에서 setter를 가져올 수도 없다.

현재는 패널이 두 개 뿐이지만 앞으로 더 많은 패널이 추가될 수 있다. 패널이 추가될 때마다 이 훅에서 호출하는 useSetAtom 의 개수가 늘어날 것이고, 그럴수록 훅은 복잡해지고 관리도 어려워진다. 그리고 패널이 추가될 때마다 잊지 않고 이 훅에 코드를 추가해 줘야 하는데, 이를 알지 못하고 누락될 가능성이 아주 높다.

쓰기 전용 Atom

특정 패널의 키워드 아톰에 값을 설정해 주기 위해 꼭 모든 아톰에 대해 useSetAtom 훅을 호출해줘야 할까? 훅을 호출하는 횟수를 최소화할 수는 없을까?

이를 해결하기 위해 쓰기 전용 atom을 생성할 수 있다. atom을 선언할 때, getter에서는 무조건 null을 반환하고, setter의 인자로 전달되는 set을 활용하는 방법이다.

1// 1. 패널 타입별 ...PanelSearchKeywordAtom을 맵으로 만든다
2const panelSearchKeywordAtomMap: Record<PanelType, WritableAtom<string, [string], void>> = {
3 CLOTHES: clothesPanelSearchKeywordAtom,
4 HAT: hatPanelSearchKeywordAtom,
5};
6
7// 2. 쓰기 전용 atom을 만든다. 읽기를 시도하면 null을 반환한다.
8const setPanelSearchKeywordAtom = atom<null, [PanelType, string], void>(null, (_, set, panelType, keyword) => {
9 const foundAtom = panelSearchKeywordAtomMap[panelType];
10 if (foundAtom) {
11 set(foundAtom, keyword);
12 }
13});
14
15export const useSetSearchKeywordInPanel = () => {
16 // 3. useSetAtom으로 쓰기 전용 atom의 setter를 가져온다
17 const setKeywordInPanel = useSetAtom(setPanelSearchKeywordAtom);
18
19 return useCallback(
20 (panelType: PanelType, keyword: string) => {
21 setKeywordInPanel(panelType, keyword);
22 },
23 [setKeywordInPanel],
24 );
25};
26

이렇게 하면 각 패널 키워드 아톰의 setter를 가져올 필요 없이, useSetSearchKeywordInPanel 훅만 호출해도 모든 패널의 키워드를 설정할 수 있게 된다.

만약에 새로운 패널이 추가된다면, panelSearchKeywordAtomMap 에 그 패널의 키워드 아톰을 추가해 주면 된다. 새로운 패널이 추가되어도 map을 갱신하지 않았다면 빌드 도중 타입 에러가 발생할 것이기 때문에 패널이 누락되는 것을 방지할 수 있다.

AtomFamily 활용하기

위 방법을 사용하여 불필요한 훅을 호출하지 않을 수 있게 됐지만, 여전히 패널마다 비슷한 형태의 아톰을 생성해 주어야 한다. 지금은 패널이 두 개 뿐이지만, 패널이 점점 더 늘어난다면 관리해야 하는 아톰의 개수도 그만큼 늘어나게 된다.

비슷한 역할을 하는 아톰을 하나로 묶어 관리하기 위해 atomFamily를 사용할 수 있다.

1export const searchKeywordAtomFamily = atomFamily((panelType: PanelType) => {
2 return atom('');
3});
4
5const clothesPanelSearchKeywordAtom = searchKeywordAtomFamily('CLOTHES');

atomFamily는 여러 아톰을 관리하는 맵과 같은 역할을 한다. atomFamily를 호출해 얻게 되는 함수는 그 함수의 인자값을 key로 삼아, 호출된 적 없는 key라면 새로운 아톰을 만들고, 이미 호출됐다면 캐싱된 아톰을 반환해 준다.

명시적으로 atom을 만드는 경우와 달리, atomFamily는 원하는 값으로 얼마든지 계속 아톰을 만들어 낼 수 있기 때문에, 메모리 누수가 발생할 가능성이 높다. 따라서 사용하지 않는 key가 생긴다면 {아톰 패밀리}.remove(param)을 호출해서 명시적으로 값을 제거해 주어야 한다.

Provider로 스코프 나누기

그런데, 각 패널이 panelSearchKeywordAtom을 각각 가지고 있어야 하는 걸까? 똑같은 동작을 하는 atom을 하나만 만들어 관리할 수는 없을까?

Jotai에서 제공하는 Provider와 store를 사용하면 각 패널에서 같은 아톰을 참조하면서도 다른 값을 저장하게 할 수 있다.

먼저, 각 패널에서 사용할 스토어를 생성한다. 그리고 각 패널의 키워드를 저장하기 위한 키워드 아톰은 한 개만 정의한다.

1// 패널별 Jotai Store를 만든다
2export const hatPanelStore = createStore();
3export const clothesPanelStore = createStore();
4
5// 패널 타입에 따른 Store를 가져오는 맵을 만든다
6export const storeMapByPanel: Record<PanelType, typeof hatPanelStore> = {
7 CLOTHES: clothesPanelStore,
8 HAT: hatPanelStore,
9};
10
11// 각 패널에서 사용할 키워드 아톰을 만든다
12export const panelSearchKeywordAtom = atom('');

각 패널을 jotai에서 제공하는 Provider로 감싸고, 이 Provider에 각 패널을 위한 스토어를 전달한다.

1<Provider store={storeMapByPanel['HAT']}>
2 <HatPanel />
3</Provider>
4<Provider store={storeMapByPanel['CLOTHES']}>
5 <ClothesPanel />
6</Provider>

패널 내부에서도, 패널마다 다른 atom을 참조하는 것이 아닌 panelSearchKeywordAtom의 값을 사용한다.

1export const HatPanel = () => {
2 const [keyword, setKeyword] = useAtom(panelSearchKeywordAtom);
3
4 return (
5 <Panel value={'HAT'}>
6 <div className={'px-4'}>
7 <Search value={keyword} onChange={setKeyword} />
8 </div>
9 ...
10 </Panel>
11 )
12}
13
14export const ClothesPanel = () => {
15 const [keyword, setKeyword] = useAtom(panelSearchKeywordAtom);
16
17 return (
18 <Panel value={'CLOTHES'}>
19 <div className={'px-4'}>
20 <Search value={keyword} onChange={setKeyword} />
21 </div>
22 ...
23 </Panel>
24 )
25}

같은 아톰을 참조하고 있지만, 각 패널이 다른 키워드를 보여주고 있는 것을 확인할 수 있다.

두 패널이 같은 아톰을 참조하고 있는데 왜 다른 값을 보여주고 있을까? 왜냐하면 atom 함수를 호출해서 생성하는 것은 값이 아닌 ‘아톰의 설정’이기 때문이다. 즉, 이 설정은 값을 관리하는 게 아니라 아톰의 정의만을 가지고 있다. 아톰의 실제 값은 Provider가 가지고 있는 Store에 저장되어 있다.

useAtom과 같은 훅을 통해 아톰 값에 접근하려고 하면, 해당 컴포넌트 상위의 가장 가까운 Provider가 가지고 있는 Store의 아톰 값을 반환한다. 이처럼, 아톰과 스토어는 React Context API에서 중첩 Provider를 사용할 때와 유사하게 동작한다.

패널의 Atom 구조가 바뀌었기 때문에, useSetSearchKeywordInPanel 훅의 구현도 수정되어야 한다. 키워드를 설정하기를 원하는 패널의 스토어를 가져와서, 그 스토어의 panelSearchKeywordAtom 아톰에 원하는 값을 설정해 주면 된다.

1export const setSearchKeywordAtom = (panelType: PanelType, keyword: string) => {
2 const foundStore = storeMapByPanel[panelType];
3 if (foundStore) {
4 foundStore.set(panelSearchKeywordAtom, keyword);
5 }
6};

이제 이 함수를 사용해서 특정 패널에만 키워드를 설정해 줄 수 있다.

jotai-scope

Jotai에서 제공하는 Provider를 사용해서 Atom 값을 특정 패널에 한정시킬 수 있게 되었지만, 대신 Provider를 사용하게 되면 Provider 하위 요소들은 그 상위 스토어의 Atom 값을 참조할 수 없게 된다.

예를 들어, 좌측 패널에 어떤 탭이 선택되었는지를 저장하는 selectedTabAtom은 애플리케이션 전역 값으로만 관리되면 되고, 패널별로 다른 값을 가질 필요는 없다. 그런데 Provider로 감싸져 있는 각 패널에서는 자체적으로 selectedTabAtom 아톰을 관리하게 된다.

1export const ClothesPanel = () => {
2 const [keyword, setKeyword] = useAtom(panelSearchKeywordAtom);
3
4 // selectedTabAtom은 애플리케이션 전역에서 같은 값을 가지는 것이 자연스럽다
5 const selectedPanel = useAtomValue(selectedTabAtom);
6
7 return (
8 <Panel value={'CLOTHES'}>
9 {/* selectedTabAtom이 무슨 값을 가지고 있는지 확인하기 위해 렌더링해본다 */}
10 {selectedPanel}
11 <div className={'px-4'}>
12 <Search value={keyword} onChange={setKeyword} />
13 </div>
14 ...
15 </Panel>
16 )
17}

아래 스크린샷에서, 좌측 패널의 ‘옷’ 탭이 선택되어 있기 때문에 selectedTabAtom의 기댓값은 ‘CLOTHES’이다. 그러나 ClothesPanel 에서는 selectedTabAtom 의 기본값인 ‘HAT’을 출력하고 있다. 다

이는 selectedTabAtom 의 값을 가져올 때 전역에 존재하는 아톰 값이 아닌, 패널을 감싸고 있는 Provider가 관리하는 selectedTabAtom 의 값을 가져오기 때문이다. 이는 Jotai Provider가 모든 atom 상태에 대한 서브 트리를 만들기 때문이다.

이런 문제를 해결하기 위해서, 특정 아톰에 대해서만 스코프를 나눌 수 있게 해주는 jotai-scopebunshi 같은 라이브러리가 있다. 여기에서는 jotai-scope만 짧게 살펴보겠다.

먼저, 범위를 특정 스코프로 한정할 아톰들을 배열로 만든다. 여기서는 키워드 아톰만 배열에 넣어주었다.

1export const panelSearchKeywordAtom = atom('');
2
3export const panelScopeAtomList = [panelSearchKeywordAtom];

jotai-scope 에서 제공하는 ScopeProvider 로 각 패널을 감싸고, panelScopeAtomList 를 각각 넘겨준다.

1<ScopeProvider atoms={panelScopeAtomList}>
2 <HatPanel />
3</ScopeProvider>
4<ScopeProvider atoms={panelScopeAtomList}>
5 <ClothesPanel />
6</ScopeProvider>

그러면 atoms 프로퍼티에 넘겨준 아톰들만 스코프에 한정되고, 다른 아톰은 전역 스코프의 값을 공유하게 된다.

참고 자료

프로필 사진

조예진

이전 포스트
《리액트 훅 인 액션》 리뷰
다음 포스트
Testing Library - act는 언제 써야 할까?