From 74d287daa9744547a4080fd9a0cb7aae8835b9ac Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 15:08:41 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EB=A1=9C=EA=B7=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/tableManagementController.ts | 265 ++++++ .../src/routes/tableManagementRoutes.ts | 32 + .../src/services/tableManagementService.ts | 406 +++++++++ frontend/app/(main)/admin/tableMng/page.tsx | 31 +- .../components/admin/CreateTableModal.tsx | 48 +- frontend/components/admin/TableLogViewer.tsx | 261 ++++++ frontend/lib/api/tableManagement.ts | 108 +++ 테이블_변경_이력_로그_시스템_구현_계획서.md | 773 ++++++++++++++++++ 8 files changed, 1918 insertions(+), 6 deletions(-) create mode 100644 frontend/components/admin/TableLogViewer.tsx create mode 100644 테이블_변경_이력_로그_시스템_구현_계획서.md diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index aac86625..d7b2bd74 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1048,3 +1048,268 @@ export async function updateColumnWebType( res.status(500).json(response); } } + +// ======================================== +// 🎯 테이블 로그 시스템 API +// ======================================== + +/** + * 로그 테이블 생성 + */ +export async function createLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { pkColumn } = req.body; + const userId = req.user?.userId; + + logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) { + const response: ApiResponse = { + success: false, + message: "PK 컬럼 정보가 필요합니다.", + error: { + code: "MISSING_PK_COLUMN", + details: "PK 컬럼명과 데이터 타입이 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + await tableManagementService.createLogTable(tableName, pkColumn, userId); + + logger.info(`로그 테이블 생성 완료: ${tableName}_log`); + + const response: ApiResponse = { + success: true, + message: "로그 테이블이 성공적으로 생성되었습니다.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 테이블 생성 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 테이블 생성 중 오류가 발생했습니다.", + error: { + code: "LOG_TABLE_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 로그 설정 조회 + */ +export async function getLogConfig( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + + logger.info(`=== 로그 설정 조회: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const logConfig = await tableManagementService.getLogConfig(tableName); + + const response: ApiResponse = { + success: true, + message: "로그 설정을 조회했습니다.", + data: logConfig, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 설정 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 설정 조회 중 오류가 발생했습니다.", + error: { + code: "LOG_CONFIG_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 로그 데이터 조회 + */ +export async function getLogData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { + page = 1, + size = 20, + operationType, + startDate, + endDate, + changedBy, + originalId, + } = req.query; + + logger.info(`=== 로그 데이터 조회: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const result = await tableManagementService.getLogData(tableName, { + page: parseInt(page as string), + size: parseInt(size as string), + operationType: operationType as string, + startDate: startDate as string, + endDate: endDate as string, + changedBy: changedBy as string, + originalId: originalId as string, + }); + + logger.info( + `로그 데이터 조회 완료: ${tableName}_log, ${result.total}건` + ); + + const response: ApiResponse = { + success: true, + message: "로그 데이터를 조회했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 데이터 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 데이터 조회 중 오류가 발생했습니다.", + error: { + code: "LOG_DATA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 로그 테이블 활성화/비활성화 + */ +export async function toggleLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { isActive } = req.body; + + logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "MISSING_TABLE_NAME", + details: "테이블명 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (isActive === undefined || isActive === null) { + const response: ApiResponse = { + success: false, + message: "isActive 값이 필요합니다.", + error: { + code: "MISSING_IS_ACTIVE", + details: "isActive 파라미터가 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + await tableManagementService.toggleLogTable( + tableName, + isActive === "Y" || isActive === true + ); + + logger.info( + `로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}` + ); + + const response: ApiResponse = { + success: true, + message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("로그 테이블 토글 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "로그 테이블 토글 중 오류가 발생했습니다.", + error: { + code: "LOG_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index c0b35b94..5e5ddf38 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -18,6 +18,10 @@ import { checkTableExists, getColumnWebTypes, checkDatabaseConnection, + createLogTable, + getLogConfig, + getLogData, + toggleLogTable, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData); */ router.delete("/tables/:tableName/delete", deleteTableData); +// ======================================== +// 테이블 로그 시스템 API +// ======================================== + +/** + * 로그 테이블 생성 + * POST /api/table-management/tables/:tableName/log + */ +router.post("/tables/:tableName/log", createLogTable); + +/** + * 로그 설정 조회 + * GET /api/table-management/tables/:tableName/log/config + */ +router.get("/tables/:tableName/log/config", getLogConfig); + +/** + * 로그 데이터 조회 + * GET /api/table-management/tables/:tableName/log + */ +router.get("/tables/:tableName/log", getLogData); + +/** + * 로그 테이블 활성화/비활성화 + * POST /api/table-management/tables/:tableName/log/toggle + */ +router.post("/tables/:tableName/log/toggle", toggleLogTable); + export default router; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 83f3a696..10de1e73 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3118,4 +3118,410 @@ export class TableManagementService { // 기본값 return "text"; } + + // ======================================== + // 🎯 테이블 로그 시스템 + // ======================================== + + /** + * 로그 테이블 생성 + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + userId?: string + ): Promise { + try { + const logTableName = `${tableName}_log`; + const triggerFuncName = `${tableName}_log_trigger_func`; + const triggerName = `${tableName}_audit_trigger`; + + logger.info(`로그 테이블 생성 시작: ${logTableName}`); + + // 로그 테이블 DDL 생성 + const logTableDDL = this.generateLogTableDDL( + logTableName, + tableName, + pkColumn.columnName, + pkColumn.dataType + ); + + // 트리거 함수 DDL 생성 + const triggerFuncDDL = this.generateTriggerFunctionDDL( + triggerFuncName, + logTableName, + tableName, + pkColumn.columnName + ); + + // 트리거 DDL 생성 + const triggerDDL = this.generateTriggerDDL( + triggerName, + tableName, + triggerFuncName + ); + + // 트랜잭션으로 실행 + await transaction(async (client) => { + // 1. 로그 테이블 생성 + await client.query(logTableDDL); + logger.info(`로그 테이블 생성 완료: ${logTableName}`); + + // 2. 트리거 함수 생성 + await client.query(triggerFuncDDL); + logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`); + + // 3. 트리거 생성 + await client.query(triggerDDL); + logger.info(`트리거 생성 완료: ${triggerName}`); + + // 4. 로그 설정 저장 + await client.query( + `INSERT INTO table_log_config ( + original_table_name, log_table_name, trigger_name, + trigger_function_name, created_by + ) VALUES ($1, $2, $3, $4, $5)`, + [tableName, logTableName, triggerName, triggerFuncName, userId] + ); + logger.info(`로그 설정 저장 완료: ${tableName}`); + }); + + logger.info(`로그 테이블 생성 완료: ${logTableName}`); + } catch (error) { + logger.error(`로그 테이블 생성 실패: ${tableName}`, error); + throw new Error( + `로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * 로그 테이블 DDL 생성 + */ + private generateLogTableDDL( + logTableName: string, + originalTableName: string, + pkColumnName: string, + pkDataType: string + ): string { + return ` + CREATE TABLE ${logTableName} ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, + original_id VARCHAR(100), + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(50), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + full_row_before JSONB, + full_row_after JSONB + ); + + CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id); + CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at); + CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type); + + COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력'; + COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)'; + COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값'; + COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명'; + COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값'; + COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값'; + COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID'; + COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각'; + COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP'; + COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)'; + COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)'; + `; + } + + /** + * 트리거 함수 DDL 생성 + */ + private generateTriggerFunctionDDL( + funcName: string, + logTableName: string, + originalTableName: string, + pkColumnName: string + ): string { + return ` + CREATE OR REPLACE FUNCTION ${funcName}() + RETURNS TRIGGER AS $$ + DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); + BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after) + VALUES ($1, ($2).%I, $3, $4, $5)', + '${pkColumnName}' + ) + USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb; + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = '${originalTableName}' + AND table_schema = 'public' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after) + VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)', + '${pkColumnName}' + ) + USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb; + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + EXECUTE format( + 'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before) + VALUES ($1, ($2).%I, $3, $4, $5)', + '${pkColumnName}' + ) + USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb; + RETURN OLD; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + `; + } + + /** + * 트리거 DDL 생성 + */ + private generateTriggerDDL( + triggerName: string, + tableName: string, + funcName: string + ): string { + return ` + CREATE TRIGGER ${triggerName} + AFTER INSERT OR UPDATE OR DELETE ON ${tableName} + FOR EACH ROW EXECUTE FUNCTION ${funcName}(); + `; + } + + /** + * 로그 설정 조회 + */ + async getLogConfig(tableName: string): Promise<{ + originalTableName: string; + logTableName: string; + triggerName: string; + triggerFunctionName: string; + isActive: string; + createdAt: Date; + createdBy: string; + } | null> { + try { + logger.info(`로그 설정 조회: ${tableName}`); + + const result = await queryOne<{ + original_table_name: string; + log_table_name: string; + trigger_name: string; + trigger_function_name: string; + is_active: string; + created_at: Date; + created_by: string; + }>( + `SELECT + original_table_name, log_table_name, trigger_name, + trigger_function_name, is_active, created_at, created_by + FROM table_log_config + WHERE original_table_name = $1`, + [tableName] + ); + + if (!result) { + return null; + } + + return { + originalTableName: result.original_table_name, + logTableName: result.log_table_name, + triggerName: result.trigger_name, + triggerFunctionName: result.trigger_function_name, + isActive: result.is_active, + createdAt: result.created_at, + createdBy: result.created_by, + }; + } catch (error) { + logger.error(`로그 설정 조회 실패: ${tableName}`, error); + throw error; + } + } + + /** + * 로그 데이터 조회 + */ + async getLogData( + tableName: string, + options: { + page: number; + size: number; + operationType?: string; + startDate?: string; + endDate?: string; + changedBy?: string; + originalId?: string; + } + ): Promise<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> { + try { + const logTableName = `${tableName}_log`; + const offset = (options.page - 1) * options.size; + + logger.info(`로그 데이터 조회: ${logTableName}`, options); + + // WHERE 조건 구성 + const whereConditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (options.operationType) { + whereConditions.push(`operation_type = $${paramIndex}`); + values.push(options.operationType); + paramIndex++; + } + + if (options.startDate) { + whereConditions.push(`changed_at >= $${paramIndex}::timestamp`); + values.push(options.startDate); + paramIndex++; + } + + if (options.endDate) { + whereConditions.push(`changed_at <= $${paramIndex}::timestamp`); + values.push(options.endDate); + paramIndex++; + } + + if (options.changedBy) { + whereConditions.push(`changed_by = $${paramIndex}`); + values.push(options.changedBy); + paramIndex++; + } + + if (options.originalId) { + whereConditions.push(`original_id::text = $${paramIndex}`); + values.push(options.originalId); + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`; + const countResult = await query(countQuery, values); + const total = parseInt(countResult[0].count); + + // 데이터 조회 + const dataQuery = ` + SELECT * FROM ${logTableName} + ${whereClause} + ORDER BY changed_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + const data = await query(dataQuery, [ + ...values, + options.size, + offset, + ]); + + const totalPages = Math.ceil(total / options.size); + + logger.info( + `로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환` + ); + + return { + data, + total, + page: options.page, + size: options.size, + totalPages, + }; + } catch (error) { + logger.error(`로그 데이터 조회 실패: ${tableName}`, error); + throw error; + } + } + + /** + * 로그 테이블 활성화/비활성화 + */ + async toggleLogTable(tableName: string, isActive: boolean): Promise { + try { + const logConfig = await this.getLogConfig(tableName); + if (!logConfig) { + throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`); + } + + logger.info( + `로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}` + ); + + await transaction(async (client) => { + // 트리거 활성화/비활성화 + if (isActive) { + await client.query( + `ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}` + ); + } else { + await client.query( + `ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}` + ); + } + + // 설정 업데이트 + await client.query( + `UPDATE table_log_config + SET is_active = $1, updated_at = NOW() + WHERE original_table_name = $2`, + [isActive ? "Y" : "N", tableName] + ); + }); + + logger.info( + `로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}` + ); + } catch (error) { + logger.error( + `로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`, + error + ); + throw error; + } + } } diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index e415fec8..74fd30af 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -20,6 +20,7 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; +import { TableLogViewer } from "@/components/admin/TableLogViewer"; // 가상화 스크롤링을 위한 간단한 구현 interface TableInfo { @@ -76,6 +77,10 @@ export default function TableManagementPage() { const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); + // 로그 뷰어 상태 + const [logViewerOpen, setLogViewerOpen] = useState(false); + const [logViewerTableName, setLogViewerTableName] = useState(""); + // 최고 관리자 여부 확인 (회사코드가 "*"인 경우) const isSuperAdmin = user?.companyCode === "*"; @@ -645,15 +650,30 @@ export default function TableManagementPage() { onClick={() => handleTableSelect(table.tableName)} >
-
+

{table.displayName || table.tableName}

{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}

- - {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")} - +
+ + {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")} + + +
)) @@ -972,6 +992,9 @@ export default function TableManagementPage() { /> setDdlLogViewerOpen(false)} /> + + {/* 테이블 로그 뷰어 */} + )} diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index 7e075ad1..c31482dc 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -19,10 +19,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react"; import { toast } from "sonner"; import { ColumnDefinitionTable } from "./ColumnDefinitionTable"; import { ddlApi } from "../../lib/api/ddl"; +import { tableManagementApi } from "../../lib/api/tableManagement"; import { CreateTableModalProps, CreateColumnDefinition, @@ -47,6 +49,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa const [validating, setValidating] = useState(false); const [tableNameError, setTableNameError] = useState(""); const [validationResult, setValidationResult] = useState(null); + const [useLogTable, setUseLogTable] = useState(false); /** * 모달 리셋 @@ -65,6 +68,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa ]); setTableNameError(""); setValidationResult(null); + setUseLogTable(false); }; /** @@ -204,6 +208,23 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa if (result.success) { toast.success(result.message); + + // 로그 테이블 생성 옵션이 선택되었다면 로그 테이블 생성 + if (useLogTable) { + try { + const pkColumn = { columnName: "id", dataType: "integer" }; + const logResult = await tableManagementApi.createLogTable(tableName, pkColumn); + + if (logResult.success) { + toast.success(`${tableName}_log 테이블이 생성되었습니다.`); + } else { + toast.warning(`테이블은 생성되었으나 로그 테이블 생성 실패: ${logResult.message}`); + } + } catch (logError) { + toast.warning("테이블은 생성되었으나 로그 테이블 생성 중 오류가 발생했습니다."); + } + } + onSuccess(result); onClose(); } else { @@ -248,7 +269,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa placeholder="예: customer_info" className={tableNameError ? "border-red-300" : ""} /> - {tableNameError &&

{tableNameError}

} + {tableNameError &&

{tableNameError}

}

영문자로 시작, 영문자/숫자/언더스코어만 사용 가능

@@ -278,6 +299,29 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa + {/* 로그 테이블 생성 옵션 */} +
+ setUseLogTable(checked as boolean)} + disabled={loading} + /> +
+ +

+ 선택 시 {tableName || "table"}_log 테이블이 + 자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다. +

+
+
+ {/* 자동 추가 컬럼 안내 */} diff --git a/frontend/components/admin/TableLogViewer.tsx b/frontend/components/admin/TableLogViewer.tsx new file mode 100644 index 00000000..6b899bf6 --- /dev/null +++ b/frontend/components/admin/TableLogViewer.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { toast } from "sonner"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { History, RefreshCw, Filter, X } from "lucide-react"; + +interface TableLogViewerProps { + tableName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface LogData { + log_id: number; + operation_type: string; + original_id: string; + changed_column?: string; + old_value?: string; + new_value?: string; + changed_by?: string; + changed_at: string; + ip_address?: string; +} + +export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewerProps) { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + const [totalPages, setTotalPages] = useState(0); + + // 필터 상태 + const [operationType, setOperationType] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [changedBy, setChangedBy] = useState(""); + const [originalId, setOriginalId] = useState(""); + + // 로그 데이터 로드 + const loadLogs = async () => { + if (!tableName) return; + + setLoading(true); + try { + const response = await tableManagementApi.getLogData(tableName, { + page, + size: pageSize, + operationType: operationType || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + changedBy: changedBy || undefined, + originalId: originalId || undefined, + }); + + if (response.success && response.data) { + setLogs(response.data.data); + setTotal(response.data.total); + setTotalPages(response.data.totalPages); + } else { + toast.error(response.message || "로그 데이터를 불러올 수 없습니다."); + } + } catch (error) { + toast.error("로그 데이터 조회 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + // 다이얼로그가 열릴 때 로그 로드 + useEffect(() => { + if (open && tableName) { + loadLogs(); + } + }, [open, tableName, page]); + + // 필터 초기화 + const resetFilters = () => { + setOperationType(""); + setStartDate(""); + setEndDate(""); + setChangedBy(""); + setOriginalId(""); + setPage(1); + }; + + // 작업 타입에 따른 뱃지 색상 + const getOperationBadge = (type: string) => { + switch (type) { + case "INSERT": + return 추가; + case "UPDATE": + return 수정; + case "DELETE": + return 삭제; + default: + return {type}; + } + }; + + // 날짜 포맷팅 + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }; + + return ( + + + + + + {tableName} - 변경 이력 + + + + {/* 필터 영역 */} +
+
+

+ + 필터 +

+ +
+ +
+
+ + +
+ +
+ + setStartDate(e.target.value)} /> +
+ +
+ + setEndDate(e.target.value)} /> +
+ +
+ + setChangedBy(e.target.value)} /> +
+ +
+ + setOriginalId(e.target.value)} /> +
+ +
+ +
+
+
+ + {/* 로그 테이블 */} +
+ {loading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
변경 이력이 없습니다.
+ ) : ( + + + + 작업 + 원본 ID + 변경 컬럼 + 변경 전 + 변경 후 + 변경자 + 변경 시각 + IP + + + + {logs.map((log) => ( + + {getOperationBadge(log.operation_type)} + {log.original_id} + {log.changed_column || "-"} + + {log.old_value || "-"} + + + {log.new_value || "-"} + + {log.changed_by || "system"} + {formatDate(log.changed_at)} + {log.ip_address || "-"} + + ))} + +
+ )} +
+ + {/* 페이지네이션 */} +
+
+ 전체 {total}건 (페이지 {page} / {totalPages}) +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 5dc1cc0a..6a8363ba 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -211,6 +211,114 @@ class TableManagementApi { }; } } + + // ======================================== + // 테이블 로그 시스템 API + // ======================================== + + /** + * 로그 테이블 생성 + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + ): Promise> { + try { + const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log`, { pkColumn }); + return response.data; + } catch (error: any) { + console.error(`❌ 로그 테이블 생성 실패: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "로그 테이블을 생성할 수 없습니다.", + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 로그 설정 조회 + */ + async getLogConfig(tableName: string): Promise< + ApiResponse<{ + originalTableName: string; + logTableName: string; + triggerName: string; + triggerFunctionName: string; + isActive: string; + createdAt: Date; + createdBy: string; + } | null> + > { + try { + const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log/config`); + return response.data; + } catch (error: any) { + console.error(`❌ 로그 설정 조회 실패: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "로그 설정을 조회할 수 없습니다.", + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 로그 데이터 조회 + */ + async getLogData( + tableName: string, + options: { + page?: number; + size?: number; + operationType?: string; + startDate?: string; + endDate?: string; + changedBy?: string; + originalId?: string; + } = {}, + ): Promise< + ApiResponse<{ + data: any[]; + total: number; + page: number; + size: number; + totalPages: number; + }> + > { + try { + const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log`, { + params: options, + }); + return response.data; + } catch (error: any) { + console.error(`❌ 로그 데이터 조회 실패: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "로그 데이터를 조회할 수 없습니다.", + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 로그 테이블 활성화/비활성화 + */ + async toggleLogTable(tableName: string, isActive: boolean): Promise> { + try { + const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log/toggle`, { + isActive, + }); + return response.data; + } catch (error: any) { + console.error(`❌ 로그 테이블 토글 실패: ${tableName}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "로그 테이블 설정을 변경할 수 없습니다.", + errorCode: error.response?.data?.errorCode, + }; + } + } } // 싱글톤 인스턴스 생성 diff --git a/테이블_변경_이력_로그_시스템_구현_계획서.md b/테이블_변경_이력_로그_시스템_구현_계획서.md new file mode 100644 index 00000000..e7f43773 --- /dev/null +++ b/테이블_변경_이력_로그_시스템_구현_계획서.md @@ -0,0 +1,773 @@ +# 테이블 변경 이력 로그 시스템 구현 계획서 + +## 1. 개요 + +테이블 생성 시 해당 테이블의 변경 이력을 자동으로 기록하는 로그 테이블 생성 기능을 추가합니다. +사용자가 테이블을 생성할 때 로그 테이블 생성 여부를 선택할 수 있으며, 선택 시 자동으로 로그 테이블과 트리거가 생성됩니다. + +## 2. 핵심 기능 + +### 2.1 로그 테이블 생성 옵션 + +- 테이블 생성 폼에 "변경 이력 로그 테이블 생성" 체크박스 추가 +- 체크 시 `{원본테이블명}_log` 형식의 로그 테이블 자동 생성 + +### 2.2 로그 테이블 스키마 구조 + +```sql +CREATE TABLE {table_name}_log ( + log_id SERIAL PRIMARY KEY, -- 로그 고유 ID + operation_type VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE + original_id {원본PK타입}, -- 원본 테이블의 PK 값 + changed_column VARCHAR(100), -- 변경된 컬럼명 (UPDATE 시) + old_value TEXT, -- 변경 전 값 + new_value TEXT, -- 변경 후 값 + changed_by VARCHAR(50), -- 변경한 사용자 ID + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각 + ip_address VARCHAR(50), -- 변경 요청 IP + user_agent TEXT, -- 변경 요청 User-Agent + full_row_before JSONB, -- 변경 전 전체 행 (JSON) + full_row_after JSONB -- 변경 후 전체 행 (JSON) +); + +CREATE INDEX idx_{table_name}_log_original_id ON {table_name}_log(original_id); +CREATE INDEX idx_{table_name}_log_changed_at ON {table_name}_log(changed_at); +CREATE INDEX idx_{table_name}_log_operation ON {table_name}_log(operation_type); +``` + +### 2.3 트리거 함수 생성 + +```sql +CREATE OR REPLACE FUNCTION {table_name}_log_trigger_func() +RETURNS TRIGGER AS $$ +DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); +BEGIN + -- 세션 변수에서 사용자 정보 가져오기 + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO {table_name}_log ( + operation_type, original_id, changed_by, ip_address, + full_row_after + ) VALUES ( + 'INSERT', NEW.{pk_column}, v_user_id, v_ip_address, + row_to_json(NEW)::jsonb + ); + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + -- 각 컬럼별로 변경사항 기록 + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = TG_TABLE_NAME + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', + v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO {table_name}_log ( + operation_type, original_id, changed_column, + old_value, new_value, changed_by, ip_address, + full_row_before, full_row_after + ) VALUES ( + 'UPDATE', NEW.{pk_column}, v_column_name, + v_old_value, v_new_value, v_user_id, v_ip_address, + row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb + ); + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO {table_name}_log ( + operation_type, original_id, changed_by, ip_address, + full_row_before + ) VALUES ( + 'DELETE', OLD.{pk_column}, v_user_id, v_ip_address, + row_to_json(OLD)::jsonb + ); + RETURN OLD; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +``` + +### 2.4 트리거 생성 + +```sql +CREATE TRIGGER {table_name}_audit_trigger +AFTER INSERT OR UPDATE OR DELETE ON {table_name} +FOR EACH ROW EXECUTE FUNCTION {table_name}_log_trigger_func(); +``` + +## 3. 데이터베이스 스키마 변경 + +### 3.1 table_type_mng 테이블 수정 + +```sql +ALTER TABLE table_type_mng +ADD COLUMN use_log_table VARCHAR(1) DEFAULT 'N'; + +COMMENT ON COLUMN table_type_mng.use_log_table IS '변경 이력 로그 테이블 사용 여부 (Y/N)'; +``` + +### 3.2 새로운 관리 테이블 추가 + +```sql +CREATE TABLE table_log_config ( + config_id SERIAL PRIMARY KEY, + original_table_name VARCHAR(100) NOT NULL, + log_table_name VARCHAR(100) NOT NULL, + trigger_name VARCHAR(100) NOT NULL, + trigger_function_name VARCHAR(100) NOT NULL, + is_active VARCHAR(1) DEFAULT 'Y', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(50), + UNIQUE(original_table_name) +); + +COMMENT ON TABLE table_log_config IS '테이블 로그 설정 관리'; +COMMENT ON COLUMN table_log_config.original_table_name IS '원본 테이블명'; +COMMENT ON COLUMN table_log_config.log_table_name IS '로그 테이블명'; +COMMENT ON COLUMN table_log_config.trigger_name IS '트리거명'; +COMMENT ON COLUMN table_log_config.trigger_function_name IS '트리거 함수명'; +COMMENT ON COLUMN table_log_config.is_active IS '활성 상태 (Y/N)'; +``` + +## 4. 백엔드 구현 + +### 4.1 Service Layer 수정 + +**파일**: `backend-node/src/services/admin/table-type-mng.service.ts` + +#### 4.1.1 로그 테이블 생성 로직 + +```typescript +/** + * 로그 테이블 생성 + */ +private async createLogTable( + tableName: string, + columns: any[], + connectionId?: number, + userId?: string +): Promise { + const logTableName = `${tableName}_log`; + const triggerFuncName = `${tableName}_log_trigger_func`; + const triggerName = `${tableName}_audit_trigger`; + + // PK 컬럼 찾기 + const pkColumn = columns.find(col => col.isPrimaryKey); + if (!pkColumn) { + throw new Error('PK 컬럼이 없으면 로그 테이블을 생성할 수 없습니다.'); + } + + // 로그 테이블 DDL 생성 + const logTableDDL = this.generateLogTableDDL( + logTableName, + pkColumn.COLUMN_NAME, + pkColumn.DATA_TYPE + ); + + // 트리거 함수 DDL 생성 + const triggerFuncDDL = this.generateTriggerFunctionDDL( + triggerFuncName, + logTableName, + tableName, + pkColumn.COLUMN_NAME + ); + + // 트리거 DDL 생성 + const triggerDDL = this.generateTriggerDDL( + triggerName, + tableName, + triggerFuncName + ); + + try { + // 1. 로그 테이블 생성 + await this.executeDDL(logTableDDL, connectionId); + + // 2. 트리거 함수 생성 + await this.executeDDL(triggerFuncDDL, connectionId); + + // 3. 트리거 생성 + await this.executeDDL(triggerDDL, connectionId); + + // 4. 로그 설정 저장 + await this.saveLogConfig({ + originalTableName: tableName, + logTableName, + triggerName, + triggerFunctionName: triggerFuncName, + createdBy: userId + }); + + console.log(`로그 테이블 생성 완료: ${logTableName}`); + } catch (error) { + console.error('로그 테이블 생성 실패:', error); + throw error; + } +} + +/** + * 로그 테이블 DDL 생성 + */ +private generateLogTableDDL( + logTableName: string, + pkColumnName: string, + pkDataType: string +): string { + return ` + CREATE TABLE ${logTableName} ( + log_id SERIAL PRIMARY KEY, + operation_type VARCHAR(10) NOT NULL, + original_id ${pkDataType}, + changed_column VARCHAR(100), + old_value TEXT, + new_value TEXT, + changed_by VARCHAR(50), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(50), + user_agent TEXT, + full_row_before JSONB, + full_row_after JSONB + ); + + CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id); + CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at); + CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type); + + COMMENT ON TABLE ${logTableName} IS '${logTableName.replace('_log', '')} 테이블 변경 이력'; + COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)'; + COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값'; + COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명'; + COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값'; + COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값'; + `; +} + +/** + * 트리거 함수 DDL 생성 + */ +private generateTriggerFunctionDDL( + funcName: string, + logTableName: string, + originalTableName: string, + pkColumnName: string +): string { + return ` + CREATE OR REPLACE FUNCTION ${funcName}() + RETURNS TRIGGER AS $$ + DECLARE + v_column_name TEXT; + v_old_value TEXT; + v_new_value TEXT; + v_user_id VARCHAR(50); + v_ip_address VARCHAR(50); + BEGIN + v_user_id := current_setting('app.user_id', TRUE); + v_ip_address := current_setting('app.ip_address', TRUE); + + IF (TG_OP = 'INSERT') THEN + INSERT INTO ${logTableName} ( + operation_type, original_id, changed_by, ip_address, full_row_after + ) VALUES ( + 'INSERT', NEW.${pkColumnName}, v_user_id, v_ip_address, row_to_json(NEW)::jsonb + ); + RETURN NEW; + + ELSIF (TG_OP = 'UPDATE') THEN + FOR v_column_name IN + SELECT column_name + FROM information_schema.columns + WHERE table_name = '${originalTableName}' + LOOP + EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name) + INTO v_old_value, v_new_value + USING OLD, NEW; + + IF v_old_value IS DISTINCT FROM v_new_value THEN + INSERT INTO ${logTableName} ( + operation_type, original_id, changed_column, old_value, new_value, + changed_by, ip_address, full_row_before, full_row_after + ) VALUES ( + 'UPDATE', NEW.${pkColumnName}, v_column_name, v_old_value, v_new_value, + v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb + ); + END IF; + END LOOP; + RETURN NEW; + + ELSIF (TG_OP = 'DELETE') THEN + INSERT INTO ${logTableName} ( + operation_type, original_id, changed_by, ip_address, full_row_before + ) VALUES ( + 'DELETE', OLD.${pkColumnName}, v_user_id, v_ip_address, row_to_json(OLD)::jsonb + ); + RETURN OLD; + END IF; + + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + `; +} + +/** + * 트리거 DDL 생성 + */ +private generateTriggerDDL( + triggerName: string, + tableName: string, + funcName: string +): string { + return ` + CREATE TRIGGER ${triggerName} + AFTER INSERT OR UPDATE OR DELETE ON ${tableName} + FOR EACH ROW EXECUTE FUNCTION ${funcName}(); + `; +} + +/** + * 로그 설정 저장 + */ +private async saveLogConfig(config: { + originalTableName: string; + logTableName: string; + triggerName: string; + triggerFunctionName: string; + createdBy?: string; +}): Promise { + const query = ` + INSERT INTO table_log_config ( + original_table_name, log_table_name, trigger_name, + trigger_function_name, created_by + ) VALUES ($1, $2, $3, $4, $5) + `; + + await this.executeQuery(query, [ + config.originalTableName, + config.logTableName, + config.triggerName, + config.triggerFunctionName, + config.createdBy + ]); +} +``` + +#### 4.1.2 테이블 생성 메서드 수정 + +```typescript +async createTable(params: { + tableName: string; + columns: any[]; + useLogTable?: boolean; // 추가 + connectionId?: number; + userId?: string; +}): Promise { + const { tableName, columns, useLogTable, connectionId, userId } = params; + + // 1. 원본 테이블 생성 + const ddl = this.generateCreateTableDDL(tableName, columns); + await this.executeDDL(ddl, connectionId); + + // 2. 로그 테이블 생성 (옵션) + if (useLogTable === true) { + await this.createLogTable(tableName, columns, connectionId, userId); + } + + // 3. 메타데이터 저장 + await this.saveTableMetadata({ + tableName, + columns, + useLogTable: useLogTable ? 'Y' : 'N', + connectionId, + userId + }); +} +``` + +### 4.2 Controller Layer 수정 + +**파일**: `backend-node/src/controllers/admin/table-type-mng.controller.ts` + +```typescript +/** + * 테이블 생성 + */ +async createTable(req: Request, res: Response): Promise { + try { + const { tableName, columns, useLogTable, connectionId } = req.body; + const userId = req.user?.userId; + + await this.tableTypeMngService.createTable({ + tableName, + columns, + useLogTable: useLogTable === 'Y' || useLogTable === true, + connectionId, + userId + }); + + res.json({ + success: true, + message: useLogTable + ? '테이블 및 로그 테이블이 생성되었습니다.' + : '테이블이 생성되었습니다.' + }); + } catch (error) { + console.error('테이블 생성 오류:', error); + res.status(500).json({ + success: false, + message: '테이블 생성 중 오류가 발생했습니다.' + }); + } +} +``` + +### 4.3 세션 변수 설정 미들웨어 + +**파일**: `backend-node/src/middleware/db-session.middleware.ts` + +```typescript +import { Request, Response, NextFunction } from "express"; + +/** + * DB 세션 변수 설정 미들웨어 + * 트리거에서 사용할 사용자 정보를 세션 변수에 설정 + */ +export const setDBSessionVariables = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const userId = req.user?.userId || "system"; + const ipAddress = req.ip || req.socket.remoteAddress || "unknown"; + + // PostgreSQL 세션 변수 설정 + const queries = [ + `SET app.user_id = '${userId}'`, + `SET app.ip_address = '${ipAddress}'`, + ]; + + // 각 DB 연결에 세션 변수 설정 + // (실제 구현은 DB 연결 풀 관리 방식에 따라 다름) + + next(); + } catch (error) { + console.error("DB 세션 변수 설정 오류:", error); + next(error); + } +}; +``` + +## 5. 프론트엔드 구현 + +### 5.1 테이블 생성 폼 수정 + +**파일**: `frontend/src/app/admin/tableMng/components/TableCreateForm.tsx` + +```typescript +const TableCreateForm = () => { + const [useLogTable, setUseLogTable] = useState(false); + + return ( +
+ {/* 기존 폼 필드들 */} + + {/* 로그 테이블 옵션 추가 */} +
+ +

+ 체크 시 데이터 변경 이력을 기록하는 로그 테이블이 자동으로 생성됩니다. + (테이블명: {tableName}_log) +

+
+ + {useLogTable && ( +
+

로그 테이블 정보

+
    +
  • • INSERT/UPDATE/DELETE 작업이 자동으로 기록됩니다
  • +
  • • 변경 전후 값과 변경자 정보가 저장됩니다
  • +
  • + • 로그는 별도 테이블에 저장되어 원본 테이블 성능에 영향을 + 최소화합니다 +
  • +
+
+ )} +
+ ); +}; +``` + +### 5.2 로그 조회 화면 추가 + +**파일**: `frontend/src/app/admin/tableMng/components/TableLogViewer.tsx` + +```typescript +interface TableLogViewerProps { + tableName: string; +} + +const TableLogViewer: React.FC = ({ tableName }) => { + const [logs, setLogs] = useState([]); + const [filters, setFilters] = useState({ + operationType: "", + startDate: "", + endDate: "", + changedBy: "", + }); + + const fetchLogs = async () => { + // 로그 데이터 조회 + const response = await fetch(`/api/admin/table-log/${tableName}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(filters), + }); + const data = await response.json(); + setLogs(data.logs); + }; + + return ( +
+

변경 이력 조회: {tableName}

+ + {/* 필터 */} +
+ + + {/* 날짜 필터, 사용자 필터 등 */} +
+ + {/* 로그 테이블 */} + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + ))} + +
작업유형원본ID변경컬럼변경전변경후변경자변경시각
{log.operation_type}{log.original_id}{log.changed_column}{log.old_value}{log.new_value}{log.changed_by}{log.changed_at}
+
+ ); +}; +``` + +## 6. API 엔드포인트 + +### 6.1 로그 조회 API + +``` +POST /api/admin/table-log/:tableName +Request Body: +{ + "operationType": "UPDATE", // 선택: INSERT, UPDATE, DELETE + "startDate": "2024-01-01", // 선택 + "endDate": "2024-12-31", // 선택 + "changedBy": "user123", // 선택 + "originalId": 123 // 선택 +} + +Response: +{ + "success": true, + "logs": [ + { + "log_id": 1, + "operation_type": "UPDATE", + "original_id": "123", + "changed_column": "user_name", + "old_value": "홍길동", + "new_value": "김철수", + "changed_by": "admin", + "changed_at": "2024-10-21T10:30:00Z", + "ip_address": "192.168.1.100" + } + ] +} +``` + +### 6.2 로그 테이블 활성화/비활성화 API + +``` +POST /api/admin/table-log/:tableName/toggle +Request Body: +{ + "isActive": "Y" // Y 또는 N +} + +Response: +{ + "success": true, + "message": "로그 기능이 활성화되었습니다." +} +``` + +## 7. 테스트 계획 + +### 7.1 단위 테스트 + +- [ ] 로그 테이블 DDL 생성 함수 테스트 +- [ ] 트리거 함수 DDL 생성 함수 테스트 +- [ ] 트리거 DDL 생성 함수 테스트 +- [ ] 로그 설정 저장 함수 테스트 + +### 7.2 통합 테스트 + +- [ ] 테이블 생성 시 로그 테이블 자동 생성 테스트 +- [ ] INSERT 작업 시 로그 기록 테스트 +- [ ] UPDATE 작업 시 로그 기록 테스트 +- [ ] DELETE 작업 시 로그 기록 테스트 +- [ ] 여러 컬럼 동시 변경 시 로그 기록 테스트 + +### 7.3 성능 테스트 + +- [ ] 대량 데이터 INSERT 시 성능 영향 측정 +- [ ] 대량 데이터 UPDATE 시 성능 영향 측정 +- [ ] 로그 테이블 크기 증가에 따른 성능 영향 측정 + +## 8. 주의사항 및 제약사항 + +### 8.1 성능 고려사항 + +- 트리거는 모든 변경 작업에 대해 실행되므로 성능 영향이 있을 수 있음 +- 대량 데이터 처리 시 로그 테이블 크기가 급격히 증가할 수 있음 +- 로그 테이블에 적절한 인덱스 설정 필요 + +### 8.2 운영 고려사항 + +- 로그 데이터의 보관 주기 정책 수립 필요 +- 오래된 로그 데이터 아카이빙 전략 필요 +- 로그 테이블의 정기적인 파티셔닝 고려 + +### 8.3 보안 고려사항 + +- 로그 데이터에는 민감한 정보가 포함될 수 있으므로 접근 권한 관리 필요 +- 로그 데이터 자체의 무결성 보장 필요 +- 로그 데이터의 암호화 저장 고려 + +## 9. 향후 확장 계획 + +### 9.1 로그 분석 기능 + +- 변경 패턴 분석 +- 사용자별 변경 통계 +- 시간대별 변경 추이 + +### 9.2 로그 알림 기능 + +- 특정 테이블/컬럼 변경 시 알림 +- 비정상적인 대량 변경 감지 +- 특정 사용자의 변경 작업 모니터링 + +### 9.3 로그 복원 기능 + +- 특정 시점으로 데이터 롤백 +- 변경 이력 기반 데이터 복구 +- 변경 이력 시각화 + +## 10. 마이그레이션 가이드 + +### 10.1 기존 테이블에 로그 기능 추가 + +```typescript +// 기존 테이블에 로그 테이블 추가하는 API +POST /api/admin/table-log/:tableName/enable + +// 실행 순서: +// 1. 로그 테이블 생성 +// 2. 트리거 함수 생성 +// 3. 트리거 생성 +// 4. 로그 설정 저장 +``` + +### 10.2 로그 기능 제거 + +```typescript +// 로그 기능 제거 API +POST /api/admin/table-log/:tableName/disable + +// 실행 순서: +// 1. 트리거 삭제 +// 2. 트리거 함수 삭제 +// 3. 로그 테이블 삭제 (선택) +// 4. 로그 설정 비활성화 +``` + +## 11. 개발 우선순위 + +### Phase 1: 기본 기능 (필수) + +1. DB 스키마 변경 (table_type_mng, table_log_config) +2. 로그 테이블 DDL 생성 로직 +3. 트리거 함수/트리거 DDL 생성 로직 +4. 테이블 생성 시 로그 테이블 자동 생성 + +### Phase 2: UI 개발 + +1. 테이블 생성 폼에 로그 옵션 추가 +2. 로그 조회 화면 개발 +3. 로그 필터링 기능 + +### Phase 3: 고급 기능 + +1. 로그 활성화/비활성화 기능 +2. 기존 테이블에 로그 추가 기능 +3. 로그 데이터 아카이빙 기능 + +### Phase 4: 분석 및 최적화 + +1. 로그 분석 대시보드 +2. 성능 최적화 +3. 로그 데이터 파티셔닝