/** * DDL 실행 서비스 * 실제 PostgreSQL 테이블 및 컬럼 생성을 담당 */ import { query, queryOne, transaction } from "../database/db"; import { CreateColumnDefinition, DDLExecutionResult, WEB_TYPE_TO_POSTGRES_MAP, WebType, } from "../types/ddl"; import { DDLSafetyValidator } from "./ddlSafetyValidator"; import { DDLAuditLogger } from "./ddlAuditLogger"; import { logger } from "../utils/logger"; import { cache, CacheKeys } from "../utils/cache"; export class DDLExecutionService { /** * 새 테이블 생성 */ async createTable( tableName: string, columns: CreateColumnDefinition[], userCompanyCode: string, userId: string, description?: string ): Promise { // DDL 실행 시작 로그 await DDLAuditLogger.logDDLStart( userId, userCompanyCode, "CREATE_TABLE", tableName, { columns, description } ); try { // 1. 권한 검증 this.validateSuperAdminPermission(userCompanyCode); // 2. 안전성 검증 const validation = DDLSafetyValidator.validateTableCreation( tableName, columns ); if (!validation.isValid) { const errorMessage = `테이블 생성 검증 실패: ${validation.errors.join(", ")}`; await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "CREATE_TABLE", tableName, "VALIDATION_FAILED", false, errorMessage ); return { success: false, message: errorMessage, error: { code: "VALIDATION_FAILED", details: validation.errors.join(", "), }, }; } // 3. 테이블 존재 여부 확인 const tableExists = await this.checkTableExists(tableName); if (tableExists) { const errorMessage = `테이블 '${tableName}'이 이미 존재합니다.`; await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "CREATE_TABLE", tableName, "TABLE_EXISTS", false, errorMessage ); return { success: false, message: errorMessage, error: { code: "TABLE_EXISTS", details: errorMessage, }, }; } // 4. DDL 쿼리 생성 const ddlQuery = this.generateCreateTableQuery(tableName, columns); // 5. 트랜잭션으로 안전하게 실행 await transaction(async (client) => { // 5-1. 테이블 생성 await client.query(ddlQuery); // 5-2. 테이블 메타데이터 저장 await this.saveTableMetadata(client, tableName, description); // 5-3. 컬럼 메타데이터 저장 await this.saveColumnMetadata(client, tableName, columns, userCompanyCode); }); // 6. 성공 로그 기록 await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "CREATE_TABLE", tableName, ddlQuery, true ); logger.info("테이블 생성 성공", { tableName, userId, columnCount: columns.length, }); // 테이블 생성 후 관련 캐시 무효화 this.invalidateTableCache(tableName); return { success: true, message: `테이블 '${tableName}'이 성공적으로 생성되었습니다.`, executedQuery: ddlQuery, }; } catch (error) { const errorMessage = `테이블 생성 실패: ${(error as Error).message}`; // 실패 로그 기록 await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "CREATE_TABLE", tableName, `FAILED: ${(error as Error).message}`, false, errorMessage ); logger.error("테이블 생성 실패:", { tableName, userId, error: (error as Error).message, stack: (error as Error).stack, }); return { success: false, message: errorMessage, error: { code: "EXECUTION_FAILED", details: (error as Error).message, }, }; } } /** * 기존 테이블에 컬럼 추가 */ async addColumn( tableName: string, column: CreateColumnDefinition, userCompanyCode: string, userId: string ): Promise { // DDL 실행 시작 로그 await DDLAuditLogger.logDDLStart( userId, userCompanyCode, "ADD_COLUMN", tableName, { column } ); try { // 1. 권한 검증 this.validateSuperAdminPermission(userCompanyCode); // 2. 안전성 검증 const validation = DDLSafetyValidator.validateColumnAddition( tableName, column ); if (!validation.isValid) { const errorMessage = `컬럼 추가 검증 실패: ${validation.errors.join(", ")}`; await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "ADD_COLUMN", tableName, "VALIDATION_FAILED", false, errorMessage ); return { success: false, message: errorMessage, error: { code: "VALIDATION_FAILED", details: validation.errors.join(", "), }, }; } // 3. 테이블 존재 여부 확인 const tableExists = await this.checkTableExists(tableName); if (!tableExists) { const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`; await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "ADD_COLUMN", tableName, "TABLE_NOT_EXISTS", false, errorMessage ); return { success: false, message: errorMessage, error: { code: "TABLE_NOT_EXISTS", details: errorMessage, }, }; } // 4. 컬럼 존재 여부 확인 const columnExists = await this.checkColumnExists(tableName, column.name); if (columnExists) { const errorMessage = `컬럼 '${column.name}'이 이미 존재합니다.`; await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "ADD_COLUMN", tableName, "COLUMN_EXISTS", false, errorMessage ); return { success: false, message: errorMessage, error: { code: "COLUMN_EXISTS", details: errorMessage, }, }; } // 5. DDL 쿼리 생성 const ddlQuery = this.generateAddColumnQuery(tableName, column); // 6. 트랜잭션으로 안전하게 실행 await transaction(async (client) => { // 6-1. 컬럼 추가 await client.query(ddlQuery); // 6-2. 컬럼 메타데이터 저장 await this.saveColumnMetadata(client, tableName, [column], userCompanyCode); }); // 7. 성공 로그 기록 await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "ADD_COLUMN", tableName, ddlQuery, true ); logger.info("컬럼 추가 성공", { tableName, columnName: column.name, webType: column.webType, userId, }); // 컬럼 추가 후 관련 캐시 무효화 this.invalidateTableCache(tableName); return { success: true, message: `컬럼 '${column.name}'이 성공적으로 추가되었습니다.`, executedQuery: ddlQuery, }; } catch (error) { const errorMessage = `컬럼 추가 실패: ${(error as Error).message}`; // 실패 로그 기록 await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "ADD_COLUMN", tableName, `FAILED: ${(error as Error).message}`, false, errorMessage ); logger.error("컬럼 추가 실패:", { tableName, columnName: column.name, userId, error: (error as Error).message, stack: (error as Error).stack, }); return { success: false, message: errorMessage, error: { code: "EXECUTION_FAILED", details: (error as Error).message, }, }; } } /** * CREATE TABLE DDL 쿼리 생성 */ private generateCreateTableQuery( tableName: string, columns: CreateColumnDefinition[] ): string { // 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 통일 const columnDefinitions = columns .map((col) => { // 입력 타입과 관계없이 모든 컬럼을 VARCHAR(500)로 생성 let definition = `"${col.name}" varchar(500)`; if (!col.nullable) { definition += " NOT NULL"; } if (col.defaultValue) { definition += ` DEFAULT '${col.defaultValue}'`; } return definition; }) .join(",\n "); // 기본 컬럼들 (날짜는 TIMESTAMP, 나머지는 VARCHAR) const baseColumns = ` "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, "created_date" timestamp DEFAULT now(), "updated_date" timestamp DEFAULT now(), "writer" varchar(500) DEFAULT NULL, "company_code" varchar(500)`; // 최종 CREATE TABLE 쿼리 return ` CREATE TABLE "${tableName}" (${baseColumns}, ${columnDefinitions} );`.trim(); } /** * ALTER TABLE ADD COLUMN DDL 쿼리 생성 */ private generateAddColumnQuery( tableName: string, column: CreateColumnDefinition ): string { // 새로 추가되는 컬럼도 VARCHAR(500)로 통일 let definition = `"${column.name}" varchar(500)`; if (!column.nullable) { definition += " NOT NULL"; } if (column.defaultValue) { definition += ` DEFAULT '${column.defaultValue}'`; } return `ALTER TABLE "${tableName}" ADD COLUMN ${definition};`; } /** * 입력타입을 PostgreSQL 타입으로 매핑 (날짜는 TIMESTAMP, 나머지는 VARCHAR) * 날짜 타입만 TIMESTAMP로, 나머지는 VARCHAR(500)로 통일 */ private mapInputTypeToPostgresType(inputType?: string): string { switch (inputType) { case "date": return "timestamp"; default: // 날짜 외의 모든 타입은 VARCHAR(500)로 통일 return "varchar(500)"; } } /** * 레거시 지원: 웹타입을 PostgreSQL 타입으로 매핑 * @deprecated 새로운 시스템에서는 mapInputTypeToPostgresType 사용 */ private mapWebTypeToPostgresType(webType: WebType, length?: number): string { // 레거시 지원을 위해 유지하되, VARCHAR(500)로 통일 logger.info(`레거시 웹타입 사용: ${webType} → varchar(500)로 변환`); return "varchar(500)"; } /** * 테이블 메타데이터 저장 */ private async saveTableMetadata( client: any, tableName: string, description?: string ): Promise { await client.query( ` INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) VALUES ($1, $2, $3, now(), now()) ON CONFLICT (table_name) DO UPDATE SET table_label = $2, description = $3, updated_date = now() `, [tableName, tableName, description || `사용자 생성 테이블: ${tableName}`] ); } /** * 컬럼 메타데이터 저장 */ private async saveColumnMetadata( client: any, tableName: string, columns: CreateColumnDefinition[], companyCode: string ): Promise { // 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성 await client.query( ` INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) VALUES ($1, $2, $3, now(), now()) ON CONFLICT (table_name) DO UPDATE SET updated_date = now() `, [tableName, tableName, `자동 생성된 테이블 메타데이터: ${tableName}`] ); // 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼) const defaultColumns = [ { name: "id", label: "ID", inputType: "text", description: "기본키 (자동생성)", order: -5, isVisible: true, }, { name: "created_date", label: "생성일시", inputType: "date", description: "레코드 생성일시", order: -4, isVisible: true, }, { name: "updated_date", label: "수정일시", inputType: "date", description: "레코드 수정일시", order: -3, isVisible: true, }, { name: "writer", label: "작성자", inputType: "text", description: "레코드 작성자", order: -2, isVisible: true, }, { name: "company_code", label: "회사코드", inputType: "text", description: "회사 구분 코드", order: -1, isVisible: true, }, ]; // 기본 컬럼들을 table_type_columns에 등록 for (const defaultCol of defaultColumns) { await client.query( ` INSERT INTO table_type_columns ( table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date ) VALUES ( $1, $2, $3, $4, '{}', 'Y', $5, now(), now() ) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = $4, display_order = $5, updated_date = now() `, [tableName, defaultCol.name, companyCode, defaultCol.inputType, defaultCol.order] ); } // 사용자 정의 컬럼들을 table_type_columns에 등록 for (let i = 0; i < columns.length; i++) { const column = columns[i]; const inputType = this.convertWebTypeToInputType( column.webType || "text" ); const detailSettings = JSON.stringify(column.detailSettings || {}); await client.query( ` INSERT INTO table_type_columns ( table_name, column_name, company_code, input_type, detail_settings, is_nullable, display_order, created_date, updated_date ) VALUES ( $1, $2, $3, $4, $5, 'Y', $6, now(), now() ) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET input_type = $4, detail_settings = $5, display_order = $6, updated_date = now() `, [tableName, column.name, companyCode, inputType, detailSettings, i] ); } // 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성) // 1. 기본 컬럼들을 column_labels에 등록 for (const defaultCol of defaultColumns) { await client.query( ` INSERT INTO column_labels ( table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, now(), now() ) ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = $3, input_type = $4, detail_settings = $5, description = $6, display_order = $7, is_visible = $8, updated_date = now() `, [ tableName, defaultCol.name, defaultCol.label, defaultCol.inputType, JSON.stringify({}), defaultCol.description, defaultCol.order, defaultCol.isVisible, ] ); } // 2. 사용자 정의 컬럼들을 column_labels에 등록 for (const column of columns) { const inputType = this.convertWebTypeToInputType( column.webType || "text" ); const detailSettings = JSON.stringify(column.detailSettings || {}); await client.query( ` INSERT INTO column_labels ( table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, created_date, updated_date ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, now(), now() ) ON CONFLICT (table_name, column_name) DO UPDATE SET column_label = $3, input_type = $4, detail_settings = $5, description = $6, display_order = $7, is_visible = $8, updated_date = now() `, [ tableName, column.name, column.label || column.name, inputType, detailSettings, column.description, column.order || 0, true, ] ); } } /** * 웹 타입을 입력 타입으로 변환 */ private convertWebTypeToInputType(webType: string): string { const webTypeToInputTypeMap: Record = { // 텍스트 관련 text: "text", textarea: "text", email: "text", tel: "text", url: "text", password: "text", // 숫자 관련 number: "number", decimal: "number", // 날짜 관련 date: "date", datetime: "date", time: "date", // 선택 관련 select: "select", dropdown: "select", checkbox: "checkbox", boolean: "checkbox", radio: "radio", // 참조 관련 code: "code", entity: "entity", // 기타 file: "text", button: "text", }; return webTypeToInputTypeMap[webType] || "text"; } /** * 권한 검증 (슈퍼관리자 확인) */ private validateSuperAdminPermission(userCompanyCode: string): void { if (userCompanyCode !== "*") { throw new Error("최고 관리자 권한이 필요합니다."); } } /** * 테이블 존재 여부 확인 */ private async checkTableExists(tableName: string): Promise { try { const result = await queryOne<{ exists: boolean }>( ` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 ) `, [tableName] ); return result?.exists || false; } catch (error) { logger.error("테이블 존재 확인 오류:", error); return false; } } /** * 컬럼 존재 여부 확인 */ private async checkColumnExists( tableName: string, columnName: string ): Promise { try { const result = await queryOne<{ exists: boolean }>( ` SELECT EXISTS ( SELECT FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 ) `, [tableName, columnName] ); return result?.exists || false; } catch (error) { logger.error("컬럼 존재 확인 오류:", error); return false; } } /** * 생성된 테이블 정보 조회 */ async getCreatedTableInfo(tableName: string): Promise<{ tableInfo: any; columns: any[]; } | null> { try { // 테이블 정보 조회 const tableInfo = await queryOne( `SELECT * FROM table_labels WHERE table_name = $1`, [tableName] ); // 컬럼 정보 조회 const columns = await query( `SELECT * FROM column_labels WHERE table_name = $1 ORDER BY display_order ASC`, [tableName] ); if (!tableInfo) { return null; } return { tableInfo, columns, }; } catch (error) { logger.error("생성된 테이블 정보 조회 실패:", error); return null; } } /** * 테이블 삭제 (DROP TABLE) */ async dropTable( tableName: string, userCompanyCode: string, userId: string ): Promise { // DDL 실행 시작 로그 await DDLAuditLogger.logDDLStart( userId, userCompanyCode, "DROP_TABLE", tableName, {} ); try { // 1. 권한 검증 (최고 관리자만 가능) this.validateSuperAdminPermission(userCompanyCode); // 2. 테이블 존재 여부 확인 const tableExists = await this.checkTableExists(tableName); if (!tableExists) { const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`; await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "DROP_TABLE", tableName, "TABLE_NOT_FOUND", false, errorMessage ); return { success: false, message: errorMessage, error: { code: "TABLE_NOT_FOUND", details: errorMessage, }, }; } // 3. DDL 쿼리 생성 const ddlQuery = `DROP TABLE IF EXISTS "${tableName}" CASCADE`; // 4. 트랜잭션으로 안전하게 실행 await transaction(async (client) => { // 4-1. 테이블 삭제 await client.query(ddlQuery); // 4-2. 관련 메타데이터 삭제 await client.query(`DELETE FROM column_labels WHERE table_name = $1`, [ tableName, ]); await client.query(`DELETE FROM table_labels WHERE table_name = $1`, [ tableName, ]); }); // 5. 성공 로그 기록 await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "DROP_TABLE", tableName, ddlQuery, true ); logger.info("테이블 삭제 성공", { tableName, userId, }); // 테이블 삭제 후 관련 캐시 무효화 this.invalidateTableCache(tableName); return { success: true, message: `테이블 '${tableName}'이 성공적으로 삭제되었습니다.`, executedQuery: ddlQuery, }; } catch (error) { const errorMessage = `테이블 삭제 실패: ${(error as Error).message}`; // 실패 로그 기록 await DDLAuditLogger.logDDLExecution( userId, userCompanyCode, "DROP_TABLE", tableName, `FAILED: ${(error as Error).message}`, false, errorMessage ); logger.error("테이블 삭제 실패:", { tableName, userId, error: (error as Error).message, stack: (error as Error).stack, }); return { success: false, message: errorMessage, error: { code: "EXECUTION_FAILED", details: (error as Error).message, }, }; } } /** * 테이블 관련 캐시 무효화 * DDL 작업 후 호출하여 캐시된 데이터를 클리어 */ private invalidateTableCache(tableName: string): void { try { // 테이블 컬럼 관련 캐시 무효화 const columnCacheDeleted = cache.deleteByPattern( `table_columns:${tableName}` ); const countCacheDeleted = cache.deleteByPattern( `table_column_count:${tableName}` ); cache.delete("table_list"); const totalDeleted = columnCacheDeleted + countCacheDeleted + 1; logger.info( `테이블 캐시 무효화 완료: ${tableName}, 삭제된 키: ${totalDeleted}개` ); } catch (error) { logger.warn(`테이블 캐시 무효화 실패: ${tableName}`, error); } } }