[Vue3] 재사용 가능한 모달을 만들자

쭌로그·2025년 2월 6일
3
post-thumbnail

사내 새로운 프로젝트에 들어가며 Modal, popup, Dialog 등 여러 팝업을 동시에 띄워야 하는 로직이 필요했습니다.
이전 프로젝트에도 동일한 사항은 있었지만 Component 내부에서 dialog를 호출하고 각 로직이 분리되어 있어 유지 보수가 어려운 부분이 있었습니다.
이를 해결하고자 Pinia를 사용하여 전역적으로 관리하고 없애는 로직을 개발했고 이를 정리하고자 합니다.

1. 전역적으로 모달 관리하기

모달을 사용하기 위해 모달을 열고 닫는 로직이 필요합니다. 이를 효율적으로 관리하기 위해 전역 스토어에 관리하고 필요한 모달을 불러오도록 구현했습니다.

//@/store/useModalStore.js
import { defineStore } from "pinia";
import {defineAsyncComponent, markRaw, ref} from 'vue';

export const MODALS = {
  confirm: defineAsyncComponent(() => import("@/components/modal/ConfirmModal.vue")),
  review: defineAsyncComponent(() => import("@/components/modal/ReviewModal.vue")),
};

export const useModalStore = defineStore("modal", () => {
    // 여러개의 모달을 관리.
    const modals = ref([]);
    const layoutVisible = ref(false);
    const modalOpen = async (Component, props) => {
      layoutVisible.value = true;
      const curModal = markRaw(await Component).default;
      modals.value.push({
        component: curModal,
        props :{
        	...props,
          	layoutVisible: true,
        }
      });
    }
    const modalClose = (component) => {
      modals.value = modals.value.filter(modal => modal.component !== component);
      layoutVisible.value = !!modals.value.length;
    }
    return {
      modals,
      layoutVisible,
      modalOpen,
      modalClose,
    }
});

왜 컴포넌트를 markRaw에 감싸서 push하나요?

📌 markRaw()란?

markRaw()를 사용하면 Vue가 객체를 반응형으로 변환하는 것을 방지합니다.
import로 불러온 컴포넌트는 Vue 객체입니다. vue 객체를 ref, reactive에 할당한다면 자동으로 컴포넌트가 반응형이 되어버립니다.
하지만 우리는 컴포넌트가 반응형일 이유가 없습니다. 오히려 컴포넌트가 반응형이면 불필요한 반응형으로 오버헤드가 발생하고 성능이 저하될 수 있습니다. 이를 해결하기 위해 markRaw 메서드를 사용하여 반응형을 방지하고 push하는 것입니다.

2. layout을 통해 모달을 렌더링하기

모달 스토어를 만들었으니 이제 원하는 모달을 뿌리는 공간을 만들어야합니다.
Modal, Dialog, Popup은 화면 제일 앞에 위치하기 때문에 특정 컴포넌트에 들어가는 것이 아닌 body 태그의 자식 태그로 들어가는 것이 적절하다고 생각했습니다.
이를 위해 Teleport 태그를 사용하여 layout을 body의 자식 태그로 만들었습니다.

<script setup>
import { useModalStore } from "@/store/useModal";
import { storeToRefs } from "pinia";
const modalStore = useModalStore();
const {visible, modals} = storeToRefs(modalStore);
const {modalClose} = modalStore;

</script>
<template>
  <Teleport to="body">
    <div class="modal" v-if="visible">
    	      <!--modals에 존재하는 컴포넌트를 차례대로 렌더링 -->
      <template v-for="({Component, props}, index) in modals" :key="index">
        <component :is="Component" v-bind="{...props, close: () => modalClose(Component)}" />
      </template>
    </div>
  </Teleport>
</template>

3. modal, dialog, popup에 props 바인딩

위 3개에서 가장 중요한 부분은 props의 사용될 공통 props를 통일시켜야합니다.
openModal 메서드에서 props는 'visible: true'로 props를 넘겼는데 dialog에서는 v-if="open"으로 하면 에러가 발생하기 때문입니다.
이를 위해 공통 컴포넌트는 네이밍을 통일하고 각각 사용될 props도 최대한 비슷한 네이밍으로 작성하여 혼란을 방지해야합니다. 아래는 간단한 모달 예제입니다.

<script setup>
const props = defineProps({
  close: Function,
  visible: Boolean,
  text: String,
});
</script>

<template>
  <div v-if="visible">
    <h1>{{ text }}</h1>
    <button @click="close">Close</button>
  </div>
</template>

4. 컴포넌트 Open하기

<template>
  <button @click="modalStore.modalOpen(MODALS.review, {text: 'Hello'})">Open</button>
</template>

<script setup>
import { useModalStore, MODALS } from "@/store/useModal";
const modalStore = useModalStore();

</script>

위는 버튼을 클릭했을 때 간단하게 모달을 등록할 수 있습니다.
MODALS에서는 dynamic import를 통해 성능을 개선했습니다.
또한 defineAsyncComponent를 통해 Suspense를 사용하여 사용자 UX를 개선할 수 있습니다.

5. 개선된 UX Layout

사용자 UI를 개선하기 위해 Suspense + Skeleton을 사용하여 사용자 UX를 개선한 코드입니다.

  <script setup>
  import { useModalStore } from "@/store/useModal";
  import { storeToRefs } from "pinia";
  import SkeletonModal from "@/components/modal/SkeletonModal.vue";
  const modalStore = useModalStore();
  const {visible, modals} = storeToRefs(modalStore);
  const {modalClose} = modalStore;

  </script>
  <template>
    <Teleport to="body">
      <div class="modal" v-if="visible">
        <!--  -->
        <Suspense>
          <template #default>
            <template v-for="({Component, props}, index) in modals" :key="index">
              <component :is="Component" v-bind="{...props, close: () => modalClose(Component)}" />
            </template>
          </template>
          <template #fallback>
            <SkeletonModal />
          </template>
        </Suspense>
      </div>
    </Teleport>
  </template>

마치며

이번 과정을 통해 markRaw와 클린 컴포넌트에 대해 좀 더 알아가는것 같아서 뿌듯했습니다. 평소에 컴포넌트를 Props로 넘길 일이 없어서 markRaw를 사용할 일이 없었는데 markRaw를 사용하여 확장성이 높은 컴포넌트를 개발할 수 있을것 같아 다른 곳에서도 사용해봐야 할 것 같습니다.

참고

sxungchxn.dev-클린한 모달 사용하기

profile
매일 발전하는 프론트엔드 개발자

4개의 댓글

comment-user-thumbnail
2025년 5월 21일

감사합니다 프로젝트에 참고로 도움되었습니다!
중간에 visible -> layoutVisible
markRaw(await Component).default; -> default X
Component -> component
손봐야 하는 부분이 있었습니다!

1개의 답글
comment-user-thumbnail
2025년 8월 5일

덕분에 기존의 모달시스템을 예전부터 원하던 방식으로 변경할 수 있었습니다.
쭌로그님의 글을 참고해 저도 모달 시스템 구현기를 작성할 수 있었네요.
감사합니다..!

1개의 답글