feat:DB 트리거 기반 데이터 변경 추적 시스템 구현

Phase 1: DB 기반 구축
- trigger_audit_logs 테이블 (RANGE 파티셔닝 15개, 3개 인덱스)
- 789개 MySQL AFTER 트리거 (263 테이블 × INSERT/UPDATE/DELETE)
- SetAuditSessionVariables 미들웨어 (@sam_actor_id, @sam_session_info)

Phase 2: 복구 메커니즘
- TriggerAuditLog 모델, TriggerAuditLogService, AuditRollbackService
- 6개 API 엔드포인트 (index, show, stats, history, rollback-preview, rollback)
- FormRequest 검증 (TriggerAuditLogIndexRequest, TriggerAuditRollbackRequest)

Phase 3: 관리 도구
- v_unified_audit VIEW (APP + TRIGGER 통합, COLLATE 처리)
- audit:partitions 커맨드 (파티션 추가/삭제, dry-run)
- audit:triggers 커맨드 (트리거 재생성, 테이블별/전체)
- 월 1회 파티션 자동 관리 스케줄러 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 09:17:15 +09:00
parent ee6794be1a
commit d07bad16df
16 changed files with 1167 additions and 1 deletions

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ManageAuditPartitions extends Command
{
protected $signature = 'audit:partitions
{--add-months=3 : 미래 파티션 추가 개월 수}
{--retention-months=13 : 보관 기간 (개월)}
{--drop : 보관 기간 초과 파티션 삭제 실행}
{--dry-run : 변경 없이 계획만 출력}';
protected $description = '트리거 감사 로그 파티션 자동 관리 (추가/삭제)';
public function handle(): int
{
$addMonths = (int) $this->option('add-months');
$retentionMonths = (int) $this->option('retention-months');
$doDrop = $this->option('drop');
$dryRun = $this->option('dry-run');
$this->info('=== 트리거 감사 로그 파티션 관리 ===');
$this->newLine();
// 현재 파티션 목록 조회
$partitions = $this->getPartitions();
$this->info('현재 파티션: '.count($partitions).'개');
$this->table(
['파티션명', '상한값 (UNIX_TIMESTAMP)', '행 수'],
collect($partitions)->map(fn ($p) => [
$p->PARTITION_NAME,
$p->PARTITION_DESCRIPTION === 'MAXVALUE' ? 'MAXVALUE' : Carbon::createFromTimestamp($p->PARTITION_DESCRIPTION)->format('Y-m-d'),
number_format($p->TABLE_ROWS),
])->toArray()
);
$this->newLine();
// 1. 미래 파티션 추가
$added = $this->addFuturePartitions($partitions, $addMonths, $dryRun);
// 2. 오래된 파티션 삭제
$dropped = 0;
if ($doDrop) {
$dropped = $this->dropOldPartitions($partitions, $retentionMonths, $dryRun);
} else {
$this->warn('파티션 삭제는 --drop 옵션 필요');
}
$this->newLine();
$this->info("결과: 추가 {$added}개, 삭제 {$dropped}".($dryRun ? ' (dry-run)' : ''));
return self::SUCCESS;
}
private function getPartitions(): array
{
return DB::select("
SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS
FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs'
AND PARTITION_NAME IS NOT NULL
ORDER BY PARTITION_ORDINAL_POSITION
", [config('database.connections.mysql.database')]);
}
private function addFuturePartitions(array $partitions, int $addMonths, bool $dryRun): int
{
$existingBounds = [];
foreach ($partitions as $p) {
if ($p->PARTITION_DESCRIPTION !== 'MAXVALUE') {
$existingBounds[] = (int) $p->PARTITION_DESCRIPTION;
}
}
$added = 0;
$now = Carbon::now();
for ($i = 0; $i <= $addMonths; $i++) {
$target = $now->copy()->addMonths($i)->startOfMonth()->addMonth();
$ts = $target->timestamp;
$name = 'p'.$target->copy()->subMonth()->format('Ym');
if (in_array($ts, $existingBounds)) {
continue;
}
$sql = "ALTER TABLE trigger_audit_logs REORGANIZE PARTITION p_future INTO (
PARTITION {$name} VALUES LESS THAN ({$ts}),
PARTITION p_future VALUES LESS THAN MAXVALUE
)";
if ($dryRun) {
$this->line(" [DRY-RUN] 추가: {$name} (< {$target->format('Y-m-d')})");
} else {
DB::statement($sql);
$this->info(" 추가: {$name} (< {$target->format('Y-m-d')})");
}
$added++;
}
if ($added === 0) {
$this->info(' 추가할 파티션 없음');
}
return $added;
}
private function dropOldPartitions(array $partitions, int $retentionMonths, bool $dryRun): int
{
$cutoff = Carbon::now()->subMonths($retentionMonths)->startOfMonth()->timestamp;
$dropped = 0;
foreach ($partitions as $p) {
if ($p->PARTITION_DESCRIPTION === 'MAXVALUE') {
continue;
}
$bound = (int) $p->PARTITION_DESCRIPTION;
if ($bound <= $cutoff) {
if ($dryRun) {
$this->line(" [DRY-RUN] 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
} else {
DB::statement("ALTER TABLE trigger_audit_logs DROP PARTITION {$p->PARTITION_NAME}");
$this->warn(" 삭제: {$p->PARTITION_NAME} ({$p->TABLE_ROWS}행)");
}
$dropped++;
}
}
if ($dropped === 0) {
$this->info(' 삭제할 파티션 없음');
}
return $dropped;
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RegenerateAuditTriggers extends Command
{
protected $signature = 'audit:triggers
{--table= : 특정 테이블만 재생성}
{--drop-only : 트리거 삭제만 (재생성 안 함)}
{--dry-run : 변경 없이 대상 목록만 출력}';
protected $description = '트리거 감사 로그용 MySQL 트리거 재생성 (스키마 변경 후 사용)';
/** @var string[] 트리거 제외 테이블 */
private array $excludeTables = [
'audit_logs',
'trigger_audit_logs',
'sessions',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
'migrations',
'personal_access_tokens',
'api_request_logs',
];
public function handle(): int
{
$specificTable = $this->option('table');
$dropOnly = $this->option('drop-only');
$dryRun = $this->option('dry-run');
$dbName = config('database.connections.mysql.database');
$this->info('=== 트리거 감사 로그 트리거 '.($dropOnly ? '삭제' : '재생성').' ===');
$this->newLine();
// 대상 테이블 목록
$tables = $this->getTargetTables($dbName, $specificTable);
$this->info('대상 테이블: '.count($tables).'개');
if ($dryRun) {
foreach ($tables as $t) {
$this->line(" - {$t}");
}
$this->newLine();
$this->info('[DRY-RUN] 실제 변경 없음');
return self::SUCCESS;
}
$dropped = 0;
$created = 0;
foreach ($tables as $table) {
// 기존 트리거 삭제
foreach (['ai', 'au', 'ad'] as $suffix) {
$triggerName = "trg_{$table}_{$suffix}";
DB::unprepared("DROP TRIGGER IF EXISTS `{$triggerName}`");
$dropped++;
}
if (! $dropOnly) {
// 트리거 재생성
$this->createTriggersForTable($dbName, $table);
$created += 3;
}
$this->line(" {$table}: ".($dropOnly ? '삭제 완료' : '재생성 완료'));
}
$this->newLine();
$this->info("결과: 삭제 {$dropped}개, 생성 {$created}");
return self::SUCCESS;
}
private function getTargetTables(string $dbName, ?string $specificTable): array
{
if ($specificTable) {
if (in_array($specificTable, $this->excludeTables)) {
$this->error("{$specificTable}은(는) 트리거 제외 테이블입니다.");
return [];
}
return [$specificTable];
}
$rows = DB::select("
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
ORDER BY TABLE_NAME
", [$dbName]);
return collect($rows)
->pluck('TABLE_NAME')
->reject(fn ($t) => in_array($t, $this->excludeTables))
->values()
->toArray();
}
private function createTriggersForTable(string $dbName, string $table): void
{
// PK 컬럼
$pkRow = DB::selectOne("
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_KEY = 'PRI'
LIMIT 1
", [$dbName, $table]);
$pkCol = $pkRow?->COLUMN_NAME ?? 'id';
// 컬럼 목록 (제외: created_at, updated_at, deleted_at, remember_token)
$excludeCols = ['created_at', 'updated_at', 'deleted_at', 'remember_token'];
$columns = DB::select('
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
', [$dbName, $table]);
$cols = collect($columns)
->pluck('COLUMN_NAME')
->reject(fn ($c) => in_array($c, $excludeCols))
->values()
->toArray();
if (empty($cols)) {
return;
}
// JSON_OBJECT 표현식
$newJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', NEW.`{$c}`")->implode(', ').')';
$oldJson = 'JSON_OBJECT('.collect($cols)->map(fn ($c) => "'{$c}', OLD.`{$c}`")->implode(', ').')';
// changed_columns (UPDATE용)
$changedCols = collect($cols)->map(fn ($c) => "IF(NOT (NEW.`{$c}` <=> OLD.`{$c}`), '{$c}', NULL)")->implode(', ');
$changeCheck = collect($cols)->map(fn ($c) => "NOT (NEW.`{$c}` <=> OLD.`{$c}`)")->implode(' OR ');
$tenantExpr = in_array('tenant_id', $cols) ? 'NEW.`tenant_id`' : 'NULL';
$tenantExprOld = in_array('tenant_id', $cols) ? 'OLD.`tenant_id`' : 'NULL';
$guard = 'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN';
// INSERT trigger
DB::unprepared("
CREATE TRIGGER `trg_{$table}_ai` AFTER INSERT ON `{$table}`
FOR EACH ROW BEGIN
{$guard}
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
VALUES ('{$table}', NEW.`{$pkCol}`, 'INSERT', NULL, {$newJson}, NULL, {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
END IF;
END
");
// UPDATE trigger
DB::unprepared("
CREATE TRIGGER `trg_{$table}_au` AFTER UPDATE ON `{$table}`
FOR EACH ROW BEGIN
{$guard}
IF {$changeCheck} THEN
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
VALUES ('{$table}', NEW.`{$pkCol}`, 'UPDATE', {$oldJson}, {$newJson}, JSON_ARRAY({$changedCols}), {$tenantExpr}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
END IF;
END IF;
END
");
// DELETE trigger
DB::unprepared("
CREATE TRIGGER `trg_{$table}_ad` AFTER DELETE ON `{$table}`
FOR EACH ROW BEGIN
{$guard}
INSERT INTO trigger_audit_logs (table_name, row_id, dml_type, old_values, new_values, changed_columns, tenant_id, actor_id, session_info, db_user, created_at)
VALUES ('{$table}', OLD.`{$pkCol}`, 'DELETE', {$oldJson}, NULL, NULL, {$tenantExprOld}, @sam_actor_id, @sam_session_info, CURRENT_USER(), NOW());
END IF;
END
");
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api\V1\Audit;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Audit\TriggerAuditLogIndexRequest;
use App\Http\Requests\Audit\TriggerAuditRollbackRequest;
use App\Services\Audit\AuditRollbackService;
use App\Services\Audit\TriggerAuditLogService;
class TriggerAuditLogController extends Controller
{
public function __construct(
protected TriggerAuditLogService $service,
protected AuditRollbackService $rollbackService,
) {}
/**
* 트리거 감사 로그 목록 조회
*/
public function index(TriggerAuditLogIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->paginate($request->validated());
}, __('message.fetched'));
}
/**
* 트리거 감사 로그 상세 조회
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return \App\Models\Audit\TriggerAuditLog::findOrFail($id);
}, __('message.fetched'));
}
/**
* 특정 레코드의 변경 이력
*/
public function recordHistory(string $tableName, string $rowId)
{
return ApiResponse::handle(function () use ($tableName, $rowId) {
return $this->service->recordHistory($tableName, $rowId);
}, __('message.fetched'));
}
/**
* 통계 조회
*/
public function stats()
{
return ApiResponse::handle(function () {
$tenantId = request()->query('tenant_id');
return $this->service->stats($tenantId ? (int) $tenantId : null);
}, __('message.fetched'));
}
/**
* 롤백 SQL 미리보기
*/
public function rollbackPreview(int $id)
{
return ApiResponse::handle(function () use ($id) {
return [
'audit_id' => $id,
'rollback_sql' => $this->rollbackService->generateRollbackSQL($id),
];
}, __('message.fetched'));
}
/**
* 롤백 실행
*/
public function rollbackExecute(TriggerAuditRollbackRequest $request, int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->rollbackService->executeRollback($id);
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class SetAuditSessionVariables
{
public function handle(Request $request, Closure $next): Response
{
if (auth()->check()) {
DB::statement('SET @sam_actor_id = ?', [auth()->id()]);
DB::statement('SET @sam_session_info = ?', [
json_encode([
'ip' => $request->ip(),
'ua' => mb_substr((string) $request->userAgent(), 0, 255),
'route' => $request->route()?->getName(),
], JSON_UNESCAPED_UNICODE),
]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Audit;
use App\Http\Requests\Traits\HasPagination;
use Illuminate\Foundation\Http\FormRequest;
class TriggerAuditLogIndexRequest extends FormRequest
{
use HasPagination;
protected int $maxSize = 200;
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'page' => 'nullable|integer|min:1',
'size' => 'nullable|integer|min:1',
'table_name' => 'nullable|string|max:64',
'row_id' => 'nullable|string|max:64',
'dml_type' => 'nullable|string|in:INSERT,UPDATE,DELETE',
'tenant_id' => 'nullable|integer|min:1',
'actor_id' => 'nullable|integer|min:1',
'db_user' => 'nullable|string|max:100',
'from' => 'nullable|date',
'to' => 'nullable|date|after_or_equal:from',
'sort' => 'nullable|string|in:created_at,id',
'order' => 'nullable|string|in:asc,desc',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Audit;
use Illuminate\Foundation\Http\FormRequest;
class TriggerAuditRollbackRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'confirm' => 'required|boolean|accepted',
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Audit;
use Illuminate\Database\Eloquent\Model;
class TriggerAuditLog extends Model
{
public $timestamps = false;
protected $table = 'trigger_audit_logs';
protected $fillable = [
'table_name',
'row_id',
'dml_type',
'old_values',
'new_values',
'changed_columns',
'tenant_id',
'actor_id',
'session_info',
'db_user',
'created_at',
];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
'changed_columns' => 'array',
'created_at' => 'datetime',
];
/**
* changed_columns에서 NULL 값 필터링
*/
public function getChangedColumnsAttribute($value): ?array
{
$decoded = is_string($value) ? json_decode($value, true) : $value;
if (! is_array($decoded)) {
return null;
}
return array_values(array_filter($decoded, fn ($v) => $v !== null));
}
/**
* session_info JSON 디코딩
*/
public function getSessionInfoAttribute($value): ?array
{
if (is_string($value)) {
return json_decode($value, true);
}
return $value;
}
public function scopeForTable($query, string $tableName)
{
return $query->where('table_name', $tableName);
}
public function scopeForRecord($query, string $tableName, string $rowId)
{
return $query->where('table_name', $tableName)->where('row_id', $rowId);
}
public function scopeForTenant($query, int $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Services\Audit;
use App\Models\Audit\TriggerAuditLog;
use App\Services\Service;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class AuditRollbackService extends Service
{
/**
* 역방향 SQL 생성 (실행하지 않음, 미리보기용)
*/
public function generateRollbackSQL(int $auditId): string
{
$log = TriggerAuditLog::findOrFail($auditId);
return match ($log->dml_type) {
'INSERT' => $this->buildDeleteSQL($log),
'UPDATE' => $this->buildRevertUpdateSQL($log),
'DELETE' => $this->buildReinsertSQL($log),
};
}
/**
* 복구 실행 (트랜잭션)
*/
public function executeRollback(int $auditId): array
{
$log = TriggerAuditLog::findOrFail($auditId);
$sql = $this->generateRollbackSQL($auditId);
DB::statement('SET @disable_audit_trigger = 1');
try {
DB::transaction(function () use ($sql) {
DB::statement($sql);
});
return [
'success' => true,
'audit_id' => $auditId,
'table_name' => $log->table_name,
'row_id' => $log->row_id,
'original_dml' => $log->dml_type,
'rollback_sql' => $sql,
];
} catch (\Throwable $e) {
return [
'success' => false,
'audit_id' => $auditId,
'error' => $e->getMessage(),
'rollback_sql' => $sql,
];
} finally {
DB::statement('SET @disable_audit_trigger = NULL');
}
}
/**
* 특정 레코드의 특정 시점 상태 조회
*/
public function getRecordStateAt(string $table, string $rowId, Carbon $at): ?array
{
$log = TriggerAuditLog::where('table_name', $table)
->where('row_id', $rowId)
->where('created_at', '<=', $at)
->orderByDesc('created_at')
->first();
if (! $log) {
return null;
}
return match ($log->dml_type) {
'INSERT', 'UPDATE' => $log->new_values,
'DELETE' => null,
};
}
/**
* INSERT 복구: 삽입된 레코드 삭제
*/
private function buildDeleteSQL(TriggerAuditLog $log): string
{
$rowId = DB::getPdo()->quote($log->row_id);
return "DELETE FROM `{$log->table_name}` WHERE `id` = {$rowId} LIMIT 1";
}
/**
* UPDATE 복구: old_values로 되돌림
*/
private function buildRevertUpdateSQL(TriggerAuditLog $log): string
{
if (empty($log->old_values)) {
throw new \RuntimeException("No old_values for audit #{$log->id}");
}
$pdo = DB::getPdo();
$sets = collect($log->old_values)
->map(fn ($val, $col) => "`{$col}` = ".($val === null ? 'NULL' : $pdo->quote((string) $val)))
->implode(', ');
$rowId = $pdo->quote($log->row_id);
return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = {$rowId} LIMIT 1";
}
/**
* DELETE 복구: old_values로 재삽입
*/
private function buildReinsertSQL(TriggerAuditLog $log): string
{
if (empty($log->old_values)) {
throw new \RuntimeException("No old_values for audit #{$log->id}");
}
$pdo = DB::getPdo();
$cols = collect($log->old_values)->keys()->map(fn ($c) => "`{$c}`")->implode(', ');
$vals = collect($log->old_values)->values()
->map(fn ($v) => $v === null ? 'NULL' : $pdo->quote((string) $v))
->implode(', ');
return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})";
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Services\Audit;
use App\Models\Audit\TriggerAuditLog;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class TriggerAuditLogService extends Service
{
public function paginate(array $filters): LengthAwarePaginator
{
$page = (int) ($filters['page'] ?? 1);
$size = (int) ($filters['size'] ?? 20);
$sort = $filters['sort'] ?? 'created_at';
$order = $filters['order'] ?? 'desc';
$q = TriggerAuditLog::query();
// 테넌트 필터 (선택적: 전체 조회도 가능)
if (! empty($filters['tenant_id'])) {
$q->where('tenant_id', (int) $filters['tenant_id']);
}
if (! empty($filters['table_name'])) {
$q->where('table_name', $filters['table_name']);
}
if (! empty($filters['row_id'])) {
$q->where('row_id', $filters['row_id']);
}
if (! empty($filters['dml_type'])) {
$q->where('dml_type', $filters['dml_type']);
}
if (! empty($filters['actor_id'])) {
$q->where('actor_id', (int) $filters['actor_id']);
}
if (! empty($filters['db_user'])) {
$q->where('db_user', 'like', "%{$filters['db_user']}%");
}
if (! empty($filters['from'])) {
$q->where('created_at', '>=', $filters['from']);
}
if (! empty($filters['to'])) {
$q->where('created_at', '<=', $filters['to']);
}
return $q->orderBy($sort, $order)->paginate($size, ['*'], 'page', $page);
}
/**
* 특정 레코드의 변경 이력 조회
*/
public function recordHistory(string $tableName, string $rowId): \Illuminate\Support\Collection
{
return TriggerAuditLog::forRecord($tableName, $rowId)
->orderByDesc('created_at')
->get();
}
/**
* 테이블별 변경 통계
*/
public function stats(?int $tenantId = null): array
{
$q = TriggerAuditLog::query();
if ($tenantId) {
$q->where('tenant_id', $tenantId);
}
$total = (clone $q)->count();
$byDmlType = (clone $q)
->selectRaw('dml_type, COUNT(*) as count')
->groupBy('dml_type')
->pluck('count', 'dml_type')
->toArray();
$topTables = (clone $q)
->selectRaw('table_name, COUNT(*) as count')
->groupBy('table_name')
->orderByDesc('count')
->limit(10)
->pluck('count', 'table_name')
->toArray();
$today = (clone $q)
->whereDate('created_at', today())
->count();
return [
'total' => $total,
'today' => $today,
'by_dml_type' => $byDmlType,
'top_tables' => $topTables,
];
}
}