테이블 변경 이력 로그 시스템 구현
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user