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

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

TIL

목록 보기
14/20

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

왜 모달을 새로 만들었나

코드 복잡도의 증가

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

<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>
    <div v-if="isModalOpened">
     <p>You want to quit?</p>
     <footer>
       <button @click="onSuccess">확인</button>
       <button @click="onClose">취소</button>
     </footer>
    </div>
  </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<T> {
  id: string
  component: Component
  props: unknown
  onClose: (value: T) => void
}
export const useModalStore = defineStore('modal', () => {
  const modalList = ref<Modal[]>([])
//...
})

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

  • id: 모달을 구분해줄 수 있는 id
  • component: 렌더링할 모달 컴포넌트
  • props: 모달 컴포넌트에 필요한 props 객체
  • onClose: 사용자가 모달과 상호작용할 때 호출되는 핸들러
const handleResolver = <T>(id: Modal<T>['id'], resolver: Modal<T>['onClose'], value: T): void => {
    resolver(value)
    deleteModal(id)
}

const openModal = async <T, C extends Component = Component>(
    id: Modal<T>['id'],
    component: C,
    props: C extends Component<infer U> ? Partial<U> : never,
  ): Promise<T> => {
    return await new Promise(resolve => {
      const newModal: Modal<T> = {
        id,
        component: markRaw(component),
        props,
        onClose: value => {
          handleResolver(id, resolve, value)
        },
      }

      modalList.value = [...modalList.value, newModal]
    })
  }

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

const handleResolver = <T>(id: Modal<T>['id'], resolver: Modal<T>['onClose'], value: T): void => {
    resolver(value)
    deleteModal(id)
}

Modal 객체의 onClose를 실행하면 모달이 닫힌다. 이 기능을 구현하기 위해 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<boolean, typeof Confirm>(
    'test-modal-id',
    Confirm,
    {
      message: 'example message'
    }
  )
  console.log('이 코드는 openModal()이 resolve되기 전까지는 실행되지 않는다.')
  console.log(res, 'res는 handleResolver가 resolver를 실행할 때 그 인자로 받은 반환값이다.')
}

index.vue

모달의 Wrapper 컴포넌트이다. 배경을 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">
    <div 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>
    </div>
  </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>

ModalContainer.vue

ModalContainer 컴포넌트의 역할은 크게 두 가지다.

  1. 화면에 표시되는 모든 모달이 담기는 컨테이너 요소를 body 요소에 생성한다.
  2. 모달을 실제 화면에 렌더링한다.

modal-container는 modal 스토어에서 modalList 객체를 불러와 이를 렌더링한다. 그리고 id나 props, onClose와 같은 속성들을 모달에 바인딩해준다. 덕분에 모달에서 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)
const CONTAINER_ID = 'modal-container'

onMounted(() => {
  if (document.getElementById(CONTAINER_ID) != 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 컨테이너 요소를 만들어둔다. 이제 모든 모달 컴포넌트들은 이 컨테이너 안에 담기게 될 것이다.

이를 통해 모든 페이지에서 띄운 모달을 하나의 컴포넌트와 스토어에서 관리할 수 있게 되었다. 자, 이제 이렇게 만든 모달을 한 번 사용해 보자.

사용

  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/ModalContainer.vue'
</script>

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

<template>
  <button @click="openConfirm">모달을 열자</button>
</template>
  1. 화면에 모달이 뜬다.
<body>
  <div id="app" data-v-app>
    <!-- Vue 어플리케이션 -->
  </div>
  <div id="modal-container">
    <!-- 모달 1 -->
    <div class="modal-wrapper">...</div>
    <!-- 모달 2 -->
    <div class="modal-wrapper">...</div>
  </div>
</body>

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

개선

openModal 함수의 props 인자 타입 추론

openModal()은 세 가지 인자를 가진다. 첫 번째는 모달의 ID, 두 번째는 모달 컴포넌트, 세 번째는 모달 컴포넌트의 props이 그것이다. 여기서 props들이 타입 추론이 되도록 만들어주어야 사용성이 높아진다.

지금은 다음과 같이 해당 인자의 타입을 선언해 주고 있다.

import { Component } from 'vue'

const openModal = async <T, C extends Component = Component>(
  id: Modal<T>['id'],
  component: C,
  props: C extends Component<infer U> ? Partial<U> : never,
): Promise<T>

여기서 Vue Component은 type Component = type Component<Props = any, RawBindings = any, D = any, C...>와 같은 타입 선언을 가진다. 따라서 infer를 사용해 Props 타입을 추론할 수 있다.
이렇게 추론된 Props 타입이 실제 우리가 원하는 prop의 타입과 약간 달라 에러를 내기 때문에 Partial을 사용하여 prop 속성들을 옵셔널로 전환하였다. 이렇게 되면 어느 정도 props 인자의 타입 추론이 가능해진다.

하지만 Partial이 적용되다 보니, 필수 prop의 경우에도 옵셔널로 인식된다. 따라서 openModal에서 필수 prop을 누락한다 해도 이를 타입스크립트가 잡아내지 못한다는 문제가 있었다. 아래의 이미지에서도 사실 placeholder prop은 필수 prop인데 선택적 속성으로 추론되는 것을 볼 수 있다.

따라서 이 문제점을 개선하기 위해 새로운 ComponentProps 타입을 만들었다.

import type { Component, VNodeProps, AllowedComponentProps } from "vue";

export type ComponentProps<C> = C extends new (...args: any) => any
  ? Omit<InstanceType<C>['$props'], keyof VNodeProps | keyof AllowedComponentProps>
  : never;

const openModal = async <T, C extends Component = Component>(
  id: Modal<T>['id'],
  component: C,
  props: ComponentProps<C>,
): Promise<T>

복잡해 보이지만 뜯어보면 생각보다는 간단하다.

일단 요지는 Typescript의 InstanceType을 사용해서 컴포넌트 자체의 타입을 가져오는 데에 있다(실제로 Vue에서 권장하는 방식이다). InstanceType은 생성자 함수 형태의 타입을 제네릭으로 받기 때문에 다음과 같이 new (...args: any) => any로 extend하여야 한다.

그리고 $props 속성을 꺼내 와서 사용하면 된다.

export type ComponentProps<C> = C extends new (...args: any) => any
  ? InstanceType<C>['$props']
  : never;

다만, 이 속성 안에는 불필요한 타입들도 같이 들어있으므로 이들을 Omit해주는 것이다.

  • VNodeProps : key, ref, onVnodeBeforeMount
  • AllowedComponentProps: classstyle
export type ComponentProps<C> = C extends new (...args: any) => any
  ? Omit<InstanceType<C>['$props'], keyof VNodeProps | keyof AllowedComponentProps>
  : never;

이제 타입 추론이 잘 되는 것을 확인할 수 있다.

참고 자료

0개의 댓글

관련 채용 정보