/** * 개선된 동적 폼 서비스 * 타입 안전성과 검증 강화 */ import { query, queryOne } from "../database/db"; import { WebType, DynamicWebType, normalizeWebType, isValidWebType, WEB_TYPE_TO_POSTGRES_CONVERTER, WEB_TYPE_VALIDATION_PATTERNS, } from "../types/unified-web-types"; import { DataflowControlService } from "./dataflowControlService"; // 테이블 컬럼 정보 export interface TableColumn { column_name: string; data_type: string; is_nullable: string; column_default: any; character_maximum_length?: number; numeric_precision?: number; numeric_scale?: number; } // 컬럼 웹타입 정보 export interface ColumnWebTypeInfo { columnName: string; webType: WebType; isRequired: boolean; validationRules?: Record; defaultValue?: any; } // 폼 데이터 검증 결과 export interface FormValidationResult { isValid: boolean; errors: FormValidationError[]; warnings: FormValidationWarning[]; transformedData: Record; } export interface FormValidationError { field: string; code: string; message: string; value?: any; } export interface FormValidationWarning { field: string; code: string; message: string; suggestion?: string; } // 저장 결과 export interface FormDataResult { success: boolean; message: string; data?: any; affectedRows?: number; insertedId?: any; validationResult?: FormValidationResult; } export class EnhancedDynamicFormService { private dataflowControlService = new DataflowControlService(); private columnCache = new Map(); private webTypeCache = new Map(); /** * 폼 데이터 저장 (메인 메서드) */ async saveFormData( screenId: number, tableName: string, data: Record ): Promise { const startTime = Date.now(); try { console.log(`🚀 개선된 폼 저장 시작: ${tableName}`, { screenId, dataKeys: Object.keys(data), timestamp: new Date().toISOString(), }); // 1. 테이블 존재 여부 확인 const tableExists = await this.validateTableExists(tableName); if (!tableExists) { return { success: false, message: `테이블 '${tableName}'이 존재하지 않습니다.`, }; } // 2. 스키마 정보 로드 const [tableColumns, columnWebTypes] = await Promise.all([ this.getTableColumns(tableName), this.getColumnWebTypes(tableName), ]); // 3. 폼 데이터 검증 const validationResult = await this.validateFormData( data, tableColumns, columnWebTypes, tableName ); if (!validationResult.isValid) { console.error("❌ 폼 데이터 검증 실패:", validationResult.errors); return { success: false, message: this.formatValidationErrors(validationResult.errors), validationResult, }; } // 4. 데이터 저장 수행 const saveResult = await this.performDataSave( tableName, validationResult.transformedData, tableColumns ); const duration = Date.now() - startTime; console.log(`✅ 폼 저장 완료: ${duration}ms`); return { success: true, message: "데이터가 성공적으로 저장되었습니다.", data: saveResult.data, affectedRows: saveResult.affectedRows, insertedId: saveResult.insertedId, validationResult, }; } catch (error: any) { console.error("❌ 폼 저장 중 오류:", error); return { success: false, message: this.formatErrorMessage(error), data: { error: error.message, stack: error.stack }, }; } } /** * 테이블 존재 여부 확인 */ private async validateTableExists(tableName: string): Promise { try { const result = await query<{ exists: boolean }>( `SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_name = $1 ) as exists`, [tableName] ); return result[0]?.exists || false; } catch (error) { console.error(`❌ 테이블 존재 여부 확인 실패: ${tableName}`, error); return false; } } /** * 테이블 컬럼 정보 조회 (캐시 포함) */ private async getTableColumns(tableName: string): Promise { // 캐시 확인 const cached = this.columnCache.get(tableName); if (cached) { return cached; } try { const columns = await query( `SELECT column_name, data_type, is_nullable, column_default, character_maximum_length, numeric_precision, numeric_scale FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position`, [tableName] ); // 캐시 저장 (10분) this.columnCache.set(tableName, columns); setTimeout(() => this.columnCache.delete(tableName), 10 * 60 * 1000); return columns; } catch (error) { console.error(`❌ 테이블 컬럼 정보 조회 실패: ${tableName}`, error); throw new Error(`테이블 컬럼 정보를 조회할 수 없습니다: ${error}`); } } /** * 컬럼 웹타입 정보 조회 */ private async getColumnWebTypes( tableName: string ): Promise { // 캐시 확인 const cached = this.webTypeCache.get(tableName); if (cached) { return cached; } try { // table_type_columns에서 웹타입 정보 조회 const webTypeData = await query<{ column_name: string; web_type: string; is_nullable: string; detail_settings: any; }>( `SELECT column_name, web_type, is_nullable, detail_settings FROM table_type_columns WHERE table_name = $1`, [tableName] ); const columnWebTypes: ColumnWebTypeInfo[] = webTypeData.map((row) => ({ columnName: row.column_name, webType: normalizeWebType(row.web_type || "text"), isRequired: row.is_nullable === "N", validationRules: this.parseDetailSettings(row.detail_settings), defaultValue: null, })); // 캐시 저장 (10분) this.webTypeCache.set(tableName, columnWebTypes); setTimeout(() => this.webTypeCache.delete(tableName), 10 * 60 * 1000); return columnWebTypes; } catch (error) { console.error(`❌ 컬럼 웹타입 정보 조회 실패: ${tableName}`, error); // 실패 시 빈 배열 반환 (기본 검증만 수행) return []; } } /** * 상세 설정 파싱 */ private parseDetailSettings( detailSettings: string | null ): Record { if (!detailSettings) return {}; try { return JSON.parse(detailSettings); } catch { return {}; } } /** * 폼 데이터 검증 */ private async validateFormData( data: Record, tableColumns: TableColumn[], columnWebTypes: ColumnWebTypeInfo[], tableName: string ): Promise { const errors: FormValidationError[] = []; const warnings: FormValidationWarning[] = []; const transformedData: Record = {}; const columnMap = new Map( tableColumns.map((col) => [col.column_name, col]) ); const webTypeMap = new Map(columnWebTypes.map((wt) => [wt.columnName, wt])); console.log(`📋 폼 데이터 검증 시작: ${tableName}`, { inputFields: Object.keys(data).length, tableColumns: tableColumns.length, webTypeColumns: columnWebTypes.length, }); // 입력된 각 필드 검증 for (const [fieldName, value] of Object.entries(data)) { const column = columnMap.get(fieldName); const webTypeInfo = webTypeMap.get(fieldName); // 1. 컬럼 존재 여부 확인 if (!column) { errors.push({ field: fieldName, code: "COLUMN_NOT_EXISTS", message: `테이블 '${tableName}'에 '${fieldName}' 컬럼이 존재하지 않습니다.`, value, }); continue; } // 2. 필수값 검증 if (webTypeInfo?.isRequired && this.isEmptyValue(value)) { errors.push({ field: fieldName, code: "REQUIRED_FIELD", message: `'${fieldName}'은(는) 필수 입력 항목입니다.`, value, }); continue; } // 3. 웹타입별 검증 및 변환 if (webTypeInfo?.webType) { const validationResult = this.validateFieldByWebType( fieldName, value, webTypeInfo.webType, webTypeInfo.validationRules ); if (!validationResult.isValid) { errors.push(validationResult.error!); continue; } transformedData[fieldName] = validationResult.transformedValue; } else { // 웹타입 정보가 없는 경우 DB 타입 기반 변환 transformedData[fieldName] = this.convertValueForPostgreSQL( value, column.data_type ); } // 4. DB 제약조건 검증 const constraintValidation = this.validateDatabaseConstraints( fieldName, transformedData[fieldName], column ); if (!constraintValidation.isValid) { errors.push(constraintValidation.error!); continue; } } // 필수 컬럼 누락 확인 const requiredColumns = columnWebTypes.filter((wt) => wt.isRequired); for (const requiredCol of requiredColumns) { if ( !(requiredCol.columnName in data) || this.isEmptyValue(data[requiredCol.columnName]) ) { if (!errors.some((e) => e.field === requiredCol.columnName)) { errors.push({ field: requiredCol.columnName, code: "MISSING_REQUIRED_FIELD", message: `필수 입력 항목 '${requiredCol.columnName}'이 누락되었습니다.`, }); } } } console.log(`📋 폼 데이터 검증 완료:`, { errors: errors.length, warnings: warnings.length, transformedFields: Object.keys(transformedData).length, }); return { isValid: errors.length === 0, errors, warnings, transformedData, }; } /** * 웹타입별 필드 검증 */ private validateFieldByWebType( fieldName: string, value: any, webType: WebType, validationRules?: Record ): { isValid: boolean; error?: FormValidationError; transformedValue?: any } { // 빈 값 처리 if (this.isEmptyValue(value)) { return { isValid: true, transformedValue: null }; } // 웹타입 유효성 확인 if (!isValidWebType(webType)) { return { isValid: false, error: { field: fieldName, code: "INVALID_WEB_TYPE", message: `'${fieldName}'의 웹타입 '${webType}'이 올바르지 않습니다.`, value, }, }; } // 패턴 검증 const pattern = WEB_TYPE_VALIDATION_PATTERNS[webType]; if (pattern && !pattern.test(String(value))) { return { isValid: false, error: { field: fieldName, code: "INVALID_FORMAT", message: `'${fieldName}'의 형식이 올바르지 않습니다.`, value, }, }; } // 값 변환 try { const converter = WEB_TYPE_TO_POSTGRES_CONVERTER[webType]; const transformedValue = converter ? converter(value) : value; return { isValid: true, transformedValue }; } catch (error) { return { isValid: false, error: { field: fieldName, code: "CONVERSION_ERROR", message: `'${fieldName}' 값 변환 중 오류가 발생했습니다: ${error}`, value, }, }; } } /** * 데이터베이스 제약조건 검증 */ private validateDatabaseConstraints( fieldName: string, value: any, column: TableColumn ): { isValid: boolean; error?: FormValidationError } { // NULL 제약조건 if ( column.is_nullable === "NO" && (value === null || value === undefined) ) { return { isValid: false, error: { field: fieldName, code: "NOT_NULL_VIOLATION", message: `'${fieldName}'에는 NULL 값을 입력할 수 없습니다.`, value, }, }; } // 문자열 길이 제약조건 if (column.character_maximum_length && typeof value === "string") { if (value.length > column.character_maximum_length) { return { isValid: false, error: { field: fieldName, code: "STRING_TOO_LONG", message: `'${fieldName}'의 길이는 최대 ${column.character_maximum_length}자까지 입력할 수 있습니다.`, value, }, }; } } // 숫자 정밀도 검증 if (column.numeric_precision && typeof value === "number") { const totalDigits = Math.abs(value).toString().replace(".", "").length; if (totalDigits > column.numeric_precision) { return { isValid: false, error: { field: fieldName, code: "NUMERIC_OVERFLOW", message: `'${fieldName}'의 숫자 자릿수가 허용 범위를 초과했습니다.`, value, }, }; } } return { isValid: true }; } /** * 빈 값 확인 */ private isEmptyValue(value: any): boolean { return ( value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0) ); } /** * 데이터 저장 수행 */ private async performDataSave( tableName: string, data: Record, tableColumns: TableColumn[] ): Promise<{ data?: any; affectedRows: number; insertedId?: any }> { try { // Primary Key 확인 const primaryKeys = await this.getPrimaryKeys(tableName); const hasExistingRecord = primaryKeys.length > 0 && primaryKeys.every((pk) => data[pk] !== undefined && data[pk] !== null); if (hasExistingRecord) { // UPDATE 수행 return await this.performUpdate(tableName, data, primaryKeys); } else { // INSERT 수행 return await this.performInsert(tableName, data); } } catch (error) { console.error(`❌ 데이터 저장 실패: ${tableName}`, error); throw error; } } /** * Primary Key 조회 */ private async getPrimaryKeys(tableName: string): Promise { try { const result = await query<{ column_name: string }>( `SELECT column_name FROM information_schema.key_column_usage WHERE table_name = $1 AND constraint_name LIKE '%_pkey'`, [tableName] ); return result.map((row) => row.column_name); } catch (error) { console.error(`❌ Primary Key 조회 실패: ${tableName}`, error); return []; } } /** * INSERT 수행 */ private async performInsert( tableName: string, data: Record ): Promise<{ data?: any; affectedRows: number; insertedId?: any }> { const columns = Object.keys(data); const values = Object.values(data); const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); const insertQuery = ` INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders}) RETURNING * `; console.log(`📝 INSERT 쿼리 실행: ${tableName}`, { columns: columns.length, query: insertQuery.replace(/\n\s+/g, " "), }); const result = await query(insertQuery, values); return { data: result[0], affectedRows: result.length, insertedId: result[0]?.id || result[0], }; } /** * UPDATE 수행 */ private async performUpdate( tableName: string, data: Record, primaryKeys: string[] ): Promise<{ data?: any; affectedRows: number; insertedId?: any }> { const updateColumns = Object.keys(data).filter( (col) => !primaryKeys.includes(col) ); const whereColumns = primaryKeys.filter((pk) => data[pk] !== undefined); if (updateColumns.length === 0) { throw new Error("업데이트할 컬럼이 없습니다."); } const setClause = updateColumns .map((col, index) => `${col} = $${index + 1}`) .join(", "); const whereClause = whereColumns .map((col, index) => `${col} = $${updateColumns.length + index + 1}`) .join(" AND "); const updateValues = [ ...updateColumns.map((col) => data[col]), ...whereColumns.map((col) => data[col]), ]; const updateQuery = ` UPDATE ${tableName} SET ${setClause} WHERE ${whereClause} RETURNING * `; console.log(`📝 UPDATE 쿼리 실행: ${tableName}`, { updateColumns: updateColumns.length, whereColumns: whereColumns.length, query: updateQuery.replace(/\n\s+/g, " "), }); const result = await query(updateQuery, updateValues); return { data: result[0], affectedRows: result.length, }; } /** * PostgreSQL 타입 변환 (레거시 지원) */ private convertValueForPostgreSQL(value: any, dataType: string): any { if (value === null || value === undefined || value === "") { return null; } const lowerDataType = dataType.toLowerCase(); // 숫자 타입 처리 if ( lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial") ) { return parseInt(value) || null; } if ( lowerDataType.includes("numeric") || lowerDataType.includes("decimal") || lowerDataType.includes("real") || lowerDataType.includes("double") ) { return parseFloat(value) || null; } // 불린 타입 처리 if (lowerDataType.includes("boolean")) { if (typeof value === "boolean") return value; if (typeof value === "string") { return value.toLowerCase() === "true" || value === "1"; } return Boolean(value); } // 날짜/시간 타입 처리 if ( lowerDataType.includes("timestamp") || lowerDataType.includes("datetime") ) { const date = new Date(value); return isNaN(date.getTime()) ? null : date.toISOString(); } if (lowerDataType.includes("date")) { const date = new Date(value); return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0]; } // JSON 타입 처리 if (lowerDataType.includes("json")) { if (typeof value === "string") { try { JSON.parse(value); return value; } catch { return JSON.stringify(value); } } return JSON.stringify(value); } return String(value); } /** * 오류 메시지 포맷팅 */ private formatValidationErrors(errors: FormValidationError[]): string { if (errors.length === 0) return "알 수 없는 오류가 발생했습니다."; if (errors.length === 1) return errors[0].message; return `다음 오류들을 수정해주세요:\n• ${errors.map((e) => e.message).join("\n• ")}`; } /** * 시스템 오류 메시지 포맷팅 */ private formatErrorMessage(error: any): string { if (error.code === "23505") { return "중복된 데이터가 이미 존재합니다."; } if (error.code === "23503") { return "참조 무결성 제약조건을 위반했습니다."; } if (error.code === "23502") { return "필수 입력 항목이 누락되었습니다."; } if ( error.message?.includes("relation") && error.message?.includes("does not exist") ) { return "지정된 테이블이 존재하지 않습니다."; } return `저장 중 오류가 발생했습니다: ${error.message || error}`; } /** * 캐시 클리어 */ public clearCache(): void { this.columnCache.clear(); this.webTypeCache.clear(); console.log("🧹 동적 폼 서비스 캐시가 클리어되었습니다."); } /** * 테이블별 캐시 클리어 */ public clearTableCache(tableName: string): void { this.columnCache.delete(tableName); this.webTypeCache.delete(tableName); console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`); } } // 싱글톤 인스턴스 export const enhancedDynamicFormService = new EnhancedDynamicFormService();