프로미스를 사용한 모달 제작

부루베릐·2024년 4월 8일
0

TIL

목록 보기
17/23
post-custom-banner

개요

새로운 팀에 투입되면서 모달을 새롭게 제작해야 하는 상황이 왔다. 원래는 간단하게 모달을 구현해서 쓰고 있었는데, 프로젝트가 갈수록 복잡해지면서 지금의 모달 코드로는 한계에 봉착해 모달 사용 로직을 고도화시킬 필요가 있었다.

왜 모달을 새로 만들었나

코드 복잡도의 증가

모달을 사용하는 기본적인 방법은 모달을 사용하는 컴포넌트에서 직접 모달 컴포넌트를 관리하는 것이다. 다음이 그 예이다.

<script setup lang="ts">
import { ref } from 'vue'
const isModalOpened = ref(false)
const openModal = () => {
  isModalOpened.value = true
}
const closeModal = () => {
  isModalOpened.value = true
}
const onSuccess = () => {
// '확인' 버튼을 눌렀을 때 처리
  closeModal()
}
const onClose = () => {
// '취소' 버튼을 눌렀을 때 처리
  closeModal()
}
</script>

<template>
  <div>
    <button @click="openModal">Open Confirm Modal</button>
    <dialog v-if="isModalOpened">
     <p>You want to quit?</p>
     <footer>
       <button @click="onSuccess">확인</button>
       <button @click="onClose">취소</button>
     </footer>
    </dialog>
  </div>
</template>

이렇게 모달의 동작을 모달을 불러오는 페이지 혹은 컴포넌트에서 직접 관리하면 신경써야 하는 것들이 많아진다.

  • 모달이 열렸는지 판단하는 플래그
  • 모달을 열고 닫는 핸들러
  • 모달에서 확인 버튼을 눌렀을 때와 취소 버튼을 눌렀을 때에 대한 처리

모달이 하나가 있을 때는 문제가 되지 않을 수도 있다. 하지만 한 페이지에 띄울 수 있는 모달의 개수가 많아지면 모달 각각을 핸들링하기 위한 코드도 그만큼 늘어나므로 코드의 복잡도가 증가할 수밖에 없다.

상세

위와 같은 문제 상황을 토대로 모달 컴포넌트를 제작할 때 크게 두 가지의 핵심을 중심으로 설계하게 되었다.

  • 모달을 사용하는 컴포넌트와 모달 컴포넌트 간의 결합도를 낮추기 위해 pinia 스토어를 사용
    • 모달을 열고 닫을 때 불필요한 코드(isOpened 등의 플래그 등)을 최소화
    • 모달을 사용하는 컴포넌트의 템플릿에서 모달 컴포넌트를 제거
  • 사용자가 모달을 띄워 작업을 처리한 후 모달을 닫아 남은 작업을 진행하는 과정을 프로미스를 사용하여 비동기적으로 처리
    • 프로미스를 resolve하여 모달을 close

정리하자면 pinia 스토어프로미스를 사용하여 모달 시스템을 새로 만들게 되었다는 것인데, 이렇게 설계하게 된 배겅을 좀 더 자세히 설명해 보겠다.

pinia 스토어를 사용한 배경

모달을 사용하는 컴포넌트에서 모달의 행동을 일일이 컨트롤하지 않도록 하지 않고, 모든 모달을 관리하는 로직을 중앙화해서 한 번에 관리하기 위해 pinia를 사용하였다. 컴포넌트나 페이지에서 모달을 사용할 때 최대한 간편하게 사용할 수 있도록(다시 말해 관리해야 하는 상태가 최소한이 되도록) 모달의 기본적인 동작 로직을 스토어로 분리하였고, 동시에 프로젝트 안의 모든 컴포넌트와 페이지에서 모달을 이용할 수 있게 되었다.

프로미스를 사용한 배경

모달의 역할은 사용자의 선택이 이루어지기 전까지 작업 진행을 보류시키는 데에 역할이 있다고 생각한다(물론 팝업을 띄울 때도 당연히 사용할 수 있지만 일단 기본적으로는 모달을 기준으로 설명한다(모달과 팝업의 차이)). 예를 들어 단순한 Confirm 모달을 보면, 확인과 취소를 누르기 전까지 사용자는 다른 행동을 할 수 없다. 또한 확인을 눌렀을 때의 후속 작업과 취소를 눌렀을 때의 후속 작업이 서로 다르므로 이에 대한 분기 처리가 들어가야 한다. 이렇게 사용자의 선택이 일어나기 전까지 동작을 보류하고, 동작의 결과에 따라 서로 다른 흐름을 가져가기 위해 모달을 프로미스 형태로 작성하였다.

이제 코드에서 어떻게 이를 구현하였는지를 보자.

구현

아래의 코드는 깃허브 레포지토리에서 확인할 수 있다.

나는 components 폴더 안에 modal 디렉토리를 만들어 모든 모달에 관련된 로직을 관리하였다. 여러 디렉토리에 걸쳐 로직인 산개되어 있으니 관리하기가 쉽지 않았던지라, 비록 성격이 서로 다른 파일들이지만 한 폴더 아래에 두는 것이 더 편리하였다.

여러 파일에 걸쳐 공통적으로 사용하는 상수를 관리하는 constant.ts와 타입스크립트 타입을 정의해 놓은 type.ts, 그리고 Alert와 Confirm 등 직접 구현한 모달 컴포넌트를 모아 놓은 base 디렉토리를 제외한 나머지 파일들이 핵심이므로 각각 하나씩 뜯어보며 어떻게 구현하였는지를 알아보자.

modal.store.ts

export interface Modal {
  id: string
  component: Component
  props: unknown
  onClose: (value: unknown) => void
}
export const useModalStore = defineStore('modal', () => {
  const modalList = ref<Modal[]>([])
//...
})

한 화면에 여러 개의 모달이 띄워질 수 있으므로 모달 객체를 만들어 배열로 관리한다. 이 모달 배열을 화면에서 렌더링하여 모달을 띄워 줄 것이다. 모달 객체는 다음과 같이 구성된다.

  • id: 모달을 구분해줄 수 있는 id
  • component: 렌더링할 모달 컴포넌트
  • props: 모달 컴포넌트에 필요한 props 객체
  • onClose: 사용자가 모달과 상호작용할 때 호출되는 핸들러
  const handleResolver = (id: Modal['id'], resolver: Modal['onClose'], value?: unknown): void => {
    resolver(value)
    modalList.value = modalList.value.filter(toast => toast.id !== id)
  }
  const openModal = async <C extends Component>(
    id: Modal['id'],
    component: C,
    props: ComponentProps<C>,
  ): Promise<unknown> => {
    return await new Promise(resolve => {
      const newModal: Modal = {
        id,
        component: markRaw(component),
        props,
        onClose: (value: unknown) => {
          handleResolver(id, resolve, value)
        },
      }
      modalList.value = [...modalList.value, newModal]
    })
  }
})

컴포넌트와 페이지에서 모달을 열기 위해 사용하는 pinia action이다. 모달 객체를 만들어 modalList 배열에 추가하는 것이 메인 로직이다. 이 때 openModal()은 프로미스 객체를 새로 만들어 이를 리턴하게 되는데, 따라서 해당 프로미스가 resolve되기 전까지는 openModal() 함수 이후의 코드는 실행되지 않는다.

const handleResolver = (id: Modal['id'], resolver: Modal['onClose'], value?: unknown): void => {
  resolver(value)
  modalList.value = modalList.value.filter(toast => toast.id !== id)
}

해당 모달을 resolve하기 위해서는 Modal 객체의 onClose를 실행시켜 주어야 한다. 모달을 resolve하고 또 그 모달을 닫아주기 위해 handleResolver 함수를 만들었다.

  • id: 상호작용이 끝나 화면에서 사라져야 하는 모달의 id
  • resolver: openModal()에서 반환하는 프로미스의 resolve. 이 함수가 실행되면 openModal() 의 다음 로직으로 넘어갈 수 있다.
  • value: resolve될 때 모달을 불러온 코드에 넘겨주어야 하는 반환값

이렇게 되면 실제로 모달을 사용하기 위해서는 openModal() 액션 하나만 실행시키면 된다. 이전의 코드와 비교해 보면 확실히 간단하게 사용할 수 있다는 것을 알 수 있다.

// 모달 사용 예시
import Confirm from '@/components/modal/base/confirm.vue'

const onClickButton = async () => {
  const res = await modalStore.openModal(
    'test-modal-id',
    ConfirmModal,
    {
      message: 'example message'
    }
  )
  console.log('이 코드는 openModal()이 resolve되기 전까지는 실행되지 않는다.')
  console.log(res, 'res는 handleResolver가 resolver를 실행할 때 그 인자로 받은 반환값이다.')
}

모달을 실제 화면에 렌더링하는 코드이다.

실제로 modal 스토어에서 modalList 객체를 불러와 이를 렌더링한다. 이 때 모달에서 close 이벤트가 발생하였을 때 modal 객체의 onClose 핸들러가 호출되고 모달이 resolve된다.

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import { useModalStore } from '@/components/modal/modal.store'
const modalStore = useModalStore()
const { modalList } = storeToRefs(modalStore)
onMounted(() => {
  if (document.getElementById('modal-container') != null) return
  const modalContainer = document.createElement('div')
  modalContainer.id = CONTAINER_ID
  document.body.append(modalContainer)
})
</script>

<template>
  <component
    :is="modal.component"
    v-for="modal in modalList"
    :key="modal.id"
    v-bind="modal.props"
    @close="modal.onClose"
  />
</template>

이 때 특기할 만한 점은 mounted될 때 body에 모달을 담을 div 컨테이너 요소를 만들어둔다는 것이다. 이제 모든 모달 컴포넌트들은 이 컨테이너 안에 담기게 될 것이다(추후에 설명).

// App.vue
<script setup lang="ts">
import { RouterView } from 'vue-router'
import '@/assets/css/reset.css'
import ModalContainer from './components/modal/modal-container.vue'
</script>

<template>
  <router-view />
  <modal-container />
</template>

그리고 이 컨테이너 컴포넌트를 프로젝트의 최상단 컴포넌트 옆에 둔다. 모든 페이지에서 모달을 띄울 수 있도록 하기 위함이다.

index.vue

모달 컴포넌트를 둘러싼 객체이다. 배경을 dimmed 처리하고 화면의 한 가운데 모달을 위치시키기 위해 사용한다. 필수는 아니며, 내가 사용하는 서비스의 모달 스타일에 맞게 변형하거나 사용하지 않아도 무방하다. 하지만 공통적으로 Vue Teleport를 사용하여 #modal-container 요소 안에 렌더링될 수 있도록 하는 것이 중요하다. 이는

  • 모든 모달을 한 곳에서 렌더링하고
  • 다른 서비스 내의 코드와 분리하여 관리의 용이함을 위해서이다.

여기서는 vueuse의 onClickOutside를 통해 배경 클릭 시 close 이벤트를 emit하여 모달이 닫혀질 수 있도록 하였다.

<script setup lang="ts">
import { ref, useSlots } from 'vue'
import { onClickOutside } from '@vueuse/core'
const emit = defineEmits(['close'])
const slots = useSlots()
const modalBody = ref<HTMLElement | null>(null)
const onClose = (value?: unknown): void => {
  emit('close', value)
}
onClickOutside(modalBody, onClose)
</script>

<template>
  <Teleport to="#modal-container">
    <section class="modal-wrapper">
      <div ref="modalBody" class="modal">
        <header v-if="slots.header" class="modal-header">
          <slot name="header" />
        </header>
        <div class="modal-content">
          <slot />
        </div>
      </div>
    </section>
  </Teleport>
</template>

<style scoped lang="scss">
.modal-container {
  width: 100%;
  height: 100vh;
  left: 0;
  position: fixed;
  top: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: gray;
  z-index: 999;
}
</style>

자, 이제 이렇게 만든 모달을 한 번 사용해 보자.

사용

  1. 프로젝트의 최상단 컴포넌트 옆에 <ModalContainer />를 둔다. Vue의 경우 App.vue에서 사용한다.
// App.vue
<script setup lang="ts">
import { RouterView } from 'vue-router'
import '@/assets/css/reset.css'
import ModalContainer from './components/modal/modal-container.vue'
</script>

<template>
  <router-view />
  <modal-container />
</template>
  1. 모달 컴포넌트를 제작한다.
  • 모달은 close 이벤트를 emit하여 닫을 수 있다. 이 때 close 이벤트에 값을 넘기면, 모달이 닫히면서 모달을 불러오는 쪽에서 그 값을 받을 수 있다.
  • 디자인에 따라 <Modal /> 컴포넌트를 따로 제작하였다. 아래는 모달 컴포넌트를 사용하여 window.confirm()을 구현한 코드이다.
// confirm.vue
<script setup lang="ts">
import { toRefs } from 'vue'
import Modal from '../index.vue'
interface Props {
  title?: string
  message: string
}
const emit = defineEmits(['close'])
const props = defineProps<Props>()
const handleSuccess = (): void => {
  emit('close', true)
}
const handleClose = (): void => {
  emit('close', false)
}
</script>

<template>
  <modal @close="handleClose">
    <section>
      <div v-if="props.title">{{ props.title }}</div>
      <div>{{ props.message }}</div>
      <footer>
        <button @click="handleClose">취소</button>
        <button @click="handleSuccess">확인</button>
      </footer>
    </section>
  </modal>
</template>
  1. 모달을 사용할 컴포넌트 혹은 페이지에서 모달을 import한 후 modal 스토어를 통해 모달을 open한다.
<script setup lang="ts">
import { useModalStore } from '...'
import ConfirmModal from '...'
const modalStore = useModalStore()
const { openModal } = modalStore
const openConfirm = async (): Promise<void> => {
  const isConfirmed = await openModal({
    '모달을_구분하는_id',
    ConfirmModal,
    {
      title: '안녕하세요?',
      message: '안녕하다면 확인을, 그렇지 않다면 취소를 눌러주세요.'
    }
  })
  if (isConfirmed) {
// '확인'을 눌렀을 때의 로직
  } else {
// '취소'를 눌렀을 때의 로직
  }
}
</script>

<template>
  <button @click="openConfirm">모달을 열자</button>
</template>

불필요한 isOpened와 같은 플래그를 사용할 필요도 없고, 많은 수의 모달을 불러올 때에도 코드가 필요 이상으로 복잡해지지 않는다. 만약 사용자가 모달에서 어떤 값을 선택했을 때 그 값을 openModal() 함수의 반환값으로 간편히 알 수 있으니 사용성도 증가하였다.

소회 및 개선 방향

기존에 간단히 구현한 모달을 실제로 사용하면서 불편한 점들이 많아 남은 시간 동안 자료를 찾아보고 이것저것 실험해 보며 만들어 보니 실제로 사용시 내가 원했던 만큼 간편히 사용할 수 있게 되어 꽤 만족스러웠다.

하지만 아직은 초기 단계라 사용해 가면서 보완해야 할 점들이 많다. 예를 들어 SSR에서 동작이 가능한지 체크가 필요하다. 지금은 ModalContainer가 마운트될 때 #modal-container 요소가 페이지에 생성되므로 클라이언트 사이드에서 모달이 잘 마운트되지만, SSR에서 모달을 불러와야 하는 경우도 염두에 두어야 할 듯 하다. 또한 resolve된 모달의 반환값의 타입을 추론하는 기능이나 모달을 caching하는 기능 등 사용의 편의성을 위해서 여러 재밌는 시도를 할 수 있을 것 같다. 일단은 다른 프론트 개발자 분들께도 모달을 공유해 같이 사용하면서 계속해서 보완해나갈 예정이다.

참고 자료

post-custom-banner

0개의 댓글