/** * 화면관리 폼 데이터 검증 유틸리티 * 클라이언트 측에서 사전 검증을 수행하여 사용자 경험 향상 */ import { WebType, DynamicWebType, isValidWebType, normalizeWebType } from "@/types/unified-web-types"; import { ColumnInfo, ComponentData, WidgetComponent } from "@/types/screen"; // 검증 결과 타입 export interface ValidationResult { isValid: boolean; errors: ValidationError[]; warnings: ValidationWarning[]; } export interface ValidationError { field: string; code: string; message: string; severity: "error" | "warning"; value?: any; } export interface ValidationWarning { field: string; code: string; message: string; suggestion?: string; } // 필드 검증 결과 export interface FieldValidationResult { isValid: boolean; error?: ValidationError; transformedValue?: any; } // 스키마 검증 결과 export interface SchemaValidationResult { isValid: boolean; missingColumns: string[]; invalidTypes: { field: string; expected: WebType; actual: string }[]; suggestions: string[]; } /** * 폼 데이터 전체 검증 */ export const validateFormData = async ( formData: Record, components: ComponentData[], tableColumns: ColumnInfo[], tableName: string, ): Promise => { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; try { // 1. 스키마 검증 (컬럼 존재 여부, 타입 일치) const schemaValidation = validateFormSchema(formData, components, tableColumns); if (!schemaValidation.isValid) { errors.push( ...schemaValidation.missingColumns.map((col) => ({ field: col, code: "COLUMN_NOT_EXISTS", message: `테이블 '${tableName}'에 '${col}' 컬럼이 존재하지 않습니다.`, severity: "error" as const, })), ); errors.push( ...schemaValidation.invalidTypes.map((type) => ({ field: type.field, code: "INVALID_WEB_TYPE", message: `필드 '${type.field}'의 웹타입이 올바르지 않습니다. 예상: ${type.expected}, 실제: ${type.actual}`, severity: "error" as const, })), ); } // 2. 필수 필드 검증 const requiredValidation = validateRequiredFields(formData, components); errors.push(...requiredValidation); // 3. 데이터 타입 검증 및 변환 const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[]; for (const component of widgetComponents) { const fieldName = component.columnName || component.id; const value = formData[fieldName]; if (value !== undefined && value !== null && value !== "") { const fieldValidation = validateFieldValue( fieldName, value, component.widgetType, component.webTypeConfig, component.validationRules, ); if (!fieldValidation.isValid && fieldValidation.error) { errors.push(fieldValidation.error); } } } // 4. 비즈니스 로직 검증 (커스텀 규칙) const businessValidation = await validateBusinessRules(formData, tableName, components); errors.push(...businessValidation.errors); warnings.push(...businessValidation.warnings); } catch (error) { errors.push({ field: "form", code: "VALIDATION_ERROR", message: `검증 중 오류가 발생했습니다: ${error}`, severity: "error", }); } return { isValid: errors.filter((e) => e.severity === "error").length === 0, errors, warnings, }; }; /** * 스키마 검증 (컬럼 존재 여부, 타입 일치) */ export const validateFormSchema = ( formData: Record, components: ComponentData[], tableColumns: ColumnInfo[], ): SchemaValidationResult => { const missingColumns: string[] = []; const invalidTypes: { field: string; expected: WebType; actual: string }[] = []; const suggestions: string[] = []; const columnMap = new Map(tableColumns.map((col) => [col.columnName, col])); const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[]; for (const component of widgetComponents) { const fieldName = component.columnName; if (!fieldName) continue; // 컬럼 존재 여부 확인 const columnInfo = columnMap.get(fieldName); if (!columnInfo) { missingColumns.push(fieldName); // 유사한 컬럼명 제안 const similar = findSimilarColumns(fieldName, tableColumns); if (similar.length > 0) { suggestions.push(`'${fieldName}' 대신 '${similar.join("', '")}'을 사용하시겠습니까?`); } continue; } // 웹타입 일치 여부 확인 const componentWebType = normalizeWebType(component.widgetType); const columnWebType = columnInfo.webType ? normalizeWebType(columnInfo.webType) : null; if (columnWebType && componentWebType !== columnWebType) { invalidTypes.push({ field: fieldName, expected: columnWebType, actual: componentWebType, }); } // 웹타입 유효성 확인 if (!isValidWebType(component.widgetType)) { invalidTypes.push({ field: fieldName, expected: "text", // 기본값 actual: component.widgetType, }); } } return { isValid: missingColumns.length === 0 && invalidTypes.length === 0, missingColumns, invalidTypes, suggestions, }; }; /** * 필수 필드 검증 */ export const validateRequiredFields = ( formData: Record, components: ComponentData[], ): ValidationError[] => { const errors: ValidationError[] = []; const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[]; for (const component of widgetComponents) { if (!component.required) continue; const fieldName = component.columnName || component.id; const value = formData[fieldName]; if ( value === undefined || value === null || (typeof value === "string" && value.trim() === "") || (Array.isArray(value) && value.length === 0) ) { errors.push({ field: fieldName, code: "REQUIRED_FIELD", message: `'${component.label || fieldName}'은(는) 필수 입력 항목입니다.`, severity: "error", value, }); } } return errors; }; /** * 개별 필드 값 검증 */ export const validateFieldValue = ( fieldName: string, value: any, webType: DynamicWebType, config?: Record, rules?: any[], ): FieldValidationResult => { try { const normalizedWebType = normalizeWebType(webType); // 타입별 검증 switch (normalizedWebType) { case "number": return validateNumberField(fieldName, value, config); case "decimal": return validateDecimalField(fieldName, value, config); case "date": return validateDateField(fieldName, value, config); case "datetime": return validateDateTimeField(fieldName, value, config); case "email": return validateEmailField(fieldName, value, config); case "tel": return validateTelField(fieldName, value, config); case "url": return validateUrlField(fieldName, value, config); case "text": case "textarea": return validateTextField(fieldName, value, config); case "boolean": case "checkbox": return validateBooleanField(fieldName, value, config); default: // 기본 문자열 검증 return validateTextField(fieldName, value, config); } } catch (error) { return { isValid: false, error: { field: fieldName, code: "VALIDATION_ERROR", message: `필드 '${fieldName}' 검증 중 오류: ${error}`, severity: "error", value, }, }; } }; /** * 숫자 필드 검증 */ const validateNumberField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { const numValue = Number(value); if (isNaN(numValue)) { return { isValid: false, error: { field: fieldName, code: "INVALID_NUMBER", message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`, severity: "error", value, }, }; } if (!Number.isInteger(numValue)) { return { isValid: false, error: { field: fieldName, code: "NOT_INTEGER", message: `'${fieldName}'에는 정수만 입력할 수 있습니다.`, severity: "error", value, }, }; } // 범위 검증 if (config?.min !== undefined && numValue < config.min) { return { isValid: false, error: { field: fieldName, code: "VALUE_TOO_SMALL", message: `'${fieldName}'의 값은 ${config.min} 이상이어야 합니다.`, severity: "error", value, }, }; } if (config?.max !== undefined && numValue > config.max) { return { isValid: false, error: { field: fieldName, code: "VALUE_TOO_LARGE", message: `'${fieldName}'의 값은 ${config.max} 이하여야 합니다.`, severity: "error", value, }, }; } return { isValid: true, transformedValue: numValue }; }; /** * 소수 필드 검증 */ const validateDecimalField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { const numValue = Number(value); if (isNaN(numValue)) { return { isValid: false, error: { field: fieldName, code: "INVALID_DECIMAL", message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`, severity: "error", value, }, }; } // 소수점 자릿수 검증 if (config?.decimalPlaces !== undefined) { const decimalPart = value.toString().split(".")[1]; if (decimalPart && decimalPart.length > config.decimalPlaces) { return { isValid: false, error: { field: fieldName, code: "TOO_MANY_DECIMAL_PLACES", message: `'${fieldName}'의 소수점은 ${config.decimalPlaces}자리까지만 입력할 수 있습니다.`, severity: "error", value, }, }; } } return { isValid: true, transformedValue: numValue }; }; /** * 날짜 필드 검증 */ const validateDateField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { const dateValue = new Date(value); if (isNaN(dateValue.getTime())) { return { isValid: false, error: { field: fieldName, code: "INVALID_DATE", message: `'${fieldName}'에는 올바른 날짜를 입력해주세요.`, severity: "error", value, }, }; } // 날짜 범위 검증 if (config?.minDate) { const minDate = new Date(config.minDate); if (dateValue < minDate) { return { isValid: false, error: { field: fieldName, code: "DATE_TOO_EARLY", message: `'${fieldName}'의 날짜는 ${config.minDate} 이후여야 합니다.`, severity: "error", value, }, }; } } if (config?.maxDate) { const maxDate = new Date(config.maxDate); if (dateValue > maxDate) { return { isValid: false, error: { field: fieldName, code: "DATE_TOO_LATE", message: `'${fieldName}'의 날짜는 ${config.maxDate} 이전이어야 합니다.`, severity: "error", value, }, }; } } return { isValid: true, transformedValue: dateValue.toISOString().split("T")[0] }; }; /** * 날짜시간 필드 검증 */ const validateDateTimeField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { const dateValue = new Date(value); if (isNaN(dateValue.getTime())) { return { isValid: false, error: { field: fieldName, code: "INVALID_DATETIME", message: `'${fieldName}'에는 올바른 날짜시간을 입력해주세요.`, severity: "error", value, }, }; } return { isValid: true, transformedValue: dateValue.toISOString() }; }; /** * 이메일 필드 검증 */ const validateEmailField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { return { isValid: false, error: { field: fieldName, code: "INVALID_EMAIL", message: `'${fieldName}'에는 올바른 이메일 주소를 입력해주세요.`, severity: "error", value, }, }; } return { isValid: true, transformedValue: value }; }; /** * 전화번호 필드 검증 */ const validateTelField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { // 기본 전화번호 형식 검증 (한국) const telRegex = /^(\d{2,3}-?\d{3,4}-?\d{4}|\d{10,11})$/; if (!telRegex.test(value.replace(/\s/g, ""))) { return { isValid: false, error: { field: fieldName, code: "INVALID_TEL", message: `'${fieldName}'에는 올바른 전화번호를 입력해주세요.`, severity: "error", value, }, }; } return { isValid: true, transformedValue: value }; }; /** * URL 필드 검증 */ const validateUrlField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { try { new URL(value); return { isValid: true, transformedValue: value }; } catch { return { isValid: false, error: { field: fieldName, code: "INVALID_URL", message: `'${fieldName}'에는 올바른 URL을 입력해주세요.`, severity: "error", value, }, }; } }; /** * 텍스트 필드 검증 */ const validateTextField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { const strValue = String(value); // 길이 검증 if (config?.minLength && strValue.length < config.minLength) { return { isValid: false, error: { field: fieldName, code: "TOO_SHORT", message: `'${fieldName}'은(는) 최소 ${config.minLength}자 이상이어야 합니다.`, severity: "error", value, }, }; } if (config?.maxLength && strValue.length > config.maxLength) { return { isValid: false, error: { field: fieldName, code: "TOO_LONG", message: `'${fieldName}'은(는) 최대 ${config.maxLength}자까지만 입력할 수 있습니다.`, severity: "error", value, }, }; } // 패턴 검증 if (config?.pattern) { const regex = new RegExp(config.pattern); if (!regex.test(strValue)) { return { isValid: false, error: { field: fieldName, code: "PATTERN_MISMATCH", message: `'${fieldName}'의 형식이 올바르지 않습니다.`, severity: "error", value, }, }; } } return { isValid: true, transformedValue: strValue }; }; /** * 불린 필드 검증 */ const validateBooleanField = (fieldName: string, value: any, config?: Record): FieldValidationResult => { let boolValue: boolean; if (typeof value === "boolean") { boolValue = value; } else if (typeof value === "string") { boolValue = value.toLowerCase() === "true" || value === "1"; } else if (typeof value === "number") { boolValue = value === 1; } else { boolValue = Boolean(value); } return { isValid: true, transformedValue: boolValue }; }; /** * 비즈니스 로직 검증 (커스텀) */ const validateBusinessRules = async ( formData: Record, tableName: string, components: ComponentData[], ): Promise<{ errors: ValidationError[]; warnings: ValidationWarning[] }> => { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; // 여기에 테이블별 비즈니스 로직 검증 추가 // 예: 중복 체크, 외래키 제약조건, 커스텀 규칙 등 return { errors, warnings }; }; /** * 유사한 컬럼명 찾기 (오타 제안용) */ const findSimilarColumns = (targetColumn: string, columns: ColumnInfo[], threshold: number = 0.6): string[] => { const similar: string[] = []; for (const column of columns) { const similarity = calculateStringSimilarity(targetColumn, column.columnName); if (similarity >= threshold) { similar.push(column.columnName); } } return similar.slice(0, 3); // 최대 3개까지 }; /** * 문자열 유사도 계산 (Levenshtein distance 기반) */ const calculateStringSimilarity = (str1: string, str2: string): number => { const len1 = str1.length; const len2 = str2.length; const matrix: number[][] = []; for (let i = 0; i <= len1; i++) { matrix[i] = [i]; } for (let j = 0; j <= len2; j++) { matrix[0][j] = j; } for (let i = 1; i <= len1; i++) { for (let j = 1; j <= len2; j++) { const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost); } } const distance = matrix[len1][len2]; const maxLen = Math.max(len1, len2); return maxLen === 0 ? 1 : (maxLen - distance) / maxLen; };