아래 스크린샷과 같은 캐릭터 옷 입히기 서비스를 만들고 있다고 해봅시다.
이 서비스의 컴포넌트 구조는 아래와 같이 나타낼 수 있습니다.
좌측 섹션에서는 캐릭터에게 입혀줄 수 있는 다양한 아이템을 볼 수 있고, 우측 캔버스에서는 캐릭터가 옷을 입은 모습을 확인할 수 있습니다. 좌측 섹션에서는 여러 종류의 아이템을 제공하는데, 아이템 패널마다 각각 검색 기능을 제공하고 있습니다.
사용성을 위해, 모자 패널에서 검색어를 입력한 후 옷 패널로 이동했다가 다시 모자 패널로 돌아와도 검색어는 유지되어야 합니다. 상태는 컴포넌트가 unmount 되면 사라지기 때문에, 패널이 unmount 되어도 상태가 유지되려면 패널 상위에 있는 컴포넌트에서 상태가 관리되어야 합니다.
한편, 사용자가 쉽고 빠르게 검색할 수 있도록, 캐릭터가 착용하고 있는 아이템과 비슷한 아이템을 검색할 수 있는 기능을 넣기로 했습니다. 캐릭터가 착용한 아이템을 클릭하면 캔버스 하단에 ‘비슷한 아이템 검색’ 버튼이 노출되고, 이 버튼을 누르면 해당 아이템의 탭으로 이동해서 아이템에 등록된 키워드로 검색을 수행합니다.
즉, ‘비슷한 아이템 찾기’ 버튼이 해야 하는 일은 아래와 같습니다.
- 현재 선택된 탭을 변경한다
- 특정 탭에 키워드를 설정한다
컴포넌트 구조 상에서, ‘비슷한 아이템 찾기’ 버튼, 즉 FindSimilarItemButton
컴포넌트는 TabTriggers
, HatPanel
, ClothesPanel
세 컴포넌트의 상태를 변경시켜야 합니다. 이 컴포넌트들의 공통 부모는 root 노드에 해당되는 App 컴포넌트 뿐이므로, ‘선택된 탭’과 ‘각 패널의 검색어’ 상태는 App 컴포넌트까지 끌어올려져야 합니다.
App 컴포넌트에서 상태를 관리한다는 것은, 상태를 실제로 사용하는 TabTriggers
, HatPanel
등등에 상태를 전달해 주기 위해 Aside
, Header
등 상태를 몰라도 되는 컴포넌트도 props를 통해 상태를 내려받아야 한다는 것을 의미합니다. 이는 불필요한 리렌더링을 유발할 수 있으며, 매번 props를 내려주는 것이 매우 번거롭습니다.
따라서 App 컴포넌트에서 모든 상태를 관리하기 보다는, ‘선택된 탭’과 ‘각 패널의 검색어’라는 상태를 전역 상태로 두고 관리하는 것이 좋습니다. 이런 상태들은 컴포넌트 렌더링과 밀접히 관련이 있고, 컴포넌트 UI로부터 파생된 상태이므로 Jotai가 제공하는 atom으로 관리하기로 합니다.
먼저, 선택된 패널 상태와 각 패널의 키워드에 대한 아톰을 생성합니다.
1// 좌측 패널에서 제공하는 패널 리스트2const PanelTypeList = ['HAT', 'CLOTHES'] as const;3export type PanelType = (typeof PanelTypeList)[number];45// 현재 선택된 패널 상태6export const selectedPanelAtom = atom<PanelType>('HAT');78// '옷' 패널의 키워드 상태9export const clothesPanelSearchKeywordAtom = atom('');10// '모자' 패널의 키워드 상태11export const hatPanelSearchKeywordAtom = atom('');
이 아톰을 사용해서, 비슷한 아이템 찾기 버튼을 구현해봤습니다.
1export const FindSimilarItemButton = ({ selectedItem }: { selectedItem: { itemType: ItemType; keywords: string[] } }) => {2 const setPanelType = useSetAtom(selectedPanelAtom);3 // 특정 패널에 검색어를 설정하는 훅4 const setSearchKeywordInPanel = useSetSearchKeywordInPanel();56 const onSearchSimilarItem = () => {7 const panelType = getPanelTypeByItemType(selectedItem.itemType);89 // 비슷한 아이템 찾기 버튼을 누르면, 패널을 이동한 후 그 패널에 키워드를 설정한다10 setPanelType(panelType);11 setSearchKeywordInPanel(panelType, selectedItem.keywords[0]);12 };1314 return (15 <Button onClick={onSearchSimilarItem}>16 비슷한 아이템 검색17 </Button>18 );19};20
특정 패널에 키워드를 설정하는 로직은 다른 곳에서도 재사용될 수 있으므로, 이 로직은 커스텀 훅으로 만들었습니다.
1export const useSetSearchKeywordInPanel = () => {2 const setHatKeyword = useSetAtom(hatPanelSearchKeywordAtom);3 const setClothesKeyword = useSetAtom(clothesPanelSearchKeywordAtom);45 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};
여기서 특이한 점은, useSetSearchKeywordInPanel
에서 각 패널의 키워드 atom setter를 useSetAtom으로 모두 가져오고 있다는 점입니다.
useSetSearchKeywordInPanel
은 훅이 호출되는 시점에는 어떤 패널에 키워드를 설정해야 할 지 모르고, 훅이 반환하는 함수가 호출되는 시점에 패널이 결정됩니다. 따라서 어떤 패널에 키워드를 설정하게 될 지 모르기 때문에, 훅 규칙으로 인해 각 패널 키워드 아톰의 setter를 가져오는 훅을 호출해야 했습니다.
현재는 패널이 두 개 뿐이지만 앞으로 더 많은 패널이 추가될 수 있습니다. 패널이 추가될 때마다 이 훅에서 호출하는 useSetAtom
의 개수가 늘어날 것이고, 그럴수록 훅은 복잡해지고 관리도 어려워집니다. 그리고 패널이 추가될 때마다 잊지 않고 이 훅에 코드를 추가해 줘야 하는데, 이를 알지 못하고 누락될 가능성이 아주 높습니다.
Atom Map으로 만들기
특정 패널의 키워드 아톰에 값을 설정해 주기 위해 꼭 모든 아톰에 대해 useSetAtom
훅을 호출해줘야 할까요? 훅을 호출하는 횟수를 최소화할 수는 없을까요?
패널마다 동일한 타입을 가지는 동일한 용도의 아톰을 가지고 있으니, 이들을 맵으로 만들고, 원하는 패널의 아톰 값을 설정하는 write-only atom을 만들 수 있습니다.
1// 1. 패널 타입별 ...PanelSearchKeywordAtom을 맵으로 만든다2const panelSearchKeywordAtomMap: Record<PanelType, WritableAtom<string, [string], void>> = {3 CLOTHES: clothesPanelSearchKeywordAtom,4 HAT: hatPanelSearchKeywordAtom,5};67// 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});1415export const useSetSearchKeywordInPanel = () => {16 // 3. useSetAtom으로 쓰기 전용 atom의 setter를 가져온다17 const setKeywordInPanel = useSetAtom(setPanelSearchKeywordAtom);1819 return useCallback(20 (panelType: PanelType, keyword: string) => {21 setKeywordInPanel(panelType, keyword);22 },23 [setKeywordInPanel],24 );25};26
이렇게 하면 각 패널 키워드 아톰의 setter를 가져올 필요 없이, useSetSearchKeywordInPanel
훅만 호출해도 모든 패널의 키워드를 설정할 수 있게 됩니다.
만약에 새로운 패널이 추가된다면, panelSearchKeywordAtomMap
에 그 패널의 키워드 아톰을 추가해 주면 됩니다. 새로운 패널이 추가되어도 map을 갱신하지 않았다면 타입스크립트가 타입을 체크할 때 타입 에러가 발생할 것이기 때문에 패널이 누락되는 것도 방지할 수 있습니다.
AtomFamily 활용하기
위 방법을 사용하여 불필요한 훅을 호출하지 않을 수 있게 됐지만, 여전히 패널마다 비슷한 형태의 아톰을 생성해 주어야 한다는 점이 번거롭고 관리가 어렵습니다. 지금은 패널이 두 개 뿐이지만, 패널이 점점 더 늘어난다면 관리해야 하는 아톰의 개수도 그만큼 늘어나게 될 것입니다.
atom을 일일이 생성하고 map을 만드는 방식 대신, atomFamily를 사용하면 일관성 있게 아톰을 관리할 수 있습니다.
1export const searchKeywordAtomFamily = atomFamily((panelType: PanelType) => {2 return atom('');3});45const clothesPanelSearchKeywordAtom = searchKeywordAtomFamily('CLOTHES');
atomFamily
는 여러 아톰을 관리하는 맵과 같은 역할을 합니다. atomFamily
를 호출해 얻게 되는 함수는 그 함수의 인자값을 key로 삼아, 호출된 적 없는 key라면 새로운 아톰을 만들고, 이미 호출됐다면 캐싱된 아톰을 반환합니다.
단점으로는 명시적으로 atom을 만드는 경우와 달리, atomFamily
는 원하는 값으로 얼마든지 계속 아톰을 만들어 낼 수 있기 때문에, 메모리 누수가 발생할 가능성이 높습니다. 따라서 사용하지 않는 key가 생긴다면 {아톰 패밀리}.remove(param)
을 호출해서 명시적으로 값을 제거해 주는 것이 필요합니다.
write-only atom과 함께 사용하기
atomFamily를 사용해서 useSetSearchKeywordInPanel를 구현해 보겠습니다.
1export const useSetSearchKeywordInPanel = (panelType: PanelType) => {2 const setKeywordInPanel = useSetAtom(searchKeywordAtomFamily);34 return useCallback(5 (keyword: string) => {6 setKeywordInPanel(keyword);7 },8 [setKeywordInPanel],9 );10};
이 훅의 단점은, 훅을 호출하는 시점에 타겟이 되는 패널이 어느 패널인지 알아야 한다는 점입니다. 그런데 어떤 경우에는 훅이 반환하는 함수를 호출하는 시점에 타겟이 되는 패널의 타입이 결정될 수도 있습니다.
write-only atom을 만들면 panelType을 함수 호출 시점에 넘겨줄 수 있는 setter를 만들 수 있습니다.
1const setPanelSearchKeywordAtom = atom<null, [PanelType, string], void>(null, (_, set, panelType, keyword) => {2 set(searchKeywordAtomFamily(panelType), keyword);3});45export const useSetSearchKeywordInPanel = () => {6 const setKeywordInPanel = useSetAtom(setPanelSearchKeywordAtom);78 return useCallback(9 (panelType: PanelType, keyword: string) => {10 setKeywordInPanel(panelType, keyword);11 },12 [setKeywordInPanel],13 );14};
이렇게 해서 아톰을 관리하기도 편하고, 원하는 시점에 panelType을 넘겨줄 수 있는 구조를 만들 수 있습니다.