728x90

TypeScript와 Vue 3를 활용해 강력하고 재사용 가능한 유효성 검증 로직을 구현하는 방법을 소개합니다. 이 글에서는 한글 조사를 처리하는 getParticle 함수와 다양한 검증 규칙(required, min, max, regex, date, dependency)을 포함한 Validator 클래스를 설계하고, Vue 컴포넌트에서 이를 사용하는 방법을 다룹니다. 특히, 반복적인 조사 처리를 리팩토링하여 코드를 간결하게 만든 과정을 강조합니다.
1. 프로젝트 배경
웹 애플리케이션에서 사용자 입력 폼은 필수적이며, 입력값의 유효성 검증은 사용자 경험과 데이터 무결성을 보장하는 핵심 요소입니다. 이 프로젝트에서는 TypeScript로 작성된 Validator 클래스를 통해 다양한 검증 규칙을 처리하고, 한글 필드명에 적합한 조사(은/는, 를/을, 과/와, 보다)를 동적으로 적용합니다. Vue 3의 Composition API와 통합하여 UI에 에러 메시지를 표시하는 예제를 제공합니다.
2. 주요 기능
Validator 클래스는 다음 검증 규칙을 지원합니다:
-
required: 필수 입력 여부 (문자열: ''/null, 숫자: 0 체크).
-
min: 문자열 최소 길이.
-
max: 문자열 최대 길이.
-
regex: 정규식 패턴 매칭 (예: 이메일 형식).
-
date: YYYYMMDD 형식 날짜 유효성 및 기간/선후 검사.
-
dependency: 두 필드(예: 수량과 수량단위)가 함께 입력되거나 함께 비어야 함.
추가적으로:
-
한글 조사 처리: getParticle 함수로 필드명에 따라 적절한 조사를 선택(예: "노즐번호은" vs "설명은").
-
기간 검증: date 검증에서 valPeriod와 patternPeriod(년/월/일)를 사용해 현재 날짜 기준 범위 검사.
-
리팩토링: 반복적인 조사 호출을 buildErrorMessage 유틸리티로 통합해 코드 간결화.
3. 코드 구조
3.1. Validator.ts
Validator 클래스는 ValidationField 인터페이스를 기반으로 동작합니다. 주요 코드는 다음과 같습니다:
export interface ValidationField {
ref1: () => any;
name1: string;
ref2?: () => any;
name2?: string;
vKind: 'required' | 'min' | 'max' | 'regex' | 'date' | 'dependency';
limit?: number;
pattern?: RegExp;
valPeriod?: number;
patternPeriod?: 'year' | 'month' | 'day';
}
export class Validator {
private validationFields: ValidationField[];
private errors: string[];
constructor(fields: ValidationField[]) {
this.validationFields = fields;
this.errors = [];
}
private getParticle(word: string, particle1: string, particle2: string): string {
if (!word) return '';
const lastChar = word[word.length - 1];
const code = lastChar.charCodeAt(0);
if (code < 0xAC00 || code > 0xD7A3) return particle2;
const jong = (code - 0xAC00) % 28;
return jong === 0 ? particle2 : particle1;
}
private buildErrorMessage(name1: string, template: string, name2?: string, extra?: string | number): string {
const subjectParticle = this.getParticle(name1, '은', '는');
const objectParticle = this.getParticle(name1, '를', '을');
const andParticle = name2 ? this.getParticle(name1, '과', '와') : '';
const thanParticle = name2 ? this.getParticle(name2, '보다', '보다') : '';
return template
.replace(/{name1}/g, name1) // 모든 {name1}을 name1으로 대체
.replace(/{subject}/g, subjectParticle) // 모든 {subject} 대체
.replace(/{object}/g, objectParticle) // 모든 {object} 대체
.replace(/{name2}/g, name2 || '') // 모든 {name2} 대체
.replace(/{and}/g, andParticle) // 모든 {and} 대체
.replace(/{than}/g, thanParticle) // 모든 {than} 대체
.replace(/{extra}/g, extra !== undefined ? String(extra) : ''); // 모든 {extra} 대체
}
// 날짜 유효성 검사 (YYYYMMDD 형식)
private isValidDate(value: string): boolean {
if (!/^\d{8}$/.test(value)) return false; // 8자리 숫자인지 확인
const year = parseInt(value.substring(0, 4), 10);
const month = parseInt(value.substring(4, 6), 10);
const day = parseInt(value.substring(6, 8), 10);
// 연도, 월, 일 범위 체크
if (year < 1900 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31) {
return false;
}
// 월별 최대 일수 체크
const maxDays = [31, (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return day <= maxDays[month - 1];
}
// 날짜 선후 검사
private isSequentialDate(date1: string, date2: string): boolean {
return date1 <= date2; // date1이 date2보다 같거나 이전이어야 함
}
// 날짜 범위 검사 (현재 날짜 기준)
private isWithinPeriod(value: string, valPeriod: number, patternPeriod: 'year' | 'month' | 'day'): boolean {
if (!this.isValidDate(value)) return false;
const inputDate = new Date(
parseInt(value.substring(0, 4), 10),
parseInt(value.substring(4, 6), 10) - 1,
parseInt(value.substring(6, 8), 10)
);
const currentDate = new Date(2025, 5, 5); // 2025-06-05 (현재 날짜 가정)
let minDate: Date, maxDate: Date;
if (patternPeriod === 'year') {
minDate = new Date(currentDate.getFullYear() - valPeriod, currentDate.getMonth(), currentDate.getDate());
maxDate = new Date(currentDate.getFullYear() + valPeriod, currentDate.getMonth(), currentDate.getDate());
} else if (patternPeriod === 'month') {
minDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - valPeriod, currentDate.getDate());
maxDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + valPeriod, currentDate.getDate());
} else { // day
minDate = new Date(currentDate.getTime() - valPeriod * 24 * 60 * 60 * 1000);
maxDate = new Date(currentDate.getTime() + valPeriod * 24 * 60 * 60 * 1000);
}
return inputDate >= minDate && inputDate <= maxDate;
}
// 유효성 검증 함수
validate(): void {
this.errors = []; // 에러 리스트 초기화
this.validationFields.forEach(({ ref1, name1, ref2, name2, vKind, limit, pattern, valPeriod, patternPeriod }) => {
const value1 = ref1(); // 첫 번째 필드 값
const valueType1 = typeof value1;
if (vKind === 'required') {
if (valueType1 === 'object') { // 값이 없는 경우는 타입을 제대로 못잡고 object 로 인식한다.
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} 필수 입력 정보입니다. ( {name1}{object} 입력하십시오 )')
);
} else if (valueType1 === 'string' && (value1.trim() === '' || value1 === null)) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} 필수 입력 정보입니다. ({name1}{object} 입력하십시오)')
);
} else if (valueType1 === 'number' && value1 === 0) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} 필수 입력 정보입니다. ({name1}{object} 입력하십시오)')
);
}
} else if (vKind === 'min' && typeof value1 === 'string' && limit !== undefined) {
if (value1.length < limit) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} {extra}자 이상이어야 합니다.', undefined, limit)
);
}
} else if (vKind === 'max' && typeof value1 === 'string' && limit !== undefined) {
if (value1.length > limit) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} {extra}자 이하여야 합니다.', undefined, limit)
);
}
} else if (vKind === 'regex' && typeof value1 === 'string' && pattern !== undefined) {
if (!pattern.test(value1)) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} 형식이 올바르지 않습니다.')
);
}
} else if (vKind === 'date' && typeof value1 === 'string') {
// 날짜 형식 및 유효성 검사
if (!this.isValidDate(value1)) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} 유효한 날짜(YYYYMMDD)가 아닙니다.')
);
} else {
// 기간 범위 검사
if (valPeriod !== undefined && valPeriod > 0 && patternPeriod) {
if (!this.isWithinPeriod(value1, valPeriod, patternPeriod)) {
const periodUnit = patternPeriod === 'year' ? '년' : patternPeriod === 'month' ? '개월' : '일';
this.errors.push(
this.buildErrorMessage(name1, `{name1}{subject} 현재 날짜 기준 {extra}${periodUnit} 이내여야 합니다.`, undefined, valPeriod)
);
}
}
// 선후 검사 (ref2, name2가 있는 경우)
if (ref2 && name2 && typeof ref2() === 'string') {
const value2 = ref2();
if (!this.isValidDate(value2)) {
this.errors.push(
this.buildErrorMessage(name2, '{name1}{subject} 유효한 날짜(YYYYMMDD)가 아닙니다.')
);
} else if (this.isValidDate(value1) && !this.isSequentialDate(value1, value2)) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} {name2}{than} previous이어야 합니다.', name2)
);
}
}
}
} else if (vKind === 'dependency') {
if (!ref2 || !name2) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{subject} 종속 필드({name2})가 필요합니다.', name2 || '다른 필드')
);
} else {
const value2 = ref2();
const isValue1Empty = valueType1 === 'string' && (value1 === '' || value1 === null) || valueType1 === 'number' && value1 === 0;
const isValue2Empty = typeof value2 === 'string' && (value2 === '' || value2 === null) || typeof value2 === 'number' && value2 === 0;
if (isValue1Empty !== isValue2Empty) {
this.errors.push(
this.buildErrorMessage(name1, '{name1}{and} {name2}{subject} 함께 입력해야 합니다.', name2)
);
}
}
}
});
}
// 에러 메시지 반환 함수
getErrors(): string[] {
return this.errors;
}
// 에러 메시지 출력 함수 (옵션)
printErrors(): void {
if (this.errors.length > 0) {
alert(this.errors.join('\n'));
}
}
}
주요 설명:
-
추가 필드: quantity와 unit 추가 (초기값: quantity: 100, unit: '').
-
검증 설정:
-
nozzleNo, saleQty: required.
-
description: min (5자), max (10자).
-
email: regex (이메일 형식).
-
startDate: date (날짜 형식, 1년 이내, 선후 검사).
-
quantity, unit: dependency (둘 다 있거나 없어야 함).
-
-
UI: quantity와 unit을 위한 <input> 추가.
-
에러 메시지: dependency 실패 시 함축적 메시지: "수량과 수량단위는 함께 입력해야 합니다."
-
buildErrorMessage 유틸리티:
-
name1, template, name2(옵셔널), extra(옵셔널)를 받아 메시지 생성.
-
템플릿 플레이스홀더({name1}, {subject}, {object}, {name2}, {and}, {than}, {extra})로 조사와 추가 값 동적 치환.
-
getParticle 호출을 한 곳에서 관리, 중복 제거.
-
-
템플릿 기반 메시지:
-
각 vKind에서 반복되던 getParticle 호출을 buildErrorMessage로 통합.
-
예: {name1}{subject} 필수 입력 정보입니다. ({name1}{object} 입력하십시오) → 노즐번호은 필수 입력 정보입니다. (노즐번호를 입력하십시오).
-
-
가독성 향상:
-
조사(은/는, 를/을, 과/와, 보다)를 템플릿으로 처리해 코드 간결화.
-
에러 메시지 생성 로직이 한 함수에서 관리되어 유지보수 용이.
-
-
기존 기능 유지:
-
required, min, max, regex, date, dependency 검증 로직 동일.
-
date의 valPeriod, patternPeriod, 선후 검사 유지.
-
dependency의 "수량과 수량단위" 예제 유지.
-
728x90
3.2. Vue 컴포넌트
Vue 3의 Composition API를 사용해 Validator를 통합합니다. 예제 컴포넌트는 다양한 필드를 검증합니다:
<!-- src/components/MyForm.vue -->
<template>
<div>
<h2>검증 예제</h2>
<div><label>노즐번호</label><input v-model="detailItem.value.nozzleNo" type="text" /></div>
<div><label>점검물량</label><input v-model.number="detailItem.value.saleQty" type="number" /></div>
<div><label>설명</label><input v-model="detailItem.value.description" type="text" /></div>
<div><label>이메일</label><input v-model="detailItem.value.email" type="text" /></div>
<div><label>시작일 (YYYYMMDD)</label><input v-model="detailItem.value.startDate" type="text" /></div>
<div><label>종료일 (YYYYMMDD)</label><input v-model="detailItem.value.endDate" type="text" /></div>
<div><label>수량</label><input v-model.number="detailItem.value.quantity" type="number" /></div>
<div><label>수량단위</label><input v-model="detailItem.value.unit" type="text" /></div>
<button @click="validateForm">검증</button>
<div v-if="errors.length > 0" class="error">
<p v-for="error in errors" :key="error">{{ error }}</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, ref } from 'vue';
import { Validator, ValidationField } from '@/utils/Validator';
export default defineComponent({
name: 'MyForm',
setup() {
const detailItem = reactive({
value: {
nozzleNo: '',
saleQty: 0,
description: '짧',
email: 'invalid-email',
startDate: '20240601',
endDate: '20250601',
quantity: 100,
unit: '',
},
});
const errors = ref<string[]>([]);
const validator = new Validator([
{ ref1: () => detailItem.value.nozzleNo, name1: '노즐번호', vKind: 'required' },
{ ref1: () => detailItem.value.saleQty, name1: '점검물량', vKind: 'required' },
{ ref1: () => detailItem.value.description, name1: '설명', vKind: 'min', limit: 5 },
{ ref1: () => detailItem.value.description, name1: '설명', vKind: 'max', limit: 10 },
{
ref1: () => detailItem.value.email,
name1: '이메일',
vKind: 'regex',
pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
},
{
ref1: () => detailItem.value.startDate,
name1: '시작일',
ref2: () => detailItem.value.endDate,
name2: '종료일',
vKind: 'date',
valPeriod: 1,
patternPeriod: 'year',
},
{
ref1: () => detailItem.value.quantity,
name1: '수량',
ref2: () => detailItem.value.unit,
name2: '수량단위',
vKind: 'dependency',
},
]);
const validateForm = () => {
validator.validate();
errors.value = validator.getErrors();
if (errors.value.length > 0) {
console.log(errors.value);
}
};
return { detailItem, errors, validateForm };
},
});
</script>
<style scoped>
.error { color: red; margin-top: 10px; }
</style>
4. 리팩토링 과정
초기 코드에서는 getParticle 호출이 각 검증 로직에서 반복되어 코드가 장황했습니다. 이를 해결하기 위해:
-
유틸리티 함수 도입: buildErrorMessage 함수로 조사(은/는, 를/을, 과/와, 보다)를 한 곳에서 관리.
-
템플릿 방식: {name1}, {subject}, {object}, {name2}, {and}, {than}, {extra} 플레이스홀더를 사용해 메시지 생성 통일.
-
결과: 코드 중복 감소, 가독성 및 유지보수성 향상.
5. 사용 예제
입력 데이터:
{
nozzleNo: '', // 빈 문자열
saleQty: 0, // 0
description: '짧', // 2자
email: 'invalid-email',
startDate: '20240601', // 2025-06-05 기준 1년 이내 아님
endDate: '20250601', // 선후 검사 실패
quantity: 100, // 수량 입력됨
unit: '', // 단위 비어있음
}
출력:
노즐번호은 필수 입력 정보입니다. (노즐번호를 입력하십시오)
점검물량은 필수 입력 정보입니다. (점검물량을 입력하십시오)
설명은 5자 이상이어야 합니다.
이메일은 형식이 올바르지 않습니다.
시작일은 현재 날짜 기준 1년 이내여야 합니다.
시작일은 종료일보다 이전이어야 합니다.
수량과 수량단위는 함께 입력해야 합니다.
6. 결론
이 Validator 클래스는 TypeScript의 타입 안정성과 Vue 3의 반응형 시스템을 활용해 유연하고 재사용 가능한 유효성 검증 솔루션을 제공합니다. buildErrorMessage로 리팩토링하여 코드 중복을 줄이고, 한글 조사 처리를 효율적으로 관리했습니다. 추가 검증 규칙(예: range, enum)이나 커스텀 메시지를 쉽게 확장할 수 있습니다.
728x90
'Vue.js 를 배워보자' 카테고리의 다른 글
Vue 3와 TypeScript로 동적 키-값 쌍을 배열에 저장하기: <script setup> 방식 (0) | 2025.06.20 |
---|---|
TypeScript에서 두 객체의 공통 요소와 내포된 객체 복사하기 (1) | 2025.06.13 |
Vue 3와 TypeScript로 구현한 실무적인 발주서 작성 화면 (0) | 2025.05.31 |
Vue 3에서 팝업에서 데이터 가져와 자식 1-1에 표시하기 (0) | 2025.05.20 |
Vue 3에서 자식 컴포넌트 간 팝업 호출 구현하기 (2) | 2025.05.19 |