Skip to content
Go back

더 이상 isModalOpen을 쓰고 싶지 않아요

Edit page

들어가며

러닝 크루 매칭 서비스를 개발을 하면서, 처음에는 빠른 기능 구현을 위해 모달 컴포넌트를 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}
        />
      )}
    </>
  );
}

제가 생각한 문제는 다음과 같습니다:

목표: 모달의 중요한 정보만 보여주기

이 코드를 개선하기 위해, 다음을 목표로 설정했습니다.

모달을 열고 닫는 상태는 숨기고, “언제 열지, 무엇을 보여줄지”를 한 곳에서 보여주기

그렇게 아래와 같이 코드를 작성하길 기대했습니다.

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> 요소를 사용해봤는데요. 여러가지 모달의 동작들과 고려해야할 접근성을 쉽게 챙길 수 있습니다.

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… 이런 상태들을 하나하나 따라가며 “이 모달이 언제 열리는지” 파악해야 한다면 얼마나 피곤할까요? 좋은 추상화란 읽는 사람이 중요한 정보에만 집중할 수 있게 하는 것이라고 생각합니다.

참고 자료

MDN - <dialog>
대기업 개발자는 “Modal” 어떻게 관리할까? 🤔


Edit page
Previous Post
프론트엔드 디자인 패턴의 변화 - MVC, Observer, MVVM, Flux
Next Post
useMediaQuery 훅 구현하기: useEffect에서 useSyncExternalStore로