선언적인 Modal을 위한 여정

Doeunnkimm·2023년 9월 17일
5

FE log

목록 보기
1/6
post-thumbnail

일단! 저는 이렇게 모듈화 했어요

결론부터, 제가 모듈화한 Modal 컴포넌트를 Stoybook으로 확인해 보면 다음과 같습니다.

이번 글에서는 제가 Modal 컴포넌트를 과거에 어떻게 모듈화했으며 그 모듈화는 어떤 점이 잘못 되었었고, 이번에 그 점을 어떻게 개선했는지를 이야기해보려고 합니다 :)

고민을 많이 했던 만큼 자세하게 적을 예정입니다 😅..!

😂 지난 나의 Modal

제가 Modal이라는 컴포넌트를 모듈화한 것이 처음은 아니였습니다. 아래는 실제 과거에 제가 작성했던 Modal 컴포넌트 코드입니다.

function Modal({ size, children, ...rest }) {
	const setIsOpenModal = useSetRecoilState(isOpenModalAtom)

	const onClickCloseModal = () => {
		setIsOpenModal(false)
	}
    
    return (
      ...
      <span onClick={onClickCloseModal}></span>
      <div>{children}</div>
      ...
    )
}

모달은 언제 어디서든 열릴 수 있다고 생각했고, 그래서 모달의 열고 닫혀 있는 상태를 전역 상태로 관리했습니다.

일단 좋아요! 덕분에 자식 컴포넌트에 내려 내려 내려 주는 일 없이도 어디서든 모달을 열고 닫는 것이 가능해졌습니다. 아래와 같이 말이죠(아래도 제가 실제 작성했던 패턴입니다).

function Foo() {
	const [isOpenModal, setIsOpenModal] = useRecoilState(isOpenModalAtom)
    
    return (
		<>
        	<button onClick={() => setIsOpenModal(true)}>모달 열려랏</button>
            {isOpenModal && (
                <Modal>
                    <div>안녕하세요</div>
                </Modal>
            )}
        </>
    )
}

일단 모달이 열려 있는지 닫혀 있는지에 따라 모달을 보여줄지 말지를 결정해야 하니 모달의 상태 코드를 불러왔습니다. 그리고 이제 View 로직에서는 "isOpenModal이 true라면~ 모달을 보여줘"라는 식의 패턴으로 작성했었습니다.

😭 사실 DX에 좋지 못한 코드였어요

위와 같은 패턴으로 모달을 모듈화하게 되면, DX에 좋지 못합니다. 이유는 다음과 같습니다.

  • 사용하는 컴포넌트에서의 핵심 로직에 모달 상태와 관련된 코드가 섞여있어요.
  • 모달을 사용할 때마다 전역 상태 관련 코드를 작성해야 해요.
  • 모달을 사용할 때마다 {isOpenModal && ...}을 작성해야 해요.

여기서 문제는 사용할 때마다 반복적이고도 섞여서 선언 및 작성해줘야 하는 코드가 있다는 겁니다.

그래서 이번에 Modal 컴포넌트 모듈화를 맡게 되었을 때 가장 고민을 많이 한 부분은 어떻게 하면 쉽고 간단하고도 쿨하게 사용할 수 있을까? 였습니다.

👏 이번 나의 Modal

이번에 제가 모듈화를 한 방법에 대해 소개해 보려고 합니다.

1. Headless UI 컴포넌트로 Modal 컴포넌트 만들기

사실 UI 라이브러리(Tailwind, bootstrap 같은)를 사용중이여서, Modal 컴포넌트가 포함되어 있었지만 사용하지 않았습니다. 해당 컴포넌트들은 스타일적으로 정형화 되어있어 커스터마이징 하기가 어렵습니다. 라이브러리에서 제공하는 스타일이 마음에 든다면 사용해도 상관없지만요!

저 같은 경우에는 프로젝트에서 사용되는 모달은 분명 UI 라이브러리에서 제공되는 Modal 컴포넌트와는 스타일적으로 달랐기에 이를 사용하지 않고 만들어 쓰기로 결정했습니다.

우선 Modal을 사용하기 위해 Modal 컴포넌트부터 만들어야겠죠? 저는 이때 Headless UI 라이브러리의 컴포넌트를 이용했습니다.

🤔 Headless UI 라이브러리를 이용하면!
컴포넌트들이 스타일링을 포함하지 않고, 로직과 기능만을 제공한다는 것을 의미
→ 즉, 동작 방식에 대해 신경 쓸 필요 없이, 오직 어떻게 보일지에만 집중할 수 있다 ✨

이는 UI 라이브러리의 컴포넌트의 기능을 사용하고 싶기도 했지만서도, 스타일을 커스터마이징 하기 어려워 사용하지 않았던 부족함을 채워줄 수 있었습니다.

ark-ui 라이브러리

Dialog를 이용했습니다. 살펴보면 여러 기능 컴포넌트와 Props를 소개해주고 있습니다. 공식 문서가 은근 불친절해서 github 코드를 살펴보고 이것저것 시도해본 결과 제가 알짜로 사용한 컴포넌트들은 아래와 같습니다.

  • Portal : z-index 선언없이 페이지 바로 아래 children에 element를 놓을 수 있어요.
  • DialogContainer : Container (쉽게 말해 Background부터 포함)
  • DialogContent : Content 부분 (쉽게 말해 실제 모달 내용 부분)

좀 전에 말했다싶이 이 컴포넌트들에는 스타일이 쏙 빠져있기 때문에 스타일 컴포넌트는 원하는 대로 선언하여 중간중간에 넣어주면 됩니다.

제가 만든 Modal 컴포넌트는 아래와 같습니다.

"use client";

import { Dialog, DialogContainer, DialogContent, Portal } from "@ark-ui/react";

import * as Styled from "./Modal.styles";
import type { ModalProps } from "./Modal.types";
import { ModalBody, ModalFooter } from "./components";

export const Modal = ({ isOpen, props, onClose }: ModalProps) => {
  return (
    <Dialog open={isOpen} onClose={onClose}>
      <Portal>
        <DialogContainer>
          <Styled.Background>
            <DialogContent>
              <Styled.ModalWrapper>
                <ModalBody props={props} />
                <ModalFooter props={props} onClose={onClose} />
              </Styled.ModalWrapper>
            </DialogContent>
          </Styled.Background>
        </DialogContainer>
      </Portal>
    </Dialog>
  );
};

2. 선언적인 컴포넌트를 위한 Props 결정

선언적인 컴포넌트가 되기 위해서는 아래와 같은 부분에 집중해야 합니다.

📌 무엇을 하는지만 외부에서 보여지고, 어떻게 하는지는 컴포넌트 내부에서 처리한다.

이를 다시 생각해보면, props를 통해서 해당 컴포넌트가 무엇을 하는지가 파악이 되어야 한다는 점이라고도 할 수 있습니다.

저의 Modal 컴포넌트의 경우 무엇을 하는지 파악하기 위해 다음과 같은 내용들이 중요했습니다.

  • 내용
  • Modal의 타입 (본 프로젝트의 경우 3가지 타입의 모달이 존재했습니다)
  • 확인을 눌렀을 때의 이벤트

무엇을 하는지 파악하기 위해서는 위 3가지가 중요했으며, 이 외 모달이 열렸는지와 닫기 위한 이벤트 함수와는 구분을 지어 묶어주었습니다.

export interface ModalProps {
  isOpen: boolean;
  props: {
    type: "positive" | "negative" | "warning";
    title: string;
    onConfirm: VoidFunction;
  };
  onClose: VoidFunction;
}

export type EssentialModalProps = ModalProps["props"];

3. Props는 어디서는 넘겨줄 수 있도록

지난 저의 Modal처럼 전역 상태를 이용하여 어디서든 Modal을 이용하는 것도 필요합니다. 하지만 지난 컴포넌트에서는 모달이 필요한 매 페이지마다 { isOpenModal && ... }을 작성해야 했습니다.

이번에는 Context와 Provider를 사용하여 Provider 바로 아래 childrenModal을 함께 두어 매번 작성하지 않도록 하려고 했습니다.

const initialModalProps: ModalProps = { ... };

export const ModalContext = createContext<ModalContextProps>({
  onOpenModal: () => {},
});

export const ModalProvider = ({ children }: PropsWithChildren) => {
  const [modalProps, setModalProps] = useState(initialModalProps);

  const onOpenModal = (props: EssentialModalProps) => {
    setModalProps((prev) => ({ ...prev, isOpen: true, props }));
  };
  
  const onClose = () => {
    setModalProps((prev) => ({ ...prev, isOpen: false }));
  };

  return (
    <ModalContext.Provider value={{ onOpenModal }}>
      {children}
      <Modal {...modalProps} onClose={onClose} />
    </ModalContext.Provider>
  );
};

즉, 무엇을 나타내는지를 위해 중요했던 props는 사용할 때 받도록 하고 이 외였던 열려있는지 상태 그리고 닫기 위한 이벤트 함수는 미리 Provider 내부에서 같이 처리되거나 미리 전달하는 형태로 로직을 작성했습니다.

또, children이랑 Modal 컴포넌트를 같이 둠으로써 매번 선언하지 않고도 isOpen이 true 상태가 되기만 한다면 모달은 화면에 보일 것입니다.

4. Provider를 쉽게 사용하기

위에서 isOpen이 true가 되면 모달이 화면에 보인다고 했습니다. 즉, 모달을 어디선가 사용을 했다라는 말을 의미하죠?

이제 편한 사용을 위한 코드를 작성해 봅시다.

저는 좀 전에 ContextProvider를 선언했는데요. 보통 이를 편하게 사용하기 위해 hook 함수를 만들어 사용합니다. 저는 useModal이라고 이름 지어주었어요.

이제 useModal을 어떻게 구성할지를 생각해야 했습니다. 저는 이 구성을 할 때 TOSS의 useOverlay를 통해 아이디어를 얻었었는데요.

useOverlay와 완벽하게 똑같게는 아니였지만, 훅 함수를 통해 open이라는 메서드를 포함한 객체를 return 해주어, 사용할 때 open을 통해 필요한 내용들을 실어서 사용하도록 해야겠다고 생각했습니다.

Provider를 선언할 때 onOpenModal이라는 필수 요소를 인자로 받고 isOpen은 true로 같이 처리해주는 메서드를 선언해서 Provider의 value로 넣어주었었습니다.

const onOpenModal = (props: EssentialModalProps) => {
    setModalProps((prev) => ({ ...prev, isOpen: true, props }));
};

따라서 위 메서드를 훅 함수를 통해 return 해주면 됩니다.

import { useContext } from "react";
import { ModalContext } from "../context/ModalContext";

export const useModal = () => {
  const { onOpenModal } = useContext(ModalContext);

  if (onOpenModal === (() => {})) {
    throw new Error("useModal should be used within ModalContext.Provider");
  }

  return { open: onOpenModal };
};

추가로, Context는 최상위에 Provider를 감싸주지 않으면 사용하지 못합니다. 혹시 최상위에 Provider 감싸주는 것을 까먹어 모달을 사용하지 못하는 상황을 빠르게 파악할 수 있도록 Error를 throw하는 부분도 넣어주었습니다.

5. 사용해보기

이제 만들어 둔 useModal을 이용하면 2줄이면 모달을 명확하고도 간단하게 사용할 수 있습니다.

const Foo = () => {
	const modal = useModal()
    
  	const onClickOpenModal = () => {
    	modal.open({ type: "positive", title: "테스트 모달입니다.", onConfirm: () => alert("잘 실행되었네요!") });
  	};
  
 	return (
    	<button onClick={onClickOpenModal}>모달 열려랏</button>
    )
}

6(선택). Storybook으로 문서화하기

제가 아무리 (물론 제가 생각하기에는 이지만) 간편하게 사용할 수 있도록 모듈화를 했다 하더라도 다른 개발자들은 당연히 어떻게 사용하는지는 관련된 여러 코드를 열어보면서 파악을 하고 테스트로 사용을 해보면서 사용법을 알아가야 할 수도 있습니다.

이럴 때 스토리북은 최고의 문서화 방법이겠죠!
props만 간단하게 넘길 때는 args를 통해도 빠르게 Story를 만들 수 있지만, 저는 이번에 사용 방법에 초점을 맞추어 Story를 작성해 두었습니다.

물론 더 필요한 Story도 같이 작성해 주었구요 😉

const ModalSample = () => {
  const modal = useModal();

  const onClickOpenModal = () => {
    modal.open({ type: "positive", title: "테스트 모달입니다.", onConfirm: () => alert("잘 실행되었네요!") });
  };

  return <button onClick={onClickOpenModal}>누르면 모달이 떠요</button>;
};

export const ButtonToModal: Story = {
  render: () => {
    return (
      <ModalProvider>
        <ModalSample />
      </ModalProvider>
    );
  },
};


이번 글을 통해 지난 저의 Modal 컴포넌트에 대해 돌아보고 문제점을 짚어보기도 했습니다. 이를 통해 개선점을 찾아보고 새로 Modal 컴포넌트를 모듈화 해보았습니다.

이번 글 역시도 이 코드가 정답이야!!는 절대 아니며 제가 고민한 내용들을 정리해보기 위해 작성했음을 말씀드리고 글을 마무리하려고 합니다 :)

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

3개의 댓글

comment-user-thumbnail
2023년 11월 30일

프로젝트에서 Modal 컴포넌트 추상화를 맡게 되었는데, 도은님 글이 큰 도움이 됐습니다! 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 6월 25일

평소에 생각하고 있던건데 잘 정리되어 있어서 너무 좋습니다! 많은 도움이 되었습니다

그런데 모달 타입이 3개 뿐이라 ModalBody 컴포넌트 내에서 type 구분을 하여 바디 구현이 된거 같은데요

모달 타입이 더 많고 앞으로도 더 늘어날 수 있는 프로젝트라면 어떻게 구현하실 생각이셨을까요? 그냥 모달 바디 내에서 타입을 더 추가하고 구현을 외부 파일로 빼서 추가 구현을 계속 할 수 있는건 당연하지만 혹시 다른 방법을 생각하신게 있나 궁금해서 댓글 드립니다~

답글 달기