diff --git a/app/Http/Controllers/TriggerAuditController.php b/app/Http/Controllers/TriggerAuditController.php index 47adb9e3..4a5c5679 100644 --- a/app/Http/Controllers/TriggerAuditController.php +++ b/app/Http/Controllers/TriggerAuditController.php @@ -3,12 +3,19 @@ namespace App\Http\Controllers; use App\Models\Audit\TriggerAuditLog; +use App\Services\PartitionManagementService; +use App\Services\TriggerManagementService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\View\View; class TriggerAuditController extends Controller { + public function __construct( + private TriggerManagementService $triggerService, + private PartitionManagementService $partitionService, + ) {} + /** * 트리거 감사 로그 목록 + 통계 대시보드 */ @@ -225,4 +232,100 @@ private function buildReinsertSQL(TriggerAuditLog $log, \PDO $pdo): string return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})"; } + + // ── 4.4 트리거 관리 ────────────────────────────── + + /** + * 트리거 관리 페이지 + */ + public function triggers(): View + { + $data = $this->triggerService->getTableTriggerStatus(); + + return view('trigger-audit.triggers', compact('data')); + } + + /** + * 트리거 재생성 (단일 또는 전체) + */ + public function regenerateTrigger(Request $request) + { + $tableName = $request->input('table_name'); + + try { + $result = $this->triggerService->regenerate($tableName); + } catch (\Throwable $e) { + return redirect()->route('trigger-audit.triggers') + ->with('error', '트리거 재생성 실패: '.$e->getMessage()); + } + + return redirect()->route('trigger-audit.triggers') + ->with($result['success'] ? 'success' : 'error', $result['message']); + } + + /** + * 트리거 삭제 (단일 또는 전체) + */ + public function dropTrigger(Request $request) + { + $tableName = $request->input('table_name'); + + try { + $result = $this->triggerService->drop($tableName); + } catch (\Throwable $e) { + return redirect()->route('trigger-audit.triggers') + ->with('error', '트리거 삭제 실패: '.$e->getMessage()); + } + + return redirect()->route('trigger-audit.triggers') + ->with($result['success'] ? 'success' : 'error', $result['message']); + } + + // ── 4.6 파티션 관리 ────────────────────────────── + + /** + * 파티션 관리 페이지 + */ + public function partitions(): View + { + $data = $this->partitionService->getPartitions(); + + return view('trigger-audit.partitions', compact('data')); + } + + /** + * 미래 파티션 추가 + */ + public function addPartitions(Request $request) + { + $request->validate(['months' => 'required|integer|min:1|max:12']); + + try { + $result = $this->partitionService->addFuturePartitions((int) $request->months); + } catch (\Throwable $e) { + return redirect()->route('trigger-audit.partitions') + ->with('error', '파티션 추가 실패: '.$e->getMessage()); + } + + return redirect()->route('trigger-audit.partitions') + ->with($result['success'] ? 'success' : 'error', $result['message']); + } + + /** + * 파티션 삭제 + */ + public function dropPartition(Request $request) + { + $request->validate(['partition_name' => 'required|string']); + + try { + $result = $this->partitionService->dropPartition($request->partition_name); + } catch (\Throwable $e) { + return redirect()->route('trigger-audit.partitions') + ->with('error', '파티션 삭제 실패: '.$e->getMessage()); + } + + return redirect()->route('trigger-audit.partitions') + ->with($result['success'] ? 'success' : 'error', $result['message']); + } } diff --git a/app/Services/PartitionManagementService.php b/app/Services/PartitionManagementService.php new file mode 100644 index 00000000..f4bec8fb --- /dev/null +++ b/app/Services/PartitionManagementService.php @@ -0,0 +1,186 @@ +getRetentionDays(); + + $partitions = DB::select(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME IS NOT NULL + ORDER BY PARTITION_ORDINAL_POSITION + ", [$dbName]); + + // 저장소 전체 크기 + $storageInfo = DB::selectOne(" + SELECT + ROUND(SUM(DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) as size_mb + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + ", [$dbName]); + + $totalRows = 0; + $cutoffTs = Carbon::now()->subDays($retentionDays)->timestamp; + $nowTs = Carbon::now()->timestamp; + + $items = []; + foreach ($partitions as $p) { + $totalRows += $p->TABLE_ROWS; + + if ($p->PARTITION_DESCRIPTION === 'MAXVALUE') { + $items[] = [ + 'name' => $p->PARTITION_NAME, + 'bound_raw' => 'MAXVALUE', + 'bound_date' => null, + 'rows' => $p->TABLE_ROWS, + 'status' => 'future', + 'can_drop' => false, + ]; + + continue; + } + + $boundTs = (int) $p->PARTITION_DESCRIPTION; + $boundDate = Carbon::createFromTimestamp($boundTs)->format('Y-m-d'); + + // 상태 결정 + if ($boundTs <= $cutoffTs) { + $status = 'expired'; // 보관기간 초과 + } elseif ($boundTs <= $nowTs) { + $prevMonthTs = Carbon::now()->startOfMonth()->timestamp; + $status = $boundTs <= $prevMonthTs ? 'archived' : 'current'; + } else { + $status = 'upcoming'; // 미래 파티션 + } + + $items[] = [ + 'name' => $p->PARTITION_NAME, + 'bound_raw' => $boundTs, + 'bound_date' => $boundDate, + 'rows' => $p->TABLE_ROWS, + 'status' => $status, + 'can_drop' => $status === 'expired', + ]; + } + + return [ + 'partitions' => $items, + 'total_partitions' => count($items), + 'total_rows' => $totalRows, + 'storage_mb' => $storageInfo->size_mb ?? 0, + 'retention_days' => $retentionDays, + ]; + } + + /** + * 미래 파티션 추가 (REORGANIZE PARTITION p_future) + */ + public function addFuturePartitions(int $months): array + { + $dbName = config('database.connections.mysql.database'); + + // 현재 파티션 바운드 조회 + $partitions = DB::select(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME IS NOT NULL + ORDER BY PARTITION_ORDINAL_POSITION + ", [$dbName]); + + $existingBounds = []; + foreach ($partitions as $p) { + if ($p->PARTITION_DESCRIPTION !== 'MAXVALUE') { + $existingBounds[] = (int) $p->PARTITION_DESCRIPTION; + } + } + + $added = 0; + $now = Carbon::now(); + + for ($i = 0; $i <= $months; $i++) { + $target = $now->copy()->addMonths($i)->startOfMonth()->addMonth(); + $ts = $target->timestamp; + $name = 'p' . $target->copy()->subMonth()->format('Ym'); + + if (in_array($ts, $existingBounds)) { + continue; + } + + DB::statement("ALTER TABLE trigger_audit_logs REORGANIZE PARTITION p_future INTO ( + PARTITION {$name} VALUES LESS THAN ({$ts}), + PARTITION p_future VALUES LESS THAN MAXVALUE + )"); + + $added++; + } + + if ($added === 0) { + return ['success' => true, 'message' => '추가할 파티션이 없습니다 (이미 존재).']; + } + + return ['success' => true, 'message' => "파티션 {$added}개 추가 완료."]; + } + + /** + * 파티션 삭제 (보관기간 초과 검증 포함) + */ + public function dropPartition(string $name): array + { + if ($name === 'p_future') { + return ['success' => false, 'message' => 'p_future 파티션은 삭제할 수 없습니다.']; + } + + $dbName = config('database.connections.mysql.database'); + $retentionDays = $this->getRetentionDays(); + + // 파티션 존재 및 바운드 확인 + $partition = DB::selectOne(" + SELECT PARTITION_NAME, PARTITION_DESCRIPTION, TABLE_ROWS + FROM INFORMATION_SCHEMA.PARTITIONS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'trigger_audit_logs' + AND PARTITION_NAME = ? + ", [$dbName, $name]); + + if (! $partition) { + return ['success' => false, 'message' => "파티션 '{$name}'을 찾을 수 없습니다."]; + } + + if ($partition->PARTITION_DESCRIPTION === 'MAXVALUE') { + return ['success' => false, 'message' => '이 파티션은 삭제할 수 없습니다.']; + } + + // 보관기간 초과 여부 서버측 검증 + $boundTs = (int) $partition->PARTITION_DESCRIPTION; + $cutoffTs = Carbon::now()->subDays($retentionDays)->timestamp; + + if ($boundTs > $cutoffTs) { + return ['success' => false, 'message' => '보관기간이 초과되지 않은 파티션은 삭제할 수 없습니다.']; + } + + $rows = $partition->TABLE_ROWS; + DB::statement("ALTER TABLE trigger_audit_logs DROP PARTITION {$name}"); + + return ['success' => true, 'message' => "파티션 '{$name}' 삭제 완료 ({$rows}행)."]; + } + + /** + * 보관기간 (일) + */ + public function getRetentionDays(): int + { + return (int) env('AUDIT_RETENTION_DAYS', 395); + } +} diff --git a/app/Services/TriggerManagementService.php b/app/Services/TriggerManagementService.php new file mode 100644 index 00000000..249280fd --- /dev/null +++ b/app/Services/TriggerManagementService.php @@ -0,0 +1,251 @@ +pluck('TABLE_NAME')->toArray(); + + // 추적 대상 테이블 + $trackedTables = array_values(array_diff($allTables, $this->excludeTables)); + + // 현재 존재하는 트리거 조회 + $triggers = collect(DB::select(" + SELECT TRIGGER_NAME, EVENT_OBJECT_TABLE, EVENT_MANIPULATION + FROM INFORMATION_SCHEMA.TRIGGERS + WHERE TRIGGER_SCHEMA = ? AND TRIGGER_NAME LIKE 'trg_%' + ORDER BY EVENT_OBJECT_TABLE, EVENT_MANIPULATION + ", [$dbName])); + + // 테이블별 트리거 상태 매핑 + $tableStatus = []; + foreach ($trackedTables as $table) { + // 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]); + + // 컬럼 수 조회 + $colCount = DB::selectOne(" + SELECT COUNT(*) as cnt FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ", [$dbName, $table])->cnt; + + $tableTriggers = $triggers->where('EVENT_OBJECT_TABLE', $table); + + $tableStatus[] = [ + 'table_name' => $table, + 'pk_column' => $pkRow?->COLUMN_NAME ?? '-', + 'column_count' => $colCount, + 'has_insert' => $tableTriggers->contains('EVENT_MANIPULATION', 'INSERT'), + 'has_update' => $tableTriggers->contains('EVENT_MANIPULATION', 'UPDATE'), + 'has_delete' => $tableTriggers->contains('EVENT_MANIPULATION', 'DELETE'), + ]; + } + + return [ + 'total_tables' => count($allTables), + 'tracked_tables' => count($trackedTables), + 'excluded_tables' => count($this->excludeTables), + 'active_triggers' => $triggers->count(), + 'table_status' => $tableStatus, + 'exclude_tables' => $this->excludeTables, + ]; + } + + /** + * 트리거 재생성 (단일 테이블 또는 전체) + */ + public function regenerate(?string $tableName = null): array + { + $dbName = config('database.connections.mysql.database'); + $tables = $this->getTargetTables($dbName, $tableName); + + if (empty($tables)) { + return ['success' => false, 'message' => '대상 테이블이 없습니다.']; + } + + $dropped = 0; + $created = 0; + + foreach ($tables as $table) { + // 기존 트리거 삭제 + foreach (['ai', 'au', 'ad'] as $suffix) { + DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$table}_{$suffix}`"); + $dropped++; + } + + // 트리거 생성 + $this->createTriggersForTable($dbName, $table); + $created += 3; + } + + $label = $tableName ?? '전체'; + + return [ + 'success' => true, + 'message' => "[{$label}] 트리거 재생성 완료 (삭제: {$dropped}, 생성: {$created})", + ]; + } + + /** + * 트리거 삭제 (단일 테이블 또는 전체) + */ + public function drop(?string $tableName = null): array + { + $dbName = config('database.connections.mysql.database'); + $tables = $this->getTargetTables($dbName, $tableName); + + if (empty($tables)) { + return ['success' => false, 'message' => '대상 테이블이 없습니다.']; + } + + $dropped = 0; + + foreach ($tables as $table) { + foreach (['ai', 'au', 'ad'] as $suffix) { + DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$table}_{$suffix}`"); + $dropped++; + } + } + + $label = $tableName ?? '전체'; + + return [ + 'success' => true, + 'message' => "[{$label}] 트리거 삭제 완료 ({$dropped}개)", + ]; + } + + private function getTargetTables(string $dbName, ?string $tableName): array + { + if ($tableName) { + if (in_array($tableName, $this->excludeTables)) { + return []; + } + + return [$tableName]; + } + + $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 + { + $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'; + + $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, $this->excludeColumns)) + ->values() + ->toArray(); + + if (empty($cols)) { + return; + } + + $newJson = 'JSON_OBJECT(' . collect($cols)->map(fn ($c) => "'{$c}', NEW.`{$c}`")->implode(', ') . ')'; + $oldJson = 'JSON_OBJECT(' . collect($cols)->map(fn ($c) => "'{$c}', OLD.`{$c}`")->implode(', ') . ')'; + + $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'; + + 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 + "); + + 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 + "); + + 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 + "); + } +} diff --git a/resources/views/trigger-audit/index.blade.php b/resources/views/trigger-audit/index.blade.php index d0045971..20df7a93 100644 --- a/resources/views/trigger-audit/index.blade.php +++ b/resources/views/trigger-audit/index.blade.php @@ -11,6 +11,8 @@ +@include('trigger-audit.partials.sub-nav') +
diff --git a/resources/views/trigger-audit/partials/sub-nav.blade.php b/resources/views/trigger-audit/partials/sub-nav.blade.php new file mode 100644 index 00000000..58d81238 --- /dev/null +++ b/resources/views/trigger-audit/partials/sub-nav.blade.php @@ -0,0 +1,17 @@ +{{-- 트리거 감사 로그 서브 네비게이션 --}} + diff --git a/resources/views/trigger-audit/partitions.blade.php b/resources/views/trigger-audit/partitions.blade.php new file mode 100644 index 00000000..c8c9aa33 --- /dev/null +++ b/resources/views/trigger-audit/partitions.blade.php @@ -0,0 +1,135 @@ +@extends('layouts.app') + +@section('title', '파티션 관리') + +@section('content') + +
+

파티션 관리

+
+ +@include('trigger-audit.partials.sub-nav') + + +@if(session('success')) +
{{ session('success') }}
+@endif +@if(session('error')) +
{{ session('error') }}
+@endif + + +
+
+
파티션 수
+
{{ $data['total_partitions'] }}
+
+
+
전체 행 수
+
{{ number_format($data['total_rows']) }}
+
+
+
저장소 크기
+
{{ $data['storage_mb'] }} MB
+
+
+
보관 정책
+
{{ $data['retention_days'] }}일
+
{{ round($data['retention_days'] / 30, 1) }}개월
+
+
+ + +
+ +
+

미래 파티션 추가

+
+ @csrf +
+ + +
+ +
+
+ + +
+

보관 정책 설정

+
+

현재 보관기간: {{ $data['retention_days'] }}일 ({{ round($data['retention_days'] / 30, 1) }}개월)

+

+ 변경하려면 .env 파일에서 + AUDIT_RETENTION_DAYS 값을 수정하세요. +

+
+
+
+ + +
+ + + + + + + + + + + + @foreach($data['partitions'] as $p) + + + + + + + + @endforeach + +
파티션명날짜 범위행 수상태액션
{{ $p['name'] }} + @if($p['bound_date']) + ~ {{ $p['bound_date'] }} + @else + MAXVALUE (캐치올) + @endif + {{ number_format($p['rows']) }} + @switch($p['status']) + @case('current') + 현재 + @break + @case('archived') + 보관중 + @break + @case('expired') + 초과 + @break + @case('upcoming') + 예정 + @break + @case('future') + 미래 + @break + @endswitch + + @if($p['can_drop']) +
+ @csrf + + +
+ @else + - + @endif +
+
+@endsection diff --git a/resources/views/trigger-audit/triggers.blade.php b/resources/views/trigger-audit/triggers.blade.php new file mode 100644 index 00000000..770b02bc --- /dev/null +++ b/resources/views/trigger-audit/triggers.blade.php @@ -0,0 +1,130 @@ +@extends('layouts.app') + +@section('title', '트리거 관리') + +@section('content') + +
+

트리거 관리

+
+ +@include('trigger-audit.partials.sub-nav') + + +@if(session('success')) +
{{ session('success') }}
+@endif +@if(session('error')) +
{{ session('error') }}
+@endif + + +
+
+
전체 테이블
+
{{ $data['total_tables'] }}
+
+
+
추적 테이블
+
{{ $data['tracked_tables'] }}
+
+
+
제외 테이블
+
{{ $data['excluded_tables'] }}
+
+
+
활성 트리거
+
{{ $data['active_triggers'] }}
+
+
+ + +
+
+
+

전체 트리거 관리

+

모든 추적 대상 테이블의 트리거를 일괄 처리합니다.

+
+
+
+ @csrf + +
+
+ @csrf + +
+
+
+
+ + +
+ + + + + + + + + + + + + + @foreach($data['table_status'] as $table) + + + + + + + + + + @endforeach + +
테이블명PK컬럼 수AIAUAD액션
{{ $table['table_name'] }}{{ $table['pk_column'] }}{{ $table['column_count'] }} + + + + + + +
+
+ @csrf + + +
+
+ @csrf + + +
+
+
+
+ + +
+

제외 테이블 (트리거 생성 안 함)

+
+ @foreach($data['exclude_tables'] as $table) + {{ $table }} + @endforeach +
+
+@endsection diff --git a/routes/web.php b/routes/web.php index ed861728..7517e3a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -278,6 +278,18 @@ // 트리거 감사 로그 (DB 레벨 변경 추적) Route::prefix('trigger-audit')->name('trigger-audit.')->group(function () { Route::get('/', [TriggerAuditController::class, 'index'])->name('index'); + + // 4.4 트리거 관리 + Route::get('/triggers', [TriggerAuditController::class, 'triggers'])->name('triggers'); + Route::post('/triggers/regenerate', [TriggerAuditController::class, 'regenerateTrigger'])->name('triggers.regenerate'); + Route::post('/triggers/drop', [TriggerAuditController::class, 'dropTrigger'])->name('triggers.drop'); + + // 4.6 파티션 관리 + Route::get('/partitions', [TriggerAuditController::class, 'partitions'])->name('partitions'); + Route::post('/partitions/add', [TriggerAuditController::class, 'addPartitions'])->name('partitions.add'); + Route::post('/partitions/drop', [TriggerAuditController::class, 'dropPartition'])->name('partitions.drop'); + + // 기존 (동적 라우트는 정적 뒤에 배치) Route::get('/{id}', [TriggerAuditController::class, 'show'])->name('show')->whereNumber('id'); Route::get('/{id}/rollback-preview', [TriggerAuditController::class, 'rollbackPreview'])->name('rollback-preview')->whereNumber('id'); Route::post('/{id}/rollback', [TriggerAuditController::class, 'rollbackExecute'])->name('rollback-execute')->whereNumber('id');