Merge pull request '테이블 이력 변경 로그 시스템' (#122) from feat/tableTypeMngHistFeat into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/122
This commit is contained in:
commit
eadff1a051
|
|
@ -36,10 +36,18 @@ export const saveFormData = async (
|
|||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// 클라이언트 IP 주소 추출
|
||||
const ipAddress =
|
||||
req.ip ||
|
||||
(req.headers["x-forwarded-for"] as string) ||
|
||||
req.socket.remoteAddress ||
|
||||
"unknown";
|
||||
|
||||
const result = await dynamicFormService.saveFormData(
|
||||
screenId,
|
||||
tableName,
|
||||
formDataWithMeta
|
||||
formDataWithMeta,
|
||||
ipAddress
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -1048,3 +1048,268 @@ export async function updateColumnWebType(
|
|||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 🎯 테이블 로그 시스템 API
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 로그 테이블 생성
|
||||
*/
|
||||
export async function createLogTable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { pkColumn } = req.body;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_TABLE_NAME",
|
||||
details: "테이블명 파라미터가 누락되었습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<null> = {
|
||||
success: true,
|
||||
message: "로그 테이블이 성공적으로 생성되었습니다.",
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("로그 테이블 생성 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
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<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
logger.info(`=== 로그 설정 조회: ${tableName} ===`);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<typeof logConfig> = {
|
||||
success: true,
|
||||
message: "로그 설정을 조회했습니다.",
|
||||
data: logConfig,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("로그 설정 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
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<void> {
|
||||
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<null> = {
|
||||
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<typeof result> = {
|
||||
success: true,
|
||||
message: "로그 데이터를 조회했습니다.",
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("로그 데이터 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
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<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { isActive } = req.body;
|
||||
|
||||
logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_TABLE_NAME",
|
||||
details: "테이블명 파라미터가 누락되었습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActive === undefined || isActive === null) {
|
||||
const response: ApiResponse<null> = {
|
||||
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<null> = {
|
||||
success: true,
|
||||
message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("로그 테이블 토글 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "로그 테이블 토글 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "LOG_TOGGLE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { query, queryOne } from "../database/db";
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
|
||||
|
|
@ -203,7 +203,8 @@ export class DynamicFormService {
|
|||
async saveFormData(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
data: Record<string, any>,
|
||||
ipAddress?: string
|
||||
): Promise<FormDataResult> {
|
||||
try {
|
||||
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
||||
|
|
@ -432,7 +433,19 @@ export class DynamicFormService {
|
|||
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
||||
console.log("📊 SQL 파라미터:", values);
|
||||
|
||||
const result = await query<any>(upsertQuery, values);
|
||||
// 로그 트리거를 위한 세션 변수 설정 및 UPSERT 실행 (트랜잭션 내에서)
|
||||
const userId = data.updated_by || data.created_by || "system";
|
||||
const clientIp = ipAddress || "unknown";
|
||||
|
||||
const result = await transaction(async (client) => {
|
||||
// 세션 변수 설정
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||
|
||||
// UPSERT 실행
|
||||
const res = await client.query(upsertQuery, values);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||
|
||||
|
|
|
|||
|
|
@ -3118,4 +3118,410 @@ export class TableManagementService {
|
|||
// 기본값
|
||||
return "text";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 🎯 테이블 로그 시스템
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 로그 테이블 생성
|
||||
*/
|
||||
async createLogTable(
|
||||
tableName: string,
|
||||
pkColumn: { columnName: string; dataType: string },
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
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<any>(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<any>(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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>("");
|
||||
|
||||
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
|
||||
const isSuperAdmin = user?.companyCode === "*";
|
||||
|
||||
|
|
@ -645,15 +650,30 @@ export default function TableManagementPage() {
|
|||
onClick={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">{table.displayName || table.tableName}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "컬럼")}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLogViewerTableName(table.tableName);
|
||||
setLogViewerOpen(true);
|
||||
}}
|
||||
title="변경 이력 조회"
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
@ -972,6 +992,9 @@ export default function TableManagementPage() {
|
|||
/>
|
||||
|
||||
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
||||
|
||||
{/* 테이블 로그 뷰어 */}
|
||||
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<any>(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 && <p className="text-sm text-destructive">{tableNameError}</p>}
|
||||
{tableNameError && <p className="text-destructive text-sm">{tableNameError}</p>}
|
||||
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -278,6 +299,29 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
|||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 생성 옵션 */}
|
||||
<div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||
<Checkbox
|
||||
id="useLogTable"
|
||||
checked={useLogTable}
|
||||
onCheckedChange={(checked) => setUseLogTable(checked as boolean)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="useLogTable"
|
||||
className="flex cursor-pointer items-center gap-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
변경 이력 로그 테이블 생성
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
선택 시 <code className="bg-muted rounded px-1 py-0.5">{tableName || "table"}_log</code> 테이블이
|
||||
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자동 추가 컬럼 안내 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -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<LogData[]>([]);
|
||||
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<string>("");
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [changedBy, setChangedBy] = useState<string>("");
|
||||
const [originalId, setOriginalId] = useState<string>("");
|
||||
|
||||
// 로그 데이터 로드
|
||||
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 <Badge className="bg-green-500">추가</Badge>;
|
||||
case "UPDATE":
|
||||
return <Badge className="bg-blue-500">수정</Badge>;
|
||||
case "DELETE":
|
||||
return <Badge className="bg-red-500">삭제</Badge>;
|
||||
default:
|
||||
return <Badge>{type}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷팅
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
{tableName} - 변경 이력
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Filter className="h-4 w-4" />
|
||||
필터
|
||||
</h4>
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">작업 유형</label>
|
||||
<Select value={operationType} onValueChange={setOperationType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
<SelectItem value="INSERT">추가</SelectItem>
|
||||
<SelectItem value="UPDATE">수정</SelectItem>
|
||||
<SelectItem value="DELETE">삭제</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">시작 날짜</label>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">종료 날짜</label>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">변경자</label>
|
||||
<Input placeholder="사용자 ID" value={changedBy} onChange={(e) => setChangedBy(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">원본 ID</label>
|
||||
<Input placeholder="레코드 ID" value={originalId} onChange={(e) => setOriginalId(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button onClick={loadLogs} className="w-full">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 */}
|
||||
<div className="flex-1 overflow-auto rounded-lg border">
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">변경 이력이 없습니다.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">작업</TableHead>
|
||||
<TableHead>원본 ID</TableHead>
|
||||
<TableHead>변경 컬럼</TableHead>
|
||||
<TableHead>변경 전</TableHead>
|
||||
<TableHead>변경 후</TableHead>
|
||||
<TableHead>변경자</TableHead>
|
||||
<TableHead>변경 시각</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.log_id}>
|
||||
<TableCell>{getOperationBadge(log.operation_type)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{log.original_id}</TableCell>
|
||||
<TableCell className="text-sm">{log.changed_column || "-"}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-sm" title={log.old_value}>
|
||||
{log.old_value || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-sm" title={log.new_value}>
|
||||
{log.new_value || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{log.changed_by || "system"}</TableCell>
|
||||
<TableCell className="text-sm">{formatDate(log.changed_at)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{log.ip_address || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
전체 {total}건 (페이지 {page} / {totalPages})
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1 || loading}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -211,6 +211,114 @@ class TableManagementApi {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 테이블 로그 시스템 API
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 로그 테이블 생성
|
||||
*/
|
||||
async createLogTable(
|
||||
tableName: string,
|
||||
pkColumn: { columnName: string; dataType: string },
|
||||
): Promise<ApiResponse<void>> {
|
||||
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<ApiResponse<void>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className="table-create-form">
|
||||
{/* 기존 폼 필드들 */}
|
||||
|
||||
{/* 로그 테이블 옵션 추가 */}
|
||||
<div className="form-group">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useLogTable}
|
||||
onChange={(e) => setUseLogTable(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>변경 이력 로그 테이블 생성</span>
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
체크 시 데이터 변경 이력을 기록하는 로그 테이블이 자동으로 생성됩니다.
|
||||
(테이블명: {tableName}_log)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{useLogTable && (
|
||||
<div className="bg-blue-50 p-4 rounded border border-blue-200">
|
||||
<h4 className="font-semibold mb-2">로그 테이블 정보</h4>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>• INSERT/UPDATE/DELETE 작업이 자동으로 기록됩니다</li>
|
||||
<li>• 변경 전후 값과 변경자 정보가 저장됩니다</li>
|
||||
<li>
|
||||
• 로그는 별도 테이블에 저장되어 원본 테이블 성능에 영향을
|
||||
최소화합니다
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 로그 조회 화면 추가
|
||||
|
||||
**파일**: `frontend/src/app/admin/tableMng/components/TableLogViewer.tsx`
|
||||
|
||||
```typescript
|
||||
interface TableLogViewerProps {
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
const TableLogViewer: React.FC<TableLogViewerProps> = ({ tableName }) => {
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
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 (
|
||||
<div className="table-log-viewer">
|
||||
<h3>변경 이력 조회: {tableName}</h3>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="filters">
|
||||
<select
|
||||
value={filters.operationType}
|
||||
onChange={(e) =>
|
||||
setFilters({ ...filters, operationType: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="INSERT">추가</option>
|
||||
<option value="UPDATE">수정</option>
|
||||
<option value="DELETE">삭제</option>
|
||||
</select>
|
||||
|
||||
{/* 날짜 필터, 사용자 필터 등 */}
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 */}
|
||||
<table className="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업유형</th>
|
||||
<th>원본ID</th>
|
||||
<th>변경컬럼</th>
|
||||
<th>변경전</th>
|
||||
<th>변경후</th>
|
||||
<th>변경자</th>
|
||||
<th>변경시각</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.log_id}>
|
||||
<td>{log.operation_type}</td>
|
||||
<td>{log.original_id}</td>
|
||||
<td>{log.changed_column}</td>
|
||||
<td>{log.old_value}</td>
|
||||
<td>{log.new_value}</td>
|
||||
<td>{log.changed_by}</td>
|
||||
<td>{log.changed_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 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. 로그 데이터 파티셔닝
|
||||
Loading…
Reference in New Issue