테이블 및 컬럼 생성기능 추가

This commit is contained in:
kjs
2025-09-22 17:00:59 +09:00
parent 0258c2a76c
commit dfda1bcc24
16 changed files with 5625 additions and 5 deletions

View File

@@ -0,0 +1,368 @@
/**
* DDL 실행 감사 로깅 서비스
* 모든 DDL 실행을 추적하고 기록
*/
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
export class DDLAuditLogger {
/**
* DDL 실행 로그 기록
*/
static async logDDLExecution(
userId: string,
companyCode: string,
ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN",
tableName: string,
ddlQuery: string,
success: boolean,
error?: string,
additionalInfo?: Record<string, any>
): Promise<void> {
try {
// DDL 실행 로그 데이터베이스에 저장
const logEntry = await prisma.$executeRaw`
INSERT INTO ddl_execution_log (
user_id,
company_code,
ddl_type,
table_name,
ddl_query,
success,
error_message,
executed_at
) VALUES (
${userId},
${companyCode},
${ddlType},
${tableName},
${ddlQuery},
${success},
${error || null},
NOW()
)
`;
// 추가 로깅 (파일 로그)
const logData = {
userId,
companyCode,
ddlType,
tableName,
success,
queryLength: ddlQuery.length,
error: error || null,
additionalInfo: additionalInfo || null,
timestamp: new Date().toISOString(),
};
if (success) {
logger.info("DDL 실행 성공", logData);
} else {
logger.error("DDL 실행 실패", { ...logData, ddlQuery });
}
// 중요한 DDL 실행은 별도 알림 (필요시)
if (ddlType === "CREATE_TABLE" || ddlType === "DROP_TABLE") {
logger.warn("중요 DDL 실행", {
...logData,
severity: "HIGH",
action: "TABLE_STRUCTURE_CHANGE",
});
}
} catch (logError) {
// 로그 기록 실패는 시스템에 영향을 주지 않도록 처리
logger.error("DDL 실행 로그 기록 실패:", {
originalUserId: userId,
originalDdlType: ddlType,
originalTableName: tableName,
originalSuccess: success,
logError: logError,
});
// 로그 기록 실패를 파일 로그로라도 남김
console.error("CRITICAL: DDL 로그 기록 실패", {
userId,
ddlType,
tableName,
success,
logError,
timestamp: new Date().toISOString(),
});
}
}
/**
* DDL 실행 시작 로그
*/
static async logDDLStart(
userId: string,
companyCode: string,
ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN",
tableName: string,
requestData: any
): Promise<void> {
logger.info("DDL 실행 시작", {
userId,
companyCode,
ddlType,
tableName,
requestData,
timestamp: new Date().toISOString(),
});
}
/**
* 최근 DDL 실행 로그 조회
*/
static async getRecentDDLLogs(
limit: number = 50,
userId?: string,
ddlType?: string
): Promise<any[]> {
try {
let whereClause = "WHERE 1=1";
const params: any[] = [];
if (userId) {
whereClause += " AND user_id = $" + (params.length + 1);
params.push(userId);
}
if (ddlType) {
whereClause += " AND ddl_type = $" + (params.length + 1);
params.push(ddlType);
}
const query = `
SELECT
id,
user_id,
company_code,
ddl_type,
table_name,
success,
error_message,
executed_at,
CASE
WHEN LENGTH(ddl_query) > 100 THEN SUBSTRING(ddl_query, 1, 100) || '...'
ELSE ddl_query
END as ddl_query_preview
FROM ddl_execution_log
${whereClause}
ORDER BY executed_at DESC
LIMIT $${params.length + 1}
`;
params.push(limit);
const logs = await prisma.$queryRawUnsafe(query, ...params);
return logs as any[];
} catch (error) {
logger.error("DDL 로그 조회 실패:", error);
return [];
}
}
/**
* DDL 실행 통계 조회
*/
static async getDDLStatistics(
fromDate?: Date,
toDate?: Date
): Promise<{
totalExecutions: number;
successfulExecutions: number;
failedExecutions: number;
byDDLType: Record<string, number>;
byUser: Record<string, number>;
recentFailures: any[];
}> {
try {
let dateFilter = "";
const params: any[] = [];
if (fromDate) {
dateFilter += " AND executed_at >= $" + (params.length + 1);
params.push(fromDate);
}
if (toDate) {
dateFilter += " AND executed_at <= $" + (params.length + 1);
params.push(toDate);
}
// 전체 통계
const totalStats = (await prisma.$queryRawUnsafe(
`
SELECT
COUNT(*) as total_executions,
SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions,
SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions
FROM ddl_execution_log
WHERE 1=1 ${dateFilter}
`,
...params
)) as any[];
// DDL 타입별 통계
const ddlTypeStats = (await prisma.$queryRawUnsafe(
`
SELECT ddl_type, COUNT(*) as count
FROM ddl_execution_log
WHERE 1=1 ${dateFilter}
GROUP BY ddl_type
ORDER BY count DESC
`,
...params
)) as any[];
// 사용자별 통계
const userStats = (await prisma.$queryRawUnsafe(
`
SELECT user_id, COUNT(*) as count
FROM ddl_execution_log
WHERE 1=1 ${dateFilter}
GROUP BY user_id
ORDER BY count DESC
LIMIT 10
`,
...params
)) as any[];
// 최근 실패 로그
const recentFailures = (await prisma.$queryRawUnsafe(
`
SELECT
user_id,
ddl_type,
table_name,
error_message,
executed_at
FROM ddl_execution_log
WHERE success = false ${dateFilter}
ORDER BY executed_at DESC
LIMIT 10
`,
...params
)) as any[];
const stats = totalStats[0];
return {
totalExecutions: parseInt(stats.total_executions) || 0,
successfulExecutions: parseInt(stats.successful_executions) || 0,
failedExecutions: parseInt(stats.failed_executions) || 0,
byDDLType: ddlTypeStats.reduce((acc, row) => {
acc[row.ddl_type] = parseInt(row.count);
return acc;
}, {}),
byUser: userStats.reduce((acc, row) => {
acc[row.user_id] = parseInt(row.count);
return acc;
}, {}),
recentFailures,
};
} catch (error) {
logger.error("DDL 통계 조회 실패:", error);
return {
totalExecutions: 0,
successfulExecutions: 0,
failedExecutions: 0,
byDDLType: {},
byUser: {},
recentFailures: [],
};
}
}
/**
* 특정 테이블의 DDL 히스토리 조회
*/
static async getTableDDLHistory(tableName: string): Promise<any[]> {
try {
const history = await prisma.$queryRawUnsafe(
`
SELECT
id,
user_id,
ddl_type,
ddl_query,
success,
error_message,
executed_at
FROM ddl_execution_log
WHERE table_name = $1
ORDER BY executed_at DESC
LIMIT 20
`,
tableName
);
return history as any[];
} catch (error) {
logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error);
return [];
}
}
/**
* DDL 로그 정리 (오래된 로그 삭제)
*/
static async cleanupOldLogs(retentionDays: number = 90): Promise<number> {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const result = await prisma.$executeRaw`
DELETE FROM ddl_execution_log
WHERE executed_at < ${cutoffDate}
`;
logger.info(`DDL 로그 정리 완료: ${result}개 레코드 삭제`, {
retentionDays,
cutoffDate: cutoffDate.toISOString(),
});
return result as number;
} catch (error) {
logger.error("DDL 로그 정리 실패:", error);
return 0;
}
}
/**
* 긴급 상황 로그 (시스템 테이블 접근 시도 등)
*/
static async logSecurityAlert(
userId: string,
companyCode: string,
alertType:
| "SYSTEM_TABLE_ACCESS"
| "INVALID_PERMISSION"
| "SUSPICIOUS_ACTIVITY",
details: string,
requestData?: any
): Promise<void> {
try {
// 보안 알림은 별도의 고급 로깅
logger.error("DDL 보안 알림", {
alertType,
userId,
companyCode,
details,
requestData,
severity: "CRITICAL",
timestamp: new Date().toISOString(),
});
// 필요시 외부 알림 시스템 연동 (이메일, 슬랙 등)
// await sendSecurityAlert(alertType, userId, details);
} catch (error) {
logger.error("보안 알림 기록 실패:", error);
}
}
}

View File

@@ -0,0 +1,625 @@
/**
* 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<DDLExecutionResult> {
// 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<DDLExecutionResult> {
// 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<void> {
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<void> {
// 먼저 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<boolean> {
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<boolean> {
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);
}
}
}

View File

@@ -0,0 +1,390 @@
/**
* 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
)
) {
warnings.push(
`${prefix}${column.webType} 타입에서는 길이 설정이 무시됩니다.`
);
} 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<string>();
const duplicates = new Set<string>();
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,
};
}
}