2025-09-19 18:43:55 +09:00
|
|
|
/**
|
|
|
|
|
* 개선된 폼 데이터 저장 서비스
|
|
|
|
|
* 클라이언트 측 사전 검증과 서버 측 검증을 조합한 안전한 저장 로직
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { ComponentData, ColumnInfo, ScreenDefinition } from "@/types/screen";
|
|
|
|
|
import { validateFormData, ValidationResult } from "@/lib/utils/formValidation";
|
|
|
|
|
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
|
|
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
|
|
|
import { screenApi } from "@/lib/api/screen";
|
|
|
|
|
|
|
|
|
|
// 저장 결과 타입
|
|
|
|
|
export interface EnhancedSaveResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
data?: any;
|
|
|
|
|
validationResult?: ValidationResult;
|
|
|
|
|
performance?: {
|
|
|
|
|
validationTime: number;
|
|
|
|
|
saveTime: number;
|
|
|
|
|
totalTime: number;
|
|
|
|
|
};
|
|
|
|
|
warnings?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 저장 옵션
|
|
|
|
|
export interface SaveOptions {
|
|
|
|
|
skipClientValidation?: boolean;
|
|
|
|
|
skipServerValidation?: boolean;
|
|
|
|
|
transformData?: boolean;
|
|
|
|
|
showProgress?: boolean;
|
|
|
|
|
retryOnError?: boolean;
|
|
|
|
|
maxRetries?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 저장 컨텍스트
|
|
|
|
|
export interface SaveContext {
|
|
|
|
|
tableName: string;
|
|
|
|
|
screenInfo: ScreenDefinition;
|
|
|
|
|
components: ComponentData[];
|
|
|
|
|
formData: Record<string, any>;
|
|
|
|
|
options?: SaveOptions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 향상된 폼 데이터 저장 클래스
|
|
|
|
|
*/
|
|
|
|
|
export class EnhancedFormService {
|
|
|
|
|
private static instance: EnhancedFormService;
|
|
|
|
|
private columnCache = new Map<string, ColumnInfo[]>();
|
|
|
|
|
private validationCache = new Map<string, ValidationResult>();
|
|
|
|
|
|
|
|
|
|
public static getInstance(): EnhancedFormService {
|
|
|
|
|
if (!EnhancedFormService.instance) {
|
|
|
|
|
EnhancedFormService.instance = new EnhancedFormService();
|
|
|
|
|
}
|
|
|
|
|
return EnhancedFormService.instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메인 저장 메서드
|
|
|
|
|
*/
|
|
|
|
|
async saveFormData(context: SaveContext): Promise<EnhancedSaveResult> {
|
|
|
|
|
const startTime = performance.now();
|
|
|
|
|
let validationTime = 0;
|
|
|
|
|
let saveTime = 0;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { tableName, screenInfo, components, formData, options = {} } = context;
|
|
|
|
|
|
|
|
|
|
console.log("🚀 향상된 폼 저장 시작:", {
|
|
|
|
|
tableName,
|
|
|
|
|
screenId: screenInfo.screenId,
|
|
|
|
|
dataKeys: Object.keys(formData),
|
|
|
|
|
componentsCount: components.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 1. 사전 검증 수행
|
|
|
|
|
let validationResult: ValidationResult | undefined;
|
|
|
|
|
if (!options.skipClientValidation) {
|
|
|
|
|
const validationStart = performance.now();
|
|
|
|
|
validationResult = await this.performClientValidation(formData, components, tableName);
|
|
|
|
|
validationTime = performance.now() - validationStart;
|
|
|
|
|
|
|
|
|
|
if (!validationResult.isValid) {
|
|
|
|
|
console.error("❌ 클라이언트 검증 실패:", validationResult.errors);
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: this.formatValidationMessage(validationResult),
|
|
|
|
|
validationResult,
|
|
|
|
|
performance: {
|
|
|
|
|
validationTime,
|
|
|
|
|
saveTime: 0,
|
|
|
|
|
totalTime: performance.now() - startTime,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. 데이터 변환 및 정제
|
|
|
|
|
let processedData = formData;
|
|
|
|
|
if (options.transformData !== false) {
|
|
|
|
|
processedData = await this.transformFormData(formData, components, tableName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. 서버 저장 수행
|
|
|
|
|
const saveStart = performance.now();
|
|
|
|
|
const saveResult = await this.performServerSave(screenInfo.screenId, tableName, processedData, options);
|
|
|
|
|
saveTime = performance.now() - saveStart;
|
|
|
|
|
|
|
|
|
|
if (!saveResult.success) {
|
|
|
|
|
console.error("❌ 서버 저장 실패:", saveResult.message);
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: saveResult.message || "저장 중 서버 오류가 발생했습니다.",
|
|
|
|
|
data: saveResult.data,
|
|
|
|
|
validationResult,
|
|
|
|
|
performance: {
|
|
|
|
|
validationTime,
|
|
|
|
|
saveTime,
|
|
|
|
|
totalTime: performance.now() - startTime,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("✅ 폼 저장 성공:", {
|
|
|
|
|
validationTime: `${validationTime.toFixed(2)}ms`,
|
|
|
|
|
saveTime: `${saveTime.toFixed(2)}ms`,
|
|
|
|
|
totalTime: `${(performance.now() - startTime).toFixed(2)}ms`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
message: "데이터가 성공적으로 저장되었습니다.",
|
|
|
|
|
data: saveResult.data,
|
|
|
|
|
validationResult,
|
|
|
|
|
performance: {
|
|
|
|
|
validationTime,
|
|
|
|
|
saveTime,
|
|
|
|
|
totalTime: performance.now() - startTime,
|
|
|
|
|
},
|
|
|
|
|
warnings: validationResult?.warnings.map((w) => w.message),
|
|
|
|
|
};
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("❌ 폼 저장 중 예외 발생:", error);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: `저장 중 오류가 발생했습니다: ${error.message || error}`,
|
|
|
|
|
performance: {
|
|
|
|
|
validationTime,
|
|
|
|
|
saveTime,
|
|
|
|
|
totalTime: performance.now() - startTime,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 클라이언트 측 검증 수행
|
|
|
|
|
*/
|
|
|
|
|
private async performClientValidation(
|
|
|
|
|
formData: Record<string, any>,
|
|
|
|
|
components: ComponentData[],
|
|
|
|
|
tableName: string,
|
|
|
|
|
): Promise<ValidationResult> {
|
|
|
|
|
try {
|
|
|
|
|
// 캐시된 검증 결과 확인
|
|
|
|
|
const cacheKey = this.generateValidationCacheKey(formData, components, tableName);
|
|
|
|
|
const cached = this.validationCache.get(cacheKey);
|
|
|
|
|
if (cached) {
|
|
|
|
|
console.log("📋 캐시된 검증 결과 사용");
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 테이블 컬럼 정보 조회 (캐시 사용)
|
|
|
|
|
const tableColumns = await this.getTableColumns(tableName);
|
|
|
|
|
|
|
|
|
|
// 폼 데이터 검증 수행
|
|
|
|
|
const validationResult = await validateFormData(formData, components, tableColumns, tableName);
|
|
|
|
|
|
|
|
|
|
// 결과 캐시 저장 (5분간)
|
|
|
|
|
setTimeout(
|
|
|
|
|
() => {
|
|
|
|
|
this.validationCache.delete(cacheKey);
|
|
|
|
|
},
|
|
|
|
|
5 * 60 * 1000,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.validationCache.set(cacheKey, validationResult);
|
|
|
|
|
return validationResult;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("❌ 클라이언트 검증 중 오류:", error);
|
|
|
|
|
return {
|
|
|
|
|
isValid: false,
|
|
|
|
|
errors: [
|
|
|
|
|
{
|
|
|
|
|
field: "validation",
|
|
|
|
|
code: "VALIDATION_ERROR",
|
|
|
|
|
message: `검증 중 오류가 발생했습니다: ${error}`,
|
|
|
|
|
severity: "error",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
warnings: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 컬럼 정보 조회 (캐시 포함)
|
|
|
|
|
*/
|
|
|
|
|
private async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
2025-10-17 15:31:23 +09:00
|
|
|
// tableName이 비어있으면 빈 배열 반환
|
|
|
|
|
if (!tableName || tableName.trim() === "") {
|
|
|
|
|
console.warn("⚠️ getTableColumns: tableName이 비어있음");
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-19 18:43:55 +09:00
|
|
|
// 캐시 확인
|
|
|
|
|
const cached = this.columnCache.get(tableName);
|
|
|
|
|
if (cached) {
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
const columns = response.data.map((col) => ({
|
|
|
|
|
tableName: col.tableName || tableName,
|
|
|
|
|
columnName: col.columnName,
|
|
|
|
|
columnLabel: col.displayName,
|
|
|
|
|
dataType: col.dataType,
|
|
|
|
|
webType: col.webType,
|
|
|
|
|
inputType: col.inputType,
|
|
|
|
|
isNullable: col.isNullable,
|
|
|
|
|
required: col.isNullable === "N",
|
|
|
|
|
detailSettings: col.detailSettings,
|
|
|
|
|
codeCategory: col.codeCategory,
|
|
|
|
|
referenceTable: col.referenceTable,
|
|
|
|
|
referenceColumn: col.referenceColumn,
|
|
|
|
|
displayColumn: col.displayColumn,
|
|
|
|
|
isVisible: col.isVisible,
|
|
|
|
|
displayOrder: col.displayOrder,
|
|
|
|
|
description: col.description,
|
|
|
|
|
})) as ColumnInfo[];
|
|
|
|
|
|
|
|
|
|
// 캐시 저장 (10분간)
|
|
|
|
|
this.columnCache.set(tableName, columns);
|
|
|
|
|
setTimeout(
|
|
|
|
|
() => {
|
|
|
|
|
this.columnCache.delete(tableName);
|
|
|
|
|
},
|
|
|
|
|
10 * 60 * 1000,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return columns;
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(response.message || "컬럼 정보 조회 실패");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("❌ 컬럼 정보 조회 실패:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 폼 데이터 변환 및 정제
|
|
|
|
|
*/
|
|
|
|
|
private async transformFormData(
|
|
|
|
|
formData: Record<string, any>,
|
|
|
|
|
components: ComponentData[],
|
|
|
|
|
tableName: string,
|
|
|
|
|
): Promise<Record<string, any>> {
|
|
|
|
|
const transformed = { ...formData };
|
|
|
|
|
const tableColumns = await this.getTableColumns(tableName);
|
|
|
|
|
const columnMap = new Map(tableColumns.map((col) => [col.columnName, col]));
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(transformed)) {
|
|
|
|
|
const column = columnMap.get(key);
|
|
|
|
|
if (!column) continue;
|
|
|
|
|
|
|
|
|
|
// 빈 문자열을 null로 변환 (nullable 컬럼인 경우)
|
|
|
|
|
if (value === "" && column.isNullable === "Y") {
|
|
|
|
|
transformed[key] = null;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 데이터 타입별 변환
|
|
|
|
|
if (value !== null && value !== undefined) {
|
|
|
|
|
transformed[key] = this.convertValueByDataType(value, column.dataType);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 시스템 필드 자동 추가
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
if (!transformed.created_date && tableColumns.some((col) => col.columnName === "created_date")) {
|
|
|
|
|
transformed.created_date = now;
|
|
|
|
|
}
|
|
|
|
|
if (!transformed.updated_date && tableColumns.some((col) => col.columnName === "updated_date")) {
|
|
|
|
|
transformed.updated_date = now;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("🔄 데이터 변환 완료:", {
|
|
|
|
|
original: Object.keys(formData).length,
|
|
|
|
|
transformed: Object.keys(transformed).length,
|
|
|
|
|
changes: Object.keys(transformed).filter((key) => transformed[key] !== formData[key]),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return transformed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 데이터 타입별 값 변환
|
|
|
|
|
*/
|
|
|
|
|
private convertValueByDataType(value: any, dataType: string): any {
|
|
|
|
|
const lowerDataType = dataType.toLowerCase();
|
|
|
|
|
|
|
|
|
|
// 숫자 타입
|
|
|
|
|
if (lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial")) {
|
|
|
|
|
const num = parseInt(value);
|
|
|
|
|
return isNaN(num) ? null : num;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
lowerDataType.includes("numeric") ||
|
|
|
|
|
lowerDataType.includes("decimal") ||
|
|
|
|
|
lowerDataType.includes("real") ||
|
|
|
|
|
lowerDataType.includes("double")
|
|
|
|
|
) {
|
|
|
|
|
const num = parseFloat(value);
|
|
|
|
|
return isNaN(num) ? null : num;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 불린 타입
|
|
|
|
|
if (lowerDataType.includes("boolean")) {
|
|
|
|
|
if (typeof value === "boolean") return value;
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
return value.toLowerCase() === "true" || value === "1" || value === "Y";
|
|
|
|
|
}
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lowerDataType.includes("time")) {
|
|
|
|
|
// 시간 형식 변환 로직
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 async performServerSave(
|
|
|
|
|
screenId: number,
|
|
|
|
|
tableName: string,
|
|
|
|
|
formData: Record<string, any>,
|
|
|
|
|
options: SaveOptions,
|
|
|
|
|
): Promise<{ success: boolean; message?: string; data?: any }> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await dynamicFormApi.saveData({
|
|
|
|
|
screenId,
|
|
|
|
|
tableName,
|
|
|
|
|
data: formData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: result.success,
|
|
|
|
|
message: result.message || "저장이 완료되었습니다.",
|
|
|
|
|
data: result.data,
|
|
|
|
|
};
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error("❌ 서버 저장 오류:", error);
|
|
|
|
|
|
|
|
|
|
// 에러 타입별 처리
|
|
|
|
|
if (error.response?.status === 400) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: "잘못된 요청: " + (error.response.data?.message || "데이터 형식을 확인해주세요."),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error.response?.status === 500) {
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: "서버 오류: " + (error.response.data?.message || "잠시 후 다시 시도해주세요."),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: false,
|
|
|
|
|
message: error.message || "저장 중 알 수 없는 오류가 발생했습니다.",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 검증 캐시 키 생성
|
|
|
|
|
*/
|
|
|
|
|
private generateValidationCacheKey(
|
|
|
|
|
formData: Record<string, any>,
|
|
|
|
|
components: ComponentData[],
|
|
|
|
|
tableName: string,
|
|
|
|
|
): string {
|
|
|
|
|
const dataHash = JSON.stringify(formData);
|
|
|
|
|
const componentsHash = JSON.stringify(
|
|
|
|
|
components.map((c) => ({ id: c.id, type: c.type, columnName: (c as any).columnName })),
|
|
|
|
|
);
|
|
|
|
|
return `${tableName}:${btoa(dataHash + componentsHash).substring(0, 32)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 검증 메시지 포맷팅
|
|
|
|
|
*/
|
|
|
|
|
private formatValidationMessage(validationResult: ValidationResult): string {
|
|
|
|
|
const errorMessages = validationResult.errors.filter((e) => e.severity === "error").map((e) => e.message);
|
|
|
|
|
|
|
|
|
|
if (errorMessages.length === 0) {
|
|
|
|
|
return "알 수 없는 검증 오류가 발생했습니다.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (errorMessages.length === 1) {
|
|
|
|
|
return errorMessages[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `다음 오류들을 수정해주세요:\n• ${errorMessages.join("\n• ")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 캐시 클리어
|
|
|
|
|
*/
|
|
|
|
|
public clearCache(): void {
|
|
|
|
|
this.columnCache.clear();
|
|
|
|
|
this.validationCache.clear();
|
|
|
|
|
console.log("🧹 폼 서비스 캐시가 클리어되었습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테이블별 캐시 클리어
|
|
|
|
|
*/
|
|
|
|
|
public clearTableCache(tableName: string): void {
|
|
|
|
|
this.columnCache.delete(tableName);
|
|
|
|
|
console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 싱글톤 인스턴스 내보내기
|
|
|
|
|
export const enhancedFormService = EnhancedFormService.getInstance();
|
|
|
|
|
|
|
|
|
|
// 편의 함수들
|
|
|
|
|
export const saveFormDataEnhanced = (context: SaveContext): Promise<EnhancedSaveResult> => {
|
|
|
|
|
return enhancedFormService.saveFormData(context);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const clearFormCache = (): void => {
|
|
|
|
|
enhancedFormService.clearCache();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const clearTableFormCache = (tableName: string): void => {
|
|
|
|
|
enhancedFormService.clearTableCache(tableName);
|
|
|
|
|
};
|