3 minute read

도입 배경

  • 여러 페이지, 여러 상황에 맞는 모달을 별도로 만들어서 관리하자니 컴포넌트별 파일이 너무 많아지고, 비슷한 디자인인 경우에는 개별적으로 수정을 해줘야한다는 번거로움이 생깁니다.
  • 예를 들어 Discord 같은 경우 서버 생성의 경우만 하더라도 서버 카테고리 설정서버 공개 여부서버 커스텀 등 여러 단계가 필요한데, 각 단계별 모달이 나뉘어지면서 관리가 복잡해졌습니다.
  • 이를 해결하기 위해 모달을 전역 상태로 관리하고 합성 컴포넌트 방식으로 구성하여 공통 레이아웃을 재사용하고 콘텐츠만 교체할 수 있도록 했습니다. 이 방식은 유지보수성과 확장성에 모두 유리할 거라 생각했습니다.

합성 컴포넌트란?

합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하고자 하는 곳에서 원하는 방식으로 조합하여 사용하는 컴포넌트 패턴 참고: Kakao FE 개발 블로그

  • 이 패턴은 컴포넌트의 재사용성과 유연성을 높여주며, 특히 공통 레이아웃을 공유해야 하는 모달 컴포넌트에 적합합니다.

구조

  • Modal 컴포넌트는 Modal.Header, Modal.Content, Modal.Footer의 형태로 구성되며, 합성 컴포넌트 구조를 따릅니다.

    import React from "react";
    
    import useModalStore from "../../../stores/modalStore";
    import { ModalType } from "../../../types";
    
    import * as S from "./styles";
    
    interface ModalProps {
      children: React.ReactNode;
      name: ModalType;
    }
    
    const Modal = ({ children, name }: ModalProps) => {
      const { closeModal, closeAllModal } = useModalStore();
    
      return (
        <>
          <S.Overlay onClick={() => closeModal(name, "close-modal")} />
          <S.ModalContainer>
            <S.CloseButton>
              <S.CloseIcon size={28} onClick={() => closeAllModal()} />
            </S.CloseButton>
            {children}
          </S.ModalContainer>
        </>
      );
    };
    
    const Header = ({ children }: { children: React.ReactNode }) => {
      return <S.HeaderWrapper>{children}</S.HeaderWrapper>;
    };
    
    const Content = ({ children }: { children: React.ReactNode }) => {
      return <S.ContentWrapper>{children}</S.ContentWrapper>;
    };
    
    const Footer = ({ children }: { children: React.ReactNode }) => {
      return <S.FooterWrapper>{children}</S.FooterWrapper>;
    };
    
    Modal.Header = Header;
    Modal.Content = Content;
    Modal.Footer = Footer;
    
    export default Modal;
    

전역 상태 관리

  • useModalStore를 통해 모달의 열림/닫힘 상태를 전역에서 관리합니다.
  • closeModal(name, reason) 또는 closeAllModal()을 통해 모달을 제어할 수 있습니다.

    import { create } from 'zustand';
    import { immer } from 'zustand/middleware/immer';
    
    import { BaseModalData, ModalType } from '../types';
    
    type ModalState = {
      modal: { [K in ModalType]?: BaseModalData };
    };
    
    type ModalActions = {
      openModal: <T extends ModalType>(type: T, content: React.ReactNode) => void;
      closeModal: (type: ModalType, key: string) => void;
      closeAllModal: () => void;
    };
    
    export const useModalStore = create<ModalState & ModalActions>()(
      immer((set) => ({
        modal: {},
        openModal: (type, content) => {
          document.body.style.overflow = 'hidden';
          set((state) => {
            state.modal[type] = {
              content,
            };
          });
        },
        closeModal: (type) => {
          document.body.style.overflow = 'unset';
          set((state) => {
            if (state.modal[type]) {
              delete state.modal[type];
            }
          });
        },
        closeAllModal: () => set({ modal: {} }),
      })),
    );
    
    export default useModalStore;
    
    

모탈 타입 정의

모달의 용도에 따라 타입은 아래와 같이 두 가지로 구분합니다.

  1. basic: 하단 버튼이 없는 단순 모달
  2. withFooter: 하단에 버튼이 있는 모달

디스코드를 사용해보면서 모달의 형태는 전반적으로 두가지 형태로 나뉘는데, 첫번째는 하단에 버튼이 없는 형태 두번째는 하단에 버튼이 있는 스타일이었습니다. 이러한 모든 요소까지 반영할 수 있도록 모달의 type을 ‘basic’, ‘withFooter’로 구분하였고, 모달 구현시 Footer 영역을 포함시켜줬습니다.

실제 사용 예를 보면, Discord에서는 두 형태의 모달이 사용됩니다. 아래 이미지처럼 Modal.Footer 영역의 유무에 따라 다른 스타일로 구성됩니다:

image image

사용 예시

  • 버튼이 없는 basic 모달
    const CreateGuildModal = ({ setCurrentModal }: CreateGuildModalProps) => {
      return (
        <S.CreateGuildModal>
          <Modal name="**basic**">
            <**Modal.Header**>
              <S.HeaderText>이 서버에 대해 더 자세히 말해주세요</S.HeaderText>
            </Modal.Header>
            <**Modal.Content**>
              <CaptionText>
                설정을 돕고자 질문을 드려요. 혹시 서버가 친구 몇 명만을 위한 서버인가요, 아니면 더 큰 커뮤니티를 위한
                서버인가요?
              </CaptionText>
            </Modal.Content>
            <S.CreateButtons onClick={() => setCurrentModal('customize')}>
              <S.CreatePrivateGuild>
                <SmallButtonText>나와 친구들을 위한 서버</SmallButtonText>
                <TbChevronRight size={24} />
              </S.CreatePrivateGuild>
              <S.CreatePublicGuild>
                <SmallButtonText>커뮤니티용 서버</SmallButtonText>
                <TbChevronRight size={24} />
              </S.CreatePublicGuild>
            </S.CreateButtons>
          </Modal>
        </S.CreateGuildModal>
      );
    };
    
  1. Footer가 있는 withFooter 모달

    const CustomizeGuildModal = ({ setCurrentModal }: CustomizeGuildModalProps) => {
      const [inputValue, setInputValue] = useState('');
    
      return (
        <S.CustomizeGuildModal>
          <Modal name="withFooter">
            <**Modal.Header**>
              <BodyMediumText>서버 커스터마이즈하기</BodyMediumText>
            </Modal.Header>
            <**Modal.Content**>
              <S.ContentContainer>
                <CaptionText>새로운 서버에 이름과 아이콘을 부여해 개성을 드러내 보세요. 나중에 언제든 바꿀 수 있어요.</CaptionText>
                <S.ImageUpLoad>
                  <S.UpLoadIcon>
                    <LuCamera size={24} />
                    <SmallText>UPLOAD</SmallText>
                  </S.UpLoadIcon>
                  <S.PlusIcon>
                    <TbPlus size={18} />
                  </S.PlusIcon>
                </S.ImageUpLoad>
                <S.GuildNameWrapper>
                  <ChipText>서버 이름</ChipText>
                  <S.GuildNameInput
                    onChange={(e) => setInputValue(e.target.value)}
                    value={inputValue}
                    placeholder="서버 이름을 입력해주세요"
                  />
                  <S.Caption>서버를 만들면 Discord의 커뮤니티 지침에 동의하게 됩니다.</S.Caption>
                </S.GuildNameWrapper>
              </S.ContentContainer>
            </Modal.Content>
            <**Modal.Footer**>
              <S.FooterContainer>
                <S.BackButton onClick={() => setCurrentModal('initial')}>뒤로가기</S.BackButton>
                <S.CreateButton>만들기</S.CreateButton>
              </S.FooterContainer>
            </Modal.Footer>
          </Modal>
        </S.CustomizeGuildModal>
      );
    };
    

마무리

Modal.Header, Modal.Content, Modal.Footer를 합성 컴포넌트로 구성하면서 공통 레이아웃을 유지하면서 콘텐츠만 교체할 수 있었습니다. 이를 통해 유지보수성, 확장성, 코드 가독성 모두 개선할 수 있는 경험이었습니다.