팀원들이 사용하기 편한 Modal 컴포넌트를 만들자

chaaerim·2023년 1월 30일
8

디프만 toks 프로젝트를 진행하면서 공통 컴포넌트 중 Modal을 맡아서 개발을 하게 되었다.

내가 만든 Modal 너를 위해 구웠지.. 🍪

처음에는 정말 간단하게 아래와 같이 모달을 구현하였다.

interface Props {
  modalTitle: string;
  contents: ReactNode;
  button: ReactNode;
  setIsModalOpened: Dispatch<SetStateAction<boolean>>;
}

export function Modal({ modalTitle, contents, button, setIsModalOpened }: Props) {
  const onClickOutsideModal = () => {
    setIsModalOpened(false);
  };
  const onClickInsideModal = (e: React.MouseEvent) => {
    e.stopPropagation();
  };
  return (
    <ModalWrapper onClick={onClickOutsideModal}>
      <ModalContainer onClick={onClickInsideModal}>
        <Text variant="title03">{modalTitle}</Text>
        <Flex direction="column">
          {contents}
          {button}
        </Flex>
      </ModalContainer>
    </ModalWrapper>
  );
}


구현해야 하는 모달의 디자인 포멧이 위와 같이 모달 타이틀과 하단의 콘텐츠가 들어가는 구조였기 때문에 위와 같이 인터페이스를 구성하여 모달 컴포넌트를 구현했다.

또한 모달 창이 아닌 모달 창의 바깥 부분을 클릭하면 모달이 닫히길 원했기 때문에 모달 상태를 제어하는 boolean값을 두었다. 또한 모달의 내부를 클릭할 시에는 모달이 닫히는 것을 방지해야했기 때문에, 즉 모달 내부를 클릭했을 때 모달을 감싸고 있는 부모 엘리먼트에 클릭이벤트가 전달되는 이벤트 버블링을 막기 위해 stopPropagation 함수를 사용했다.

그러나 모달을 만들고 난 이후 모달에 대한 상태관리를 결국 const [isModalOpened, setIsModalOpened] = useState(false); 과 같은 코드를 반복하면서 모달을 사용하는 쪽에서 해야한다는 사실이 나의 심기를 불편하게 만들었다.. ~(공통 컴포넌트를 만들었지만 제대로 만들지 못한 느낌이랄까... )~

민석님께서 모달 Dimmer(배경)에 대한 제어는 ref를 활용하는 것이 더 바람직해보인다고 리뷰를 남겨주셨다!! ref를 제대로 이해하고 사용해본 적이 없었는데 민석님 덕분에 코드 수정을 하면서 ref도 공부하는 일석이조의 시간이었다.

Ref를 사용해야 할 때
Ref의 바람직한 사용 사례는 다음과 같습니다.

  • 포커스, 텍스트 선택영역, 혹은 미디어의 재생을 관리할 때.
  • 애니메이션을 직접적으로 실행시킬 때.
  • 서드 파티 DOM 라이브러리를 React와 같이 사용할 때.

리액트 공식문서에서는 위와 같은 상황에서 Ref를 사용하라고 권장한다. 즉 모달에서 배경에 포커스가 가는 경우 모달 창을 닫기 때문에 Ref의 사용이 적합해 보였다.

추가로 현구님이 모달과 같은 overlay되는 요소들은 Promise로 생성하여 순차적으로 처리될 수 있도록 구현하는 것이 좋다는 설명을 해주셨다. 우리 서비스에서는 toast 알림, Modal과 같이 비동기로 처리되는 요소들이 존재한다.

이러한 비동기 요소들을 개발자들이 의도한 대로 순차처리를 하기 위해서는 Promise로 객체를 만들고 await을 통해 이를 처리하면 동기적인 것처럼 비동기 요소를 제어할 수 있다.

이 부분에 대해서는 전혀 고려하지 못하고 있었는데 앞으로 확장될 서비스와 수정될 디자인 시스템을 생각하면 너무나 중요한 부분이었다. (이 글을 빌어 다시 한 번 현구님께 감사함을 .. )
+플러스로 컴포넌트가 어떻게 사용되면 좋을지 먼저 상상해보고 인터페이스를 구성하는 것이 좋다고 조언해주셔서 모달 컴포넌트를 대폭 수정하게 되었다.

수정된 모달

import { useOverlay } from '@toss/use-overlay';

export function useModal() {
  const overlay = useOverlay();
  const ref = useRef<HTMLDivElement>(null);

  const openModal = (props: ComponentProps<typeof Modal>) =>
    new Promise(resolve => {
      overlay.open(({ isOpen, close }) => {
        return (
          <>
            {isOpen ? (
              <Dimmer
                ref={ref}
                onClick={(e: React.MouseEvent) => {
                  {
                    e.target === ref.current ? close() : null;
                  }
                  resolve(false);
                }}
              >
                <Modal {...props} />
              </Dimmer>
            ) : null}
          </>
        );
      });
    });

  return { open: openModal, close: overlay.close };

위와 같이 openModal 함수 내부에 Promise 객체를 생성하고 resolve된 경우 토스의 useOverlay hook을 사용해 모달의 동작을 제어한다. overlay된 요소가 isOpen인 경우 모달과 Dimmer를 보여주고 그렇지 않은 경우 아무것도 보여주지 않는다.

또한 Dimmer에 ref를 달아주어 현재 클릭된 target이 Dimmer라면 모달을 닫고 그렇지 않다면 null 처리를 하여 사용자가 모달이 아닌 배경을 클릭했을 시에만 모달 창이 닫히도록 구현을 하였다.

사용하는 쪽에서 모달의 상태를 관리하는 것이 아니라 모달을 열고 싶으면 open, 닫고 싶으면 close함수를 이용해서 더욱 간편하게 모달을 사용할 수 있도록 컴포넌트를 만들었다.

토스의 useOverlay는 어떻게 생겼을까 ? 🧐

  return useMemo(
    () => ({
      open: (overlayElement: CreateOverlayElement) => {
        mount(
          id,
          <OverlayController
            // NOTE: state should be reset every time we open an overlay
            key={Date.now()}
            ref={overlayRef}
            overlayElement={overlayElement}
            onExit={() => {
              unmount(id);
            }}
          />
        );
      },
      close: () => {
        overlayRef.current?.close();
      },
      exit: () => {
        unmount(id);
      },
    }),
    [id, mount, unmount]
  );
export const OverlayController = forwardRef(function OverlayController(
  { overlayElement: OverlayElement, onExit }: Props,
  ref: Ref<OverlayControlRef>
) {
  const [isOpen, setIsOpen] = useState(false);

  const handleClose = useCallback(() => setIsOpen(false), []);

  useImperativeHandle(
    ref,
    () => {
      return { close: handleClose };
    },
    [handleClose]
  );

토스에서 제공하는 useOverlay는 위와 같이 구성되어있다.어떠한 요소가 overlay될 때 해당 요소를 mount시키고 exit될 때는 unmount시키는 구조이다. OverlayController에서 모달의 상태를 나타내고 제어하는 const [isOpen, setIsOpen] = useState(false); 코드를 가지고 있고 close함수에서 setIsOpen(fasle)를 이용하여 overlay된 요소를 닫는다.

사용 예시

  const { open } = useModal();

  const openModal = async () => {
    await open({
      children: <Text variant="title01">hi</Text>,
    });
  };

위와 같이 이제 더 이상 사용하는 측에서 모달 상태에 대한 코드를 작성하지 않아도 모달을 open하고 close할 수 있다 !!
사용할 때에는 모달 내부에 어떤 요소가 들어갈 지만 고민하면 된다.
2차 수정을 통해 결국 내가 지향하던 방향으로 모달 컴포넌트를 구현할 수 있었다.
앞으로도 나도 사용하기 편하고 팀원들은 더더 사용하기 편한 컴포넌트를 만들기 위해 고민해야지.!.!!!.!!



참고자료
https://github.com/toss/slash/blob/main/packages/react/use-overlay/src/useOverlay.tsx
https://ko.reactjs.org/docs/refs-and-the-dom.html

0개의 댓글