Vue 실습

[vue.js] components을 이용한 동적 렌더링 - 1

코딩질문자 2024. 12. 7. 16:33
728x90

최근 프로젝트에서 다음과 같은 두 가지 요구사항이 있었습니다:

  1. 항목 조건에 따라 다른 형태의 폼이 생성
  2. '신청 항목 추가+' 버튼 클릭 시 새로운 폼 항목 추가

 

이 요구사항을 단순히 v-if나 v-show로 구현하면 코드가 복잡해질 가능성이 높습니다. 이를 해결하기 위해 Vue.js의 태그를 활용하여 동적 렌더링을 구현했습니다. 동작 원리는 다음과 같습니다

<component :is="activeComponent" />

<component> 태그란?

  • <component> 태그: Vue.js의 내장 컴포넌트로, 조건에 따라 동적으로 컴포넌트를 렌더링할 수 있습니다.
  • :is 속성: 렌더링할 컴포넌트를 지정합니다. 이 속성에 전달된 값에 따라 해당 컴포넌트를 화면에 표시합니다.
  • 활용 방식: activeComponent라는 변수의 값이 컴포넌트 이름이라면 <component>는 그 컴포넌트를 렌더링합니다. 예를 들어 activeComponent: 'MyComponent'라면 <MyComponent />를 렌더링하게 됩니다.

구현 코드

1. 재사용 가능한 입력 컴포넌트

먼저, 폼 항목을 구성하는 컴포넌트를 작성합니다. 아래는 BackupInput 컴포넌트의 예시입니다

 

 

<BackupInput.vue>

<template>
  <a-form-item>
    <template #label>
      <span>{{ props.label }}</span>
      <a-tooltip v-if="props.tooltip && props.tooltip.length > 0">
        <template #title>{{ props.tooltip }}</template>
        <span
          class="material-symbols-outlined material-symbols-18 material-symbols-default"
        >
          help
        </span>
      </a-tooltip>
    </template>

    <a-input
      v-model:value="inputValue"
      :type="inputType"
      :placeholder="placeholder"
    />
  </a-form-item>
</template>

<script setup lang="ts">
import { defineProps, defineEmits, ref, watch, watchEffect } from 'vue';

const props = defineProps<{
  inputType?: string;
  placeholder?: string;
  label: string;
  tooltip?: string;
}>();

const emit = defineEmits<{
  (e: 'update:inputValue', value: string): void;
}>();

const inputType = props.inputType || 'text';
const placeholder = props.placeholder || '입력하세요...';
const inputValue = ref('');

watch(inputValue, (newValue) => {
  emit('update:inputValue', newValue);
});
</script>

 

이와 비슷하게 BackupCheckbox, BackupSelect, BackupTimePicker 등 필요한 폼 항목 컴포넌트를 구현합니다.

 

만들어놓은 코드를 import 해서 쓴다.

import BackupCheckbox from './BackupCheckbox.vue';
import BackupInput from './BackupInput.vue';
import BackupRadioFormItem from './BackupRadioFormItem.vue';
import BackupSelect from './BackupSelect.vue';
import BackupTimePicker from './BackupTimePicker.vue';

 

이제 components 코드를 어떻게 정의했는지 알아보자.


2. 동적 렌더링을 위한 템플릿 코드

   <component
          :is="components[componentMap[componentInfo.type]]"
          v-if="componentInfo.type === 'input'"
          v-model:input-value="formItem[componentInfo.field]"
          v-model:label="componentInfo.label"
          :tooltip="componentInfo.tooltip"
          :placeholder="componentInfo.placeholder"
          :options="
            componentInfo.type === 'select' || 'checkbox'
              ? componentInfo.options
              : []
          "
          :error-message="
            formStore.errorMessages[formIndex]?.[componentInfo.field]
          "
        />
        <component
          :is="components[componentMap[componentInfo.type]]"
          v-else
          v-model="formItem[componentInfo.field]"
          v-model:label="componentInfo.label"
          :tooltip="componentInfo.tooltip"
          :placeholder="componentInfo.placeholder"
          :options="
            componentInfo.type === 'select' || 'checkbox'
              ? componentInfo.options
              : []
          "
          :error-message="
            formStore.errorMessages[formIndex]?.[componentInfo.field]
          "
        />

 

components를 두개 나눈 이유는 인풋은 v-model:value 로 해야 데이터를 반응성 있게 가져와 졌고, 나머지는 v-model로 설정해야 했기 때문에 두개로 나눈것뿐 동작 방식은 그걸 제외하고는 동일 하니 안심하고 봐줬음 한다.

 

:is="components[componentMap[componentInfo.type]]"

 

위의 :is 코드부터 찬찬히 살펴보자 componentInfo는 renderOrderComputed 에서 순회되는 인덱스값이고  되어 있고, 다음 renderOrderComputed는 아래와 같이 선언 되어 있다.

const renderOrderComputed = computed(() => {
  const osIndex = renderOrder.findIndex((item) => item.field === 'os');

  if (props.selectProduct === 'b' && osIndex !== -1) {
    return [
      ...renderOrder.slice(0, osIndex + 1),
      ...oracleFields,
      ...renderOrder.slice(osIndex + 1),
    ];
  } else if (props.selectProduct === 'c' && osIndex !== -1) {
    return [
      ...renderOrder.slice(0, osIndex + 1),
      ...mssqlFields,
      ...renderOrder.slice(osIndex + 1),
    ];
  } else if (props.selectProduct === 'e' && osIndex !== -1) {
    return [
      ...renderOrder.slice(0, osIndex + 1),
      ...shellFields,
      ...renderOrder.slice(osIndex + 1),
    ];
  }
  return renderOrder;
});

 

으악!! 이게 뭐야!! 왜 인덱스를 찾아서 조건에 따라 return 하는거야!! 
요구조건 1에서 항목에 따라 폼의 구조를 다르게 보여주기 위해 분기를 넣어서 조건절을 추가한건데 우리는 일단 이해를 빠르게 하기 위해 기본 조건문 retrun renderOreder; 만 생각하자.


3. renderOrder와 동적 컴포넌트 매핑

const renderOrder = [
  {
    type: 'input',
    field: 'name',
    label: 'Host 명',
    tooltip:
      '포털에서 조회되는 Host 명과 실제 OS의 호스트 명이 다를 경우, 백업이 불가능합니다',
    placeholder: '입력해주세요.',
  },
  {
    type: 'input',
    field: 'ip',
    label: '백업 서버 Connect Hub IP',
    tooltip: 'IP 포맷 형태로만 입력 가능합니다.',
    placeholder: '입력해주세요.',
  },
  {
    type: 'input',
    field: 'os',
    label: 'O/S',
    tooltip: 'CentOS 7.9, WIN2016 STD 등',
    placeholder: '입력해주세요.',
  },
  {
    type: 'input',
    field: 'directory',
    label: '백업 대상 Directory',
    tooltip: '',
    placeholder: '입력해주세요.',
  },
  {
    type: 'timepicker',
    field: 'time',
    label: '희망 백업 시간',
    tooltip: '오전, 오후 구분 없이 13시, 22시 형태의 포맷으로 제공',
    placeholder: '입력해주세요.',
    rules: [
      {
        require: true,
      },
    ],
  },
  {
    type: 'select',
    field: 'period',
    label: '보관 주기',
    tooltip:
      '2주가 기본값(Default)이며, 2주, 3주, 4주, 1개월, 2개월, 3개월, 5개월, 6개월, 12개월 중 택1',
    options: [
      { label: '2주', value: '14' },
      { label: '3주', value: '21' },
      { label: '4주', value: '28' },
      { label: '1개월', value: '30' },
      { label: '2개월', value: '60' },
      { label: '3개월', value: '90' },
      { label: '5개월', value: '150' },
      { label: '6개월', value: '180' },
    ],
  },
  {
    type: 'checkbox',
    field: 'backupList',
    label: 'FULL 백업 일정',
    tooltip:
      'FULL 백업 일정에 체크되지 않은 일정은 자동으로 증분 백업이 수행됩니다.',
    options: [
      { label: '일', value: 'sun' },
      { label: '월', value: 'mon' },
      { label: '화', value: 'tue' },
      { label: '수', value: 'wed' },
      { label: '목', value: 'thu' },
      { label: '금', value: 'fri' },
      { label: '토', value: 'sat' },
    ],
  },
  {
    type: 'select',
    field: 'transactionBackup',
    label: '소산 백업 신청 여부',
    tooltip: '소산 백업 서비스가 필요한 경우 선택',
    placeholder: 'Select dissipation',
    options: [
      { value: 'a', label: 'Option A' },
      { value: 'b', label: 'Option B' },
      { value: 'c', label: 'Option C' },
    ],
  },
];

 

type에 따라 랜더링할 대상의 컴포넌트를 가져온다 -> componentInfo.type : 가져올 컴포넌트를 결정

 

각각의 input, timepikcer, checkbox, select, radio 컴포넌트를 가져오고 각각의이름은 componentMap으로 맵핑한다.

const componentMap = {
  input: 'BackupInput',
  timepicker: 'BackupTimePicker',
  checkbox: 'BackupCheckbox',
  select: 'BackupSelect',
  radio: 'BackupRadioFormItem',
};

 

componentMap[componentInfo.type]
// 각 컴포넌트를 Vue에 등록
const components = {
  BackupInput,
  BackupTimePicker,
  BackupCheckbox,
  BackupSelect,
  BackupRadioFormItem,
};

 

맵핑된 값을 아래의 과정을 거쳐서 등록된다.

1. type = 'input' 라면

2. components.type == input: 조건에 맞게 되고,

3. componentMap[input] -> 'BackupInput' 

3. component['BackupInput'] -> BackupInput 컴포넌트가 랜더링 되게 되는 것

 

v-model:label="componentInfo.label"
:tooltip="componentInfo.tooltip"
:placeholder="componentInfo.placeholder"

 

는 각각 renderOrder 에 매칭 되는 label, tooltip, placeholder을 넣어준다. 

 

체크박스와 셀렉트문의 경우 옵션이 있기 때문에 예외 처리를 해준 것.

 :options="
    componentInfo.type === 'select' || 'checkbox'
      ? componentInfo.options
      : []
  "

 


 

 v-model:input-value="formItem[componentInfo.field]"

4. formData와 양방향 바인딩

formData동적 폼 항목의 데이터 상태를 관리하는 객체로, 각 폼 항목의 데이터를 저장합니다. v-model을 활용해 컴포넌트와 데이터를 양방향 바인딩하며, 이를 통해 컴포넌트에서 입력한 값이 자동으로 formData에 저장되고, 반대로 formData 값이 컴포넌트에 반영됩니다.


componentInfo.field와 formData의 연결

componentInfo.fieldformData의 키(key) 역할을 합니다. 이는 각 폼 항목이 어떤 데이터를 참조하고 수정해야 하는지 정의하며, 동적으로 렌더링된 컴포넌트와 formData를 연결합니다.

예를 들어, componentInfo.field'name'이라면, 해당 컴포넌트는 formData.name과 바인딩됩니다.

 

동작 방식

1. renderOrder 정의
renderOrder에서 각 항목의 field를 지정합니다. 이 field는 formData의 속성 이름으로 사용됩니다.

const renderOrder = [
  { type: 'input', field: 'name', label: 'Host 명', tooltip: '...' },
  { type: 'input', field: 'ip', label: '백업 서버 Connect Hub IP', tooltip: '...' },
];

 

2. formData 초기화
formData 객체는 동적으로 생성된 폼 항목의 데이터를 저장합니다.

const formData = ref({
  name: '',
  ip: '',
  time: '',
  period: '',
});

 

3. v-model로 양방향 바인딩
v-model은 컴포넌트의 데이터 입력과 formData를 동기화합니다.

<component
  :is="components[componentMap[componentInfo.type]]"
  v-model="formData[componentInfo.field]"
  :label="componentInfo.label"
  :tooltip="componentInfo.tooltip"
/>

 

4. 자동 데이터 갱신

  • 사용자가 입력한 값이 즉시 formData의 대응하는 속성(formData.name, formData.ip)에 저장됩니다.
  • 반대로, formData 속성값이 변경되면 화면에 즉시 반영됩니다.

formDatacomponentInfo.field의 관계

  • formData: 폼 데이터 상태를 관리하는 객체로, 컴포넌트에서 입력한 값을 저장하거나 초기 데이터를 제공.
  • componentInfo.field: formData의 속성 이름으로, 각 폼 항목이 어떤 데이터를 참조해야 할지 결정.

 


 

이렇게 완성했으면 component가 동적 랜더링 되고, 요구조건1을 만족시키기 위해서 항목 시키기 위해서 조건에 따른 변경이 될 값을 객체로 정의해서 넣어주면 된다 예를 들어

props.selectProduct === 'b'

 

일 경우, 그에 맞는 객체 필드를 미리 정의 하고,

const oracleFields = [
  {
    type: 'input',
    field: 'sid',
    label: 'SID',
    placeholder: 'SID 입력',
  },
  {
    type: 'input',
    field: 'oracleHome',
    label: 'Oracle Home',
    placeholder: 'Oracle Home 입력',
  },
  {
    type: 'radio',
    field: 'archiveLogBackup',
    label: 'Archive log 백업 여부',
    options: [
      { value: 'a', label: '사용' },
      { value: 'b', label: '미사용' },
    ],
  },
  {
    type: 'radio',
    field: 'archiveLogDelete',
    label: 'Archive log 삭제 여부',
    options: [
      { value: 'a', label: '사용' },
      { value: 'b', label: '미사용' },
    ],
  },
];

 

 

const renderOrderComputed = computed(() => {
  const osIndex = renderOrder.findIndex((item) => item.field === 'os');

  if (props.selectProduct === 'b' && osIndex !== -1) {
    return [
      ...renderOrder.slice(0, osIndex + 1),
      ...oracleFields,
      ...renderOrder.slice(osIndex + 1),
    ];
  } else if (props.selectProduct === 'c' && osIndex !== -1) {
    return [
      ...renderOrder.slice(0, osIndex + 1),
      ...mssqlFields,
      ...renderOrder.slice(osIndex + 1),
    ];
  } else if (props.selectProduct === 'e' && osIndex !== -1) {
    return [
      ...renderOrder.slice(0, osIndex + 1),
      ...shellFields,
      ...renderOrder.slice(osIndex + 1),
    ];
  }
  return renderOrder;
});

 

에서 요구 사항에 있는 순서에 맞게 findIndex 써서 기존 배열 사이에 넣어주고, 동적랜더링 되게 하기 위해 계산 매서드 computed를 쓴 모습.

  if (props.selectProduct === 'b' && osIndex !== -1) {
    return [
      ...renderOrder.slice(0, osIndex + 1),
      ...oracleFields,
      ...renderOrder.slice(osIndex + 1),
    ];
  }

 

selectProduct 가 b 인 경우만 떼서 보면 위와 같다.

이렇게 return 해줌으로써 항목값을 선택할 때마다 동적렌더링이 되게 구현을 했다.

 

 


요약

  • <component> 태그와 :is 속성을 사용하여 컴포넌트를 동적으로 렌더링합니다.
  • renderOrder와 computed를 활용해 항목 조건에 따라 렌더링 순서를 동적으로 변경합니다.
  • v-model을 통해 폼 데이터와 컴포넌트 상태를 양방향으로 바인딩합니다.

 

 


후기

다음 포스팅에는 요구조건2를 만족 시키는 코드를

설명하는 포스팅을 올리려고 합니다.

사실 이부분이 제일 어렵기 때문에

두번째 포스팅은 쉽게 쉽게 풀어나갈 수 있을듯 합니다.

다만 글이 좀 길어지는거 같아서 나누려고 합니다.

그러 이상 글을 마칩니다.

728x90