들어가며
러닝 크루 매칭 서비스를 개발을 하면서, 처음에는 빠른 기능 구현을 위해 모달 컴포넌트를 Radix UI, shadcn/ui를 가져와 사용했는데요.
하지만 점점 프로젝트에 모달이 많아지고, 페이지마다 여러 개의 모달을 관리하면서 모달 관리 방식에 아쉬움이 느껴져, 개선해보기로 했습니다.
문제: 모달이 늘어날수록 복잡해지는 컴포넌트
기존 코드
function Page() {
const [isParticipantsModalOpen, setIsParticipantsModalOpen] = useState(false);
const { data: participants } = useQuery(...);
const handleShowParticipants = () => {
setIsParticipantsModalOpen(true);
};
return (
<>
<Button onClick={handleShowParticipants}>참여자 보기</Button>
{isParticipantsModalOpen && (
<ParticipantsModal
onClose={() => setIsParticipantsModalOpen(false)}
participants={participants}
/>
)}
</>
);
}
제가 생각한 문제는 다음과 같습니다:
- 상태와 렌더링이 분리되어 있습니다: 모달을 여는 로직(상단의 useState)과 실제 모달을 보여주는 코드(하단의 JSX)가 멀리 떨어져 있어, 코드의 흐름을 파악하기 어렵습니다.
- 중요하지 않은 정보가 눈에 띕니다: 모달은 당연히 열고 닫을 수 있습니다. 하지만 매번 isOpen 상태를 선언하고, 조건부 렌더링을 작성해야 합니다. 모달에서 중요한 건 “언제, 어떤 모달을 보여줄지”가 아닐까요?
목표: 모달의 중요한 정보만 보여주기
이 코드를 개선하기 위해, 다음을 목표로 설정했습니다.
모달을 열고 닫는 상태는 숨기고, “언제 열지, 무엇을 보여줄지”를 한 곳에서 보여주기
그렇게 아래와 같이 코드를 작성하길 기대했습니다.
function Page() {
const { data: participants } = useQuery(...);
const modal = useModal();
const handleShowParticipants = () => {
modalController.open(() => (
<ParticipantsModal participants={participants} />
));
};
return (
<>
<Button onClick={handleShowParticipants}>참여자 보기</Button>
</>
);
}
1. Provider 생성하기
앱의 모든 모달을 관리할 저장소가 필요했습니다.
기획 상 다수의 모달을 띄우는 경우가 있었고, 화면에 가장 먼저 보이는 모달에만 배경(오버레이) 스타일을 주고 싶었습니다. 따라서 스택을 사용해 모달의 목록을 관리했습니다.
스택의 여러 동작을 정의하기 위해 class를 사용할 수 있었지만, 리액트만 사용하는 단계에서 과한 추상화라고 생각해서, reducer를 사용해 동작을 정의했습니다.
export function ModalControllerProvider({ children }: PropsWithChildren) {
const [modals, dispatch] = useReducer(stackReducer<Modal>, []);
const open = useCallback((id: string, render: () => React.ReactNode) => {
dispatch({ type: "PUSH", item: { id, render } });
}, []);
const close = useCallback(() => {
dispatch({ type: "POP" });
}, []);
const topModalId = modals.length > 0 ? modals[modals.length - 1].id : null;
return (
<ModalControllerContext.Provider value={{ open, close }}>
{children}
<ModalPortal modals={modals} close={close} topModalId={topModalId} />
</ModalControllerContext.Provider>
);
}
2. <dialog> 요소 활용하기
Radix UI는 복잡한 접근성 처리가 자동화된 장점이 있지만, 내부 동작을 알 수 없어 커스터마이징이 어려웠고, Overlay 중복 생성 문제를 해결할 수 없었습니다.
그래서 모달 동작뿐만 아니라 모달의 뷰 컴포넌트도 구현하는 것을 결정했습니다.
그러면서 모달의 <dialog> 요소를 사용해봤는데요. 여러가지 모달의 동작들과 고려해야할 접근성을 쉽게 챙길 수 있습니다.
- ESC 키를 누르면 자동으로 cancel 이벤트가 발생합니다.
::backdrop가상 요소로 배경을 쉽게 스타일링할 수 있습니다.HTMLDialogElement.showModal()메서드를 사용하면 모달을 제외한 모든 것에 inert 특성을 사용하여 비활성화할 수 있습니다.
export function ModalWrapper({ modal, isTop, close }: ModalWrapperProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
dialog.showModal(); // 모달 열기
const handleEscape = (e: Event) => {
if (isTop) {
e.preventDefault();
close();
}
};
dialog.addEventListener("cancel", handleEscape);
return () => dialog.removeEventListener("cancel", handleEscape);
}, [isTop, close]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (isTop && e.target === e.currentTarget) {
close();
}
};
return (
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
className="backdrop:bg-black/50"
>
{modal.render()}
</dialog>
);
}
마치며
이번 리팩토링을 하면서 코드를 읽는 사람에 대해 많이 생각하게 됐습니다. 코드는 작성하는 것보다 읽히는 횟수가 훨씬 많습니다. 내가 3개월 뒤에 이 코드를 다시 볼 수도 있고, 팀원이 기능을 추가하기 위해 이 코드를 읽을 수도 있죠. 그때마다 isModalOpen, isAnotherModalOpen, isYetAnotherModalOpen… 이런 상태들을 하나하나 따라가며 “이 모달이 언제 열리는지” 파악해야 한다면 얼마나 피곤할까요? 좋은 추상화란 읽는 사람이 중요한 정보에만 집중할 수 있게 하는 것이라고 생각합니다.