[vue.js] components을 이용한 동적 렌더링 - 1
최근 프로젝트에서 다음과 같은 두 가지 요구사항이 있었습니다:
- 항목 조건에 따라 다른 형태의 폼이 생성
- '신청 항목 추가+' 버튼 클릭 시 새로운 폼 항목 추가
이 요구사항을 단순히 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.field는 formData의 키(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 속성값이 변경되면 화면에 즉시 반영됩니다.
formData와 componentInfo.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를 만족 시키는 코드를
설명하는 포스팅을 올리려고 합니다.
사실 이부분이 제일 어렵기 때문에
두번째 포스팅은 쉽게 쉽게 풀어나갈 수 있을듯 합니다.
다만 글이 좀 길어지는거 같아서 나누려고 합니다.
그러 이상 글을 마칩니다.