전역상태와 합성컴포넌트로 모달관리하기
도입 배경
- 여러 페이지, 여러 상황에 맞는 모달을 별도로 만들어서 관리하자니 컴포넌트별 파일이 너무 많아지고, 비슷한 디자인인 경우에는 개별적으로 수정을 해줘야한다는 번거로움이 생깁니다.
- 예를 들어 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;
모탈 타입 정의
모달의 용도에 따라 타입은 아래와 같이 두 가지로 구분합니다.
- basic: 하단 버튼이 없는 단순 모달
- withFooter: 하단에 버튼이 있는 모달
디스코드를 사용해보면서 모달의 형태는 전반적으로 두가지 형태로 나뉘는데, 첫번째는 하단에 버튼이 없는 형태 두번째는 하단에 버튼이 있는 스타일이었습니다. 이러한 모든 요소까지 반영할 수 있도록 모달의 type을 ‘basic
’, ‘withFooter
’로 구분하였고, 모달 구현시 Footer 영역을 포함시켜줬습니다.
실제 사용 예를 보면, Discord에서는 두 형태의 모달이 사용됩니다. 아래 이미지처럼 Modal.Footer 영역의 유무에 따라 다른 스타일로 구성됩니다:
사용 예시
- 버튼이 없는 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> ); };
-
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
를 합성 컴포넌트로 구성하면서 공통 레이아웃을 유지하면서 콘텐츠만 교체할 수 있었습니다. 이를 통해 유지보수성, 확장성, 코드 가독성 모두 개선할 수 있는 경험이었습니다.