/** * DDL 실행 서비스 * 실제 PostgreSQL 테이블 및 컬럼 생성을 담당 */ import { PrismaClient } from "@prisma/client"; 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"; const prisma = new PrismaClient(); 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 prisma.$transaction(async (tx) => { // 5-1. 테이블 생성 await tx.$executeRawUnsafe(ddlQuery); // 5-2. 테이블 메타데이터 저장 await this.saveTableMetadata(tx, tableName, description); // 5-3. 컬럼 메타데이터 저장 await this.saveColumnMetadata(tx, tableName, columns); }); // 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 prisma.$transaction(async (tx) => { // 6-1. 컬럼 추가 await tx.$executeRawUnsafe(ddlQuery); // 6-2. 컬럼 메타데이터 저장 await this.saveColumnMetadata(tx, tableName, [column]); }); // 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 { // 사용자 정의 컬럼들 const columnDefinitions = columns .map((col) => { const postgresType = this.mapWebTypeToPostgresType( col.webType, col.length ); let definition = `"${col.name}" ${postgresType}`; if (!col.nullable) { definition += " NOT NULL"; } if (col.defaultValue) { definition += ` DEFAULT '${col.defaultValue}'`; } return definition; }) .join(",\n "); // 기본 컬럼들 (시스템 필수 컬럼) const baseColumns = ` "id" serial PRIMARY KEY, "created_date" timestamp DEFAULT now(), "updated_date" timestamp DEFAULT now(), "writer" varchar(100), "company_code" varchar(50) DEFAULT '*'`; // 최종 CREATE TABLE 쿼리 return ` CREATE TABLE "${tableName}" (${baseColumns}, ${columnDefinitions} );`.trim(); } /** * ALTER TABLE ADD COLUMN DDL 쿼리 생성 */ private generateAddColumnQuery( tableName: string, column: CreateColumnDefinition ): string { const postgresType = this.mapWebTypeToPostgresType( column.webType, column.length ); let definition = `"${column.name}" ${postgresType}`; if (!column.nullable) { definition += " NOT NULL"; } if (column.defaultValue) { definition += ` DEFAULT '${column.defaultValue}'`; } return `ALTER TABLE "${tableName}" ADD COLUMN ${definition};`; } /** * 웹타입을 PostgreSQL 타입으로 매핑 */ private mapWebTypeToPostgresType(webType: WebType, length?: number): string { const mapping = WEB_TYPE_TO_POSTGRES_MAP[webType]; if (!mapping) { logger.warn(`알 수 없는 웹타입: ${webType}, text로 대체`); return "text"; } if (mapping.supportsLength && length && length > 0) { if (mapping.postgresType === "varchar") { return `varchar(${length})`; } } return mapping.postgresType; } /** * 테이블 메타데이터 저장 */ private async saveTableMetadata( tx: any, tableName: string, description?: string ): Promise { await tx.table_labels.upsert({ where: { table_name: tableName }, update: { table_label: tableName, description: description || `사용자 생성 테이블: ${tableName}`, updated_date: new Date(), }, create: { table_name: tableName, table_label: tableName, description: description || `사용자 생성 테이블: ${tableName}`, created_date: new Date(), updated_date: new Date(), }, }); } /** * 컬럼 메타데이터 저장 */ private async saveColumnMetadata( tx: any, tableName: string, columns: CreateColumnDefinition[] ): Promise { // 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성 await tx.table_labels.upsert({ where: { table_name: tableName, }, update: { updated_date: new Date(), }, create: { table_name: tableName, table_label: tableName, description: `자동 생성된 테이블 메타데이터: ${tableName}`, created_date: new Date(), updated_date: new Date(), }, }); for (const column of columns) { await tx.column_labels.upsert({ where: { table_name_column_name: { table_name: tableName, column_name: column.name, }, }, update: { column_label: column.label || column.name, web_type: column.webType, detail_settings: JSON.stringify(column.detailSettings || {}), description: column.description, display_order: column.order || 0, is_visible: true, updated_date: new Date(), }, create: { table_name: tableName, column_name: column.name, column_label: column.label || column.name, web_type: column.webType, detail_settings: JSON.stringify(column.detailSettings || {}), description: column.description, display_order: column.order || 0, is_visible: true, created_date: new Date(), updated_date: new Date(), }, }); } } /** * 권한 검증 (슈퍼관리자 확인) */ private validateSuperAdminPermission(userCompanyCode: string): void { if (userCompanyCode !== "*") { throw new Error("최고 관리자 권한이 필요합니다."); } } /** * 테이블 존재 여부 확인 */ private async checkTableExists(tableName: string): Promise { try { const result = await prisma.$queryRawUnsafe( ` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 ); `, tableName ); return (result as any)[0]?.exists || false; } catch (error) { logger.error("테이블 존재 확인 오류:", error); return false; } } /** * 컬럼 존재 여부 확인 */ private async checkColumnExists( tableName: string, columnName: string ): Promise { try { const result = await prisma.$queryRawUnsafe( ` SELECT EXISTS ( SELECT FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 ); `, tableName, columnName ); return (result as any)[0]?.exists || false; } catch (error) { logger.error("컬럼 존재 확인 오류:", error); return false; } } /** * 생성된 테이블 정보 조회 */ async getCreatedTableInfo(tableName: string): Promise<{ tableInfo: any; columns: any[]; } | null> { try { // 테이블 정보 조회 const tableInfo = await prisma.table_labels.findUnique({ where: { table_name: tableName }, }); // 컬럼 정보 조회 const columns = await prisma.column_labels.findMany({ where: { table_name: tableName }, orderBy: { display_order: "asc" }, }); if (!tableInfo) { return null; } return { tableInfo, columns, }; } catch (error) { logger.error("생성된 테이블 정보 조회 실패:", error); return null; } } /** * 테이블 관련 캐시 무효화 * 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); } } }