지난 글에선 toss의 useOverlay 동작원리 및 주의사항에 대해 알아봤다.
이번 글에서는 실제로 이를 도입하며 생긴 고민과 그에 대한 해결책을 작성하고자 한다.
문제점
지난 글의 overlay 중복 사용 파트를 통해 알 수 있듯이 useOverlay가 반환하는 overlay는 하나의 컴포넌트만 관리하게 된다.
그럼 한 컴포넌트에서 여러 모달을 독립적으로 띄우려면 useOverlay를 모달 개수만큼 선언해 줘야 한다는 것이 문제다.
// 두 개의 overlay 인스턴스를 생성
const overlay = useOverlay();
const messageOverlay = useOverlay();
<>
{overlay.open(({ isOpen, close }) => (
<MessageDialog
isDialogOpen={isOpen}
onDialogClose={close}
messageList={["테스트1"]}
/>
))}
{messageOverlay.open(({ isOpen, close }) => (
<MessageDialog
isDialogOpen={isOpen}
onDialogClose={close}
messageList={["테스트2"]}
/>
))}
</>
해결책
고민했던 여러 방법들에 대해 작성하려고 한다.
작성자가 최종적으로 선택한 건 방법 5 - useMultipleOverlay이다.
방법 1 - 인스턴스 여러개 만들기
const overlay1 = useOverlay();
const overlay2 = useOverlay();
...
가장 단순한 방법이다.
해결책이라기보단 이를 해결하고자 고민하는 시작 단계이다.
방법 2 - 적절한 변수명 짓기
const productListUpdateOverlay = useOverlay();
const ProductRestoreOverlay = useOverlay();
...
인스턴스 개수가 많아지면 여전히 문제가 된다.
어떤 overlay인지 명시해서 가독성은 좋다.
방법 3 - 각 레이어별로 공통 overlay 사용하기
const overlay1 = useOverlay();
const overlay2 = useOverlay();
const submit = async () => {
try {
// API 호출
overlayControl.exit();
} catch {
// API 호출 실패 시
overlay2.open((control) => ( // 여긴 overlay 레이어 2
<Dialog overlayControl={control} />
));
}
};
<Button1
onClick={() => {
overlay1.open((control) => ( // 여긴 overlay 레이어 1
<Confirm overlayControl={control} onConfirm={submit} />
));
}}
/>
<Button2
onClick={() => {
overlay1.open((control) => ( // 여기도 overlay 레이어 1
<></>
));
}}
/>
위의 코드를 간단 요약하면 Button1을 누르면 컨펌 모달이 올라오고 컨펌 내에서 수락을 누르면 API 호출을 진행한다. Button2를 누르면 그냥 모달이 렌더링된다고 생각하자.
두 버튼의 공통점은 overlay 인스턴스로 overlay1을 쓴다는 점이다.
두 버튼을 동시에 누를 일은 없으므로 둘은 같은 오버레이를 써도 전혀 상관 없다.
반면 submit 함수 내에서 overlay2 인스턴스를 사용하는 이유는 Button1의 overlay1을 공유하면 원하는 동작을 하지 않기 때문이다.
만약 submit에서도 overlay1 인스턴스를 사용한다면
1. submit에서 API 호출 실패 시 Dialog를 열려고 할 것이다.
2. overlay1의 기존 컴포넌트인 Confirm을 unmount하게 될 것이다.
3. 이 과정에서 submit 함수 또한 같이 unmount 된다.
4. 결과적으로 Confirm, Dialog 둘 다 닫히게 된다.
그렇기 때문에 레이어만 제대로 나누어 준다면 인스턴스 개수도 줄일 수 있다.
현실적으로 좋은 방법이다.
하지만 개발자들이 컴포넌트의 overlay 계층 구조를 파악하고 있어야한다. 협업하는 상황에서 익숙치 않은 컴포넌트를 볼 때 이를 고려해야한다는 건 비효율적이라고 판단하여 이 방법을 도입하지 않았다.
방법 4 - 역할별로 나누기
confirmOverlay, dialogOverlay 이런 식으로 역할별로 나누는 건?
const confirmOverlay1 = useOverlay();
const confirmOverlay2 = useOverlay();
...
해당 역할도 컴포넌트 내에서 중첩될 수 있다. 결국은 번호를 붙이게 됨.
방법 5 - useMultipleOverlay 직접 만들기
필요한 인스턴스 개수를 매개변수로 전달하고 그만큼 인스턴스를 반환하는 커스텀 훅을 만들었다.
먼저 이 훅은 toss의 useOverlay를 참고하여 만들었고, 해당 프로젝트의 코드에도 주석으로 남겨져 있다.
// 참고: https://github.com/toss/slash/tree/main/packages/react/use-overlay
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import {
OverlayController,
OverlayControlRef,
} from '@/hooks/useOverlay/OverlayController';
import { OverlayContext } from '@/hooks/useOverlay/OverlayProvider';
import { CreateOverlayElement } from '@/hooks/useOverlay/types';
let elementId = 1;
export const useMultipleOverlay = (
count: number,
nonExitOnUnmountList?: number[]
) => {
const context = useContext(OverlayContext);
if (context == null) {
throw new Error(
'useMultipleOverlay is only available within OverlayProvider.'
);
}
if (count < 1) {
throw new Error('useMultipleOverlay는 overlay가 1개 이상이어야 합니다.');
}
const { mount, unmount } = context;
const [ids] = useState(() => {
const res = Array.from(
{ length: count },
(_, i) => `mult-${elementId + i}`
);
elementId += count;
return res;
});
const overlayRefs = useRef<(OverlayControlRef | null)[]>(
Array(count).fill(null)
);
useEffect(() => {
return () => {
ids.forEach((id, index) => {
if (!nonExitOnUnmountList || !nonExitOnUnmountList.includes(index)) {
unmount(id);
}
});
};
}, [ids, nonExitOnUnmountList, unmount]);
return useMemo(() => {
const overlays = ids.map((id, index) => ({
open: (overlayElement: CreateOverlayElement) => {
mount(
id,
<OverlayController
key={Date.now()}
ref={(ref) => {
overlayRefs.current[index] = ref;
}}
overlayElement={overlayElement}
onExit={() => {
unmount(id);
}}
/>
);
},
close: () => {
overlayRefs.current[index]?.close();
},
exit: () => {
unmount(id);
},
}));
return overlays;
}, [ids, mount, unmount]);
};
여기서 useOverlay 코드와 직접 비교하지는 않겠지만, 두 코드는 거의 유사하다.
코드 설명
매개변수로 count와 nonExitOnUnmountList를 받는다.
count 값만큼 overlay 인스턴스를 생성하여 배열에 담는다.
기존 useOverlay는 exitOnUnmount를 boolean 형태로 받았고, 기본값이 true이다. 이를 응용해서 nonExitOnUnmountList로 이를 처리했다. 리스트를 안 받으면 모두 exitOnUnmount 처리되는 것이다.
내부적으로는 element Id 중복을 방지하기 위해 mult-${elementId + i}
로 id 값을 구현했다.
기존 코드가 overlayRef로 처리되어있어서 ref 배열로 처리하느라 OverlayController의 ref 콜백 함수로 current를 지정했다.
count는 1 이상이어야하고 1 미만이라면 error를 발생하도록 처리했다.
결론적으로 이 방식을 채택했다.
인스턴스 개수를 N개에서 1개로 줄일 수 있었고, 개발자도 overlay에 대해 신경 쓸 필요가 없어서 좋다.
실제 사용 시에는 선언 후 배열 인덱스 순서와 관계없이 자유롭게 사용하면 된다.
const overlays = useMultipleOverlay(3);
overlays[0].open(...);
overlays[1].open(...);
...
const ComponentA = ({ open, onClose }) => {
const overlay = useOverlay();
const openComponentB = () => {
overlay.open(({ isOpen, close }) => (
<ComponentB open={isOpen} onClose={close} />
));
};
return (
<Modal open={open} onClose={onClose}>
<h2>Component A</h2>
<button onClick={openComponentB}>Open Component B</button>
</Modal>
);
};
const ComponentB = ({ open, onClose }) => {
const overlay = useOverlay();
const openComponentA = () => {
overlay.open(({ isOpen, close }) => (
<ComponentA open={isOpen} onClose={close} />
));
};
return (
<Modal open={open} onClose={onClose}>
<h2>Component B</h2>
<button onClick={openComponentA}>Open Component A</button>
</Modal>
);
};
위와 같이 A 컴포넌트가 B 컴포넌트를 호출하고, B 컴포넌트에서 다시 새로운 A 컴포넌트를 호출하는 구조는 순환 구조이다.
내 프로젝트에 존재하는 overlay 순환 구조에서 발생하는 메모리 누수를 직접 확인해 보았다.
먼저 아래 사진은 일반적인 상황에서의 OverlayProvider Map 상태이다.
정상적으로 exit을 잘 사용했다면 모달을 열었다가 닫았을 때, 객체가 Map에서 사라진다.
다음은 순환 구조에서 계속해서 다음 컴포넌트를 호출했을 때의 상황이다.
보다시피 Map에 overlay 객체들이 남아있음을 알 수 있다.
이게 문제가 되는 점은 순환 구조 모달을 닫았을 때도 해당 객체들이 Map에 그대로 남아있다는 것이다.
즉, 위 사진의 key가 "1"인 부모 컴포넌트가 unmount 되어야 비로소 이 객체들이 Map에서 제거된다.
다른 모달을 열었음에도 기존 순환 구조 모달 객체들이 남아있는 모습..
결론적으로 이 문제를 해결하기 위해 순환 구조를 피하고, 모달 위에 모달을 호출하는 중첩 구조로 변경했다.
문제점
내 프로젝트에는 overlay로만 사용되는 컴포넌트들이 있다. 예를 들면 FolderActionModal 또는 MessageDialog 등이 있다. 이런 컴포넌트들에 대해 매번 파라미터로 overlay의 open, close를 전달했다.
toss의 예시에도 open과 onClose로 넘겨주고 있으니 이 점에 대해 특별히 주목하진 않았다.
const MessageDialog = ({
isDialogOpen,
closeDialog,
exitDialog,
messageList,
}: {
isDialogOpen: boolean;
closeDialog: () => void;
exitDialog: () => void;
messageList: string[];
}) => {
// ...
}
// 컴포넌트 A
overlay.open(({ isOpen, close, exit }) => (
<MessageDialog
isDialogOpen={isOpen}
closeDialog={close}
exitDialog={exit}
messageList={[""]}
/>
));
하지만 실제로 개발하며 이에 대해 의문이 들었다. 어차피 overlay로만 관리되는데, 이 모든 속성을 별도로 전달할 필요가 있을까?
즉, 컴포넌트 A가 열게 되는 MessageDialog 모달의 isOpen 상태를 알아야 할 필요가 있을까? 라는 의문이 들었다.
결론적으로, 내 프로젝트에서 MessageDialog는 일관된 사용자 경험을 제공하기 때문에 그럴 필요가 없다고 판단했다.
해결
{ isOpen, close, exit } 객체를 펼쳐서 모달에 전달하던 방식을 변경하여, 이를 control이라는 이름으로 통합하여 전달했다.
프로퍼티명이 control인 이유는 overlay를 제어하는 프로퍼티이기도 하고 이들을 담당하는 게 내부적으로 OverlayController이기 때문이다.
overlay.open((control) => (
<MessageDialog
overlayControl={control}
messageList={[""]}
/>
));
MessageDialog가 닫힐 때 컴포넌트 A에서 특정 동작을 하고 싶을 수 있기 때문에 onDialogClose를 만들어두었고, 동기 비동기 둘 다 사용할 수 있도록 구현했다.
const MessageDialog = ({
overlayControl,
messageList,
onDialogClose = () => {},
}: {
overlayControl: OverlayControl;
messageList: string[];
onDialogClose?: () => void | Promise<void>;
}) => {
return (
<StyledMessageDialog
open={overlayControl.isOpen}
onClose={async () => {
await onDialogClose();
overlayControl.exit();
}}
>
<DialogContent>
// ...
</DialogContent>
<DialogActions>
<Button
onClick={async () => {
await onDialogClose();
overlayControl.exit();
}}
>
Confirm
</Button>
</DialogActions>
</StyledMessageDialog>
);
}
이렇게 구현하면 외부 컴포넌트는 이 컴포넌트의 isOpen, close 상태를 직접 관리할 필요가 없다. 단순히 컴포넌트를 사용하기만 하면 된다.
기존 모달에 useOverlay 도입부터 개선하기까지 한 달 정도 걸렸고 useOverlay 관련 블로그 글 마무리하기까지 다시 한 달이 걸렸다.
계속 바쁘게 달리느라 블로그 글을 못 썼다고 생각했다. 하지만 매일 느끼는 건, 항상 오늘이 가장 바쁜 것 같다. 어차피 바쁠 거, 써야할 거 남겨두지 말고 얼른 처리하고 다음 일을 또 처리하자.
useMultipleOverlay는 따로 npm에 구현하진 않았고 이 글을 쓰게 된 프로젝트인 QR 코드 쇼핑에 구현했다. 나중에 시간 나면 별도 모듈화해서 배포하고자 한다.