/** * DDL 안전성 검증 서비스 * 테이블/컬럼 생성 전 모든 보안 검증을 수행 */ import { CreateColumnDefinition, ValidationResult, SYSTEM_TABLES, RESERVED_WORDS, RESERVED_COLUMNS, } from "../types/ddl"; import { logger } from "../utils/logger"; export class DDLSafetyValidator { /** * 테이블 생성 전 전체 검증 */ static validateTableCreation( tableName: string, columns: CreateColumnDefinition[] ): ValidationResult { const errors: string[] = []; const warnings: string[] = []; try { // 1. 테이블명 기본 검증 const tableNameValidation = this.validateTableName(tableName); if (!tableNameValidation.isValid) { errors.push(...tableNameValidation.errors); } // 2. 컬럼 기본 검증 if (columns.length === 0) { errors.push("최소 1개의 컬럼이 필요합니다."); } // 3. 컬럼 목록 검증 const columnsValidation = this.validateColumnList(columns); if (!columnsValidation.isValid) { errors.push(...columnsValidation.errors); } if (columnsValidation.warnings) { warnings.push(...columnsValidation.warnings); } // 4. 컬럼명 중복 검증 const duplicateValidation = this.validateColumnDuplication(columns); if (!duplicateValidation.isValid) { errors.push(...duplicateValidation.errors); } logger.info("테이블 생성 검증 완료", { tableName, columnCount: columns.length, errorCount: errors.length, warningCount: warnings.length, }); return { isValid: errors.length === 0, errors, warnings, }; } catch (error) { logger.error("테이블 생성 검증 중 오류 발생:", error); return { isValid: false, errors: ["테이블 생성 검증 중 내부 오류가 발생했습니다."], }; } } /** * 컬럼 추가 전 검증 */ static validateColumnAddition( tableName: string, column: CreateColumnDefinition ): ValidationResult { const errors: string[] = []; const warnings: string[] = []; try { // 1. 테이블명 검증 (시스템 테이블 확인) if (this.isSystemTable(tableName)) { errors.push( `'${tableName}'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.` ); } // 2. 컬럼 정의 검증 const columnValidation = this.validateSingleColumn(column); if (!columnValidation.isValid) { errors.push(...columnValidation.errors); } if (columnValidation.warnings) { warnings.push(...columnValidation.warnings); } logger.info("컬럼 추가 검증 완료", { tableName, columnName: column.name, webType: column.webType, errorCount: errors.length, }); return { isValid: errors.length === 0, errors, warnings, }; } catch (error) { logger.error("컬럼 추가 검증 중 오류 발생:", error); return { isValid: false, errors: ["컬럼 추가 검증 중 내부 오류가 발생했습니다."], }; } } /** * 테이블명 검증 */ private static validateTableName(tableName: string): ValidationResult { const errors: string[] = []; // 1. 기본 형식 검증 if (!this.isValidTableName(tableName)) { errors.push( "유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다." ); } // 2. 길이 검증 if (tableName.length > 63) { errors.push("테이블명은 63자를 초과할 수 없습니다."); } if (tableName.length < 2) { errors.push("테이블명은 최소 2자 이상이어야 합니다."); } // 3. 시스템 테이블 보호 if (this.isSystemTable(tableName)) { errors.push( `'${tableName}'은 시스템 테이블명으로 사용할 수 없습니다. 다른 이름을 선택해주세요.` ); } // 4. 예약어 검증 if (this.isReservedWord(tableName)) { errors.push( `'${tableName}'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.` ); } // 5. 일반적인 네이밍 컨벤션 검증 if (tableName.startsWith("_") || tableName.endsWith("_")) { errors.push("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다."); } if (tableName.includes("__")) { errors.push("테이블명에 연속된 언더스코어는 사용할 수 없습니다."); } return { isValid: errors.length === 0, errors, }; } /** * 컬럼 목록 검증 */ private static validateColumnList( columns: CreateColumnDefinition[] ): ValidationResult { const errors: string[] = []; const warnings: string[] = []; for (let i = 0; i < columns.length; i++) { const column = columns[i]; const columnValidation = this.validateSingleColumn(column, i + 1); if (!columnValidation.isValid) { errors.push(...columnValidation.errors); } if (columnValidation.warnings) { warnings.push(...columnValidation.warnings); } } return { isValid: errors.length === 0, errors, warnings, }; } /** * 개별 컬럼 검증 */ private static validateSingleColumn( column: CreateColumnDefinition, position?: number ): ValidationResult { const errors: string[] = []; const warnings: string[] = []; const prefix = position ? `컬럼 ${position}(${column.name}): ` : `컬럼 '${column.name}': `; // 1. 컬럼명 기본 검증 if (!column.name || column.name.trim() === "") { errors.push(`${prefix}컬럼명은 필수입니다.`); return { isValid: false, errors }; } if (!this.isValidColumnName(column.name)) { errors.push( `${prefix}유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.` ); } // 2. 길이 검증 if (column.name.length > 63) { errors.push(`${prefix}컬럼명은 63자를 초과할 수 없습니다.`); } if (column.name.length < 2) { errors.push(`${prefix}컬럼명은 최소 2자 이상이어야 합니다.`); } // 3. 예약된 컬럼명 검증 if (this.isReservedColumnName(column.name)) { errors.push( `${prefix}'${column.name}'은 예약된 컬럼명입니다. 기본 컬럼(id, created_date, updated_date, company_code)과 중복됩니다.` ); } // 4. SQL 예약어 검증 if (this.isReservedWord(column.name)) { errors.push( `${prefix}'${column.name}'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.` ); } // 5. 웹타입 검증 if (!column.webType) { errors.push(`${prefix}웹타입이 지정되지 않았습니다.`); } // 6. 길이 설정 검증 (text, code 타입에서만 허용) if (column.length !== undefined) { if ( !["text", "code", "email", "tel", "select", "radio"].includes( column.webType || "text" ) ) { warnings.push( `${prefix}${column.webType || "text"} 타입에서는 길이 설정이 무시됩니다.` ); } else if (column.length <= 0 || column.length > 65535) { errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`); } } // 7. 네이밍 컨벤션 검증 if (column.name.startsWith("_") || column.name.endsWith("_")) { warnings.push( `${prefix}컬럼명이 언더스코어로 시작하거나 끝나는 것은 권장하지 않습니다.` ); } if (column.name.includes("__")) { errors.push(`${prefix}컬럼명에 연속된 언더스코어는 사용할 수 없습니다.`); } return { isValid: errors.length === 0, errors, warnings, }; } /** * 컬럼명 중복 검증 */ private static validateColumnDuplication( columns: CreateColumnDefinition[] ): ValidationResult { const errors: string[] = []; const columnNames = columns.map((col) => col.name.toLowerCase()); const seen = new Set(); const duplicates = new Set(); for (const name of columnNames) { if (seen.has(name)) { duplicates.add(name); } else { seen.add(name); } } if (duplicates.size > 0) { errors.push( `중복된 컬럼명이 있습니다: ${Array.from(duplicates).join(", ")}` ); } return { isValid: errors.length === 0, errors, }; } /** * 테이블명 유효성 검증 (정규식) */ private static isValidTableName(tableName: string): boolean { const tableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; return tableNameRegex.test(tableName); } /** * 컬럼명 유효성 검증 (정규식) */ private static isValidColumnName(columnName: string): boolean { const columnNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/; return columnNameRegex.test(columnName) && columnName.length <= 63; } /** * 시스템 테이블 확인 */ private static isSystemTable(tableName: string): boolean { return SYSTEM_TABLES.includes(tableName.toLowerCase() as any); } /** * SQL 예약어 확인 */ private static isReservedWord(word: string): boolean { return RESERVED_WORDS.includes(word.toLowerCase() as any); } /** * 예약된 컬럼명 확인 */ private static isReservedColumnName(columnName: string): boolean { return RESERVED_COLUMNS.includes(columnName.toLowerCase() as any); } /** * 전체 검증 통계 생성 */ static generateValidationReport( tableName: string, columns: CreateColumnDefinition[] ): { tableName: string; totalColumns: number; validationResult: ValidationResult; summary: string; } { const validationResult = this.validateTableCreation(tableName, columns); let summary = `테이블 '${tableName}' 검증 완료. `; summary += `컬럼 ${columns.length}개 중 `; if (validationResult.isValid) { summary += "모든 검증 통과."; } else { summary += `${validationResult.errors.length}개 오류 발견.`; } if (validationResult.warnings && validationResult.warnings.length > 0) { summary += ` ${validationResult.warnings.length}개 경고 있음.`; } return { tableName, totalColumns: columns.length, validationResult, summary, }; } }