테이블 변경 이력 로그 시스템 구현

This commit is contained in:
dohyeons
2025-10-21 15:08:41 +09:00
parent 874cf485a8
commit 74d287daa9
8 changed files with 1918 additions and 6 deletions

View File

@@ -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;
}
}
}