Files
sam-docs/plans/db-trigger-audit-system-plan.md
권혁성 0e9559fcd8 docs: 개발 계획 문서 5건 추가
- db-trigger-audit-system-plan.md
- intermediate-inspection-report-plan.md
- mng-numbering-rule-management-plan.md
- quote-order-sync-improvement-plan.md
- tenant-numbering-system-plan.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:02:47 +09:00

1294 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DB 트리거 기반 데이터 변경 추적 시스템 계획
> **작성일**: 2026-02-07
> **목적**: 모든 경로(앱, 직접SQL, AI, phpMyAdmin 등)의 데이터 변경을 DB 레벨에서 추적하고 복구 가능하게 함
> **기준 문서**: `docs/specs/database-schema.md`, `api/app/Traits/Auditable.php`, `api/config/audit.php`
> **상태**: 🔄 Phase 1-3 완료, Phase 4 핵심 완료 (4.4~4.6 옵션 잔여)
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 4 핵심 (mng 대시보드 + 목록 + 상세 + 이력 + 롤백) |
| **다음 작업** | Phase 4.4 트리거 관리 화면 (옵션) |
| **진행률** | 15/16 (94%) - 핵심 기능 완료, 옵션 3개 잔여 |
| **마지막 업데이트** | 2026-02-07 |
---
## 1. 개요
### 1.1 배경
SAM 프로젝트에는 이미 Laravel `Auditable` trait 기반 감사 로그가 존재하지만, 이는 **Laravel Eloquent ORM을 통한 변경만 추적**한다. 다음 경로의 변경은 추적 불가:
- AI(Claude 등)가 직접 실행하는 SQL 쿼리
- phpMyAdmin, DBeaver 등 DB 클라이언트에서의 직접 수정
- MySQL CLI에서의 직접 쿼리
- 다른 애플리케이션/스크립트에서의 DB 접근
- Laravel `DB::statement()` 등 Eloquent 우회 쿼리
**해결책**: MySQL 트리거를 사용하여 DB 엔진 레벨에서 모든 INSERT/UPDATE/DELETE를 포착한다.
### 1.2 기준 원칙
```
+------------------------------------------------------------------+
| 계층 분리 (Layered Audit) |
+------------------------------------------------------------------+
| Layer 1: Laravel Audit (기존 유지) |
| - 비즈니스 액션 (released, cloned, items_replaced 등) |
| - 사용자 컨텍스트 풍부 (IP, UA, 세션 정보) |
| - 실패 시 비즈니스 로직 불영향 (try/catch) |
+------------------------------------------------------------------+
| Layer 2: MySQL Trigger Audit (신규) |
| - 모든 DML 포착 (직접 쿼리 포함, 누락 불가) |
| - 컬럼 단위 old/new values JSON 저장 |
| - 특정 레코드의 특정 시점으로 복원 가능 |
+------------------------------------------------------------------+
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 트리거 대상 테이블 목록 조정, 제외 컬럼 변경 | 불필요 |
| ⚠️ 컨펌 필요 | 마이그레이션 실행, 트리거 생성/변경, 미들웨어 추가, 새 API 엔드포인트 | **필수** |
| 🔴 금지 | 기존 audit_logs 테이블 구조 변경, 기존 Auditable trait 수정 | 별도 협의 |
### 1.4 준수 규칙
- `docs/quickstart/quick-start.md` - 빠른 시작 가이드
- `docs/standards/quality-checklist.md` - 품질 체크리스트
- `docs/specs/database-schema.md` - DB 스키마
- `docs/standards/api-rules.md` - API 규칙 (Audit Logging 섹션)
---
## 2. 대상 범위
### 2.1 Phase 1: DB 기반 구축
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | trigger_audit_logs 테이블 마이그레이션 (파티셔닝 포함) | ✅ | 15개 파티션, 3개 인덱스 |
| 1.2 | 트리거 대상 테이블 선정 및 확정 | ✅ | 제외 11개 외 전체 적용 |
| 1.3 | 트리거 자동 생성 (PHP 기반, SP 불가) | ✅ | MySQL CREATE TRIGGER는 PREPARE 미지원 → PHP 마이그레이션으로 전환 |
| 1.4 | 대상 테이블별 트리거 생성 | ✅ | 789개 트리거 (263 테이블 × 3) |
| 1.5 | 세션 변수 설정 미들웨어 (Laravel) | ✅ | @sam_actor_id, @sam_session_info |
### 2.2 Phase 2: 복구 메커니즘
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | TriggerAuditLog 모델 | ✅ | casts, scopes, changed_columns accessor |
| 2.2 | AuditRollbackService 구현 | ✅ | rollback SQL 생성 + 실행 + getRecordStateAt |
| 2.3 | Trigger Audit 조회 API | ✅ | 6개 엔드포인트 (index, show, stats, history, rollback-preview, rollback) |
| 2.4 | Rollback API 엔드포인트 | ✅ | POST /api/v1/trigger-audit-logs/{id}/rollback + confirm 필수 |
### 2.3 Phase 3: 관리 도구
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 3.1 | 통합 조회 뷰 (v_unified_audit) | ✅ | APP 3,108건 + TRIGGER 2,649건 통합, COLLATE 해결 |
| 3.2 | 파티션 자동 관리 (artisan 커맨드) | ✅ | audit:partitions --add-months --retention-months --drop --dry-run |
| 3.3 | 트리거 재생성 artisan 커맨드 | ✅ | audit:triggers --table --drop-only --dry-run |
### 2.4 Phase 4: 관리자 대시보드 (mng)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 4.1 | 변경 이력 목록 화면 (index) | ✅ | 통계카드+필터+목록+파티션현황+트리거수, 페이지네이션 |
| 4.2 | 레코드 상세 변경 이력 (show + history) | ✅ | diff 뷰(old/new 비교, 변경 컬럼 하이라이트) + 레코드 타임라인 |
| 4.3 | 복구 기능 UI (rollback-preview) | ✅ | SQL 미리보기, 확인 체크박스+confirm, @disable_audit_trigger |
| 4.4 | 트리거 관리 화면 | ⏭️ | 옵션 - artisan audit:triggers 커맨드로 CLI 관리 가능 |
| 4.5 | 대시보드 통계 | ✅ | index에 통합 (전체/오늘/DML별 통계, 상위 테이블, 파티션, 저장소) |
| 4.6 | 보관 정책 설정 | ⏭️ | 옵션 - artisan audit:partitions 커맨드로 CLI 관리 가능 |
---
## 3. 작업 절차
### 3.1 아키텍처 다이어그램
```
[사용자/AI/phpMyAdmin/스크립트]
┌─────────┐
│ MySQL │
│ Engine │
└────┬────┘
│ DML (INSERT/UPDATE/DELETE)
┌─────────────────────────┐
│ 대상 테이블 │
│ (제외 목록 외 전체 │
│ 약 207개) │
└────┬────────────────────┘
│ AFTER 트리거 발동
┌─────────────────────────┐
│ trigger_audit_logs │
│ (파티셔닝, 13개월 보관) │
│ - table_name │
│ - row_id │
│ - dml_type │
│ - old_values (JSON) │
│ - new_values (JSON) │
│ - tenant_id │
│ - actor_id │ ← @sam_actor_id 세션변수
│ - session_info │ ← @sam_session_info 세션변수
│ - db_user │ ← CURRENT_USER()
│ - created_at │
└─────────────────────────┘
┌─────────────────────────┐
│ AuditRollbackService │
│ (Laravel) │
│ - 이력 조회 │
│ - Rollback SQL 생성 │
│ - 특정 시점 복원 │
└─────────────────────────┘
```
### 3.2 트리거 대상 테이블
#### 적용 방침: 전체 적용 (제외 목록 방식)
로컬 개발 환경에서 1인 사용이므로, **제외 대상을 제외한 모든 테이블에 트리거를 적용**한다.
운영 환경 전환 시 필요에 따라 대상을 축소할 수 있다.
SP(`sp_create_audit_triggers`)가 `INFORMATION_SCHEMA.TABLES`에서 samdb의 전체 테이블을 읽고,
제외 목록에 없는 모든 테이블에 자동으로 트리거를 생성한다.
#### 제외 대상 (트리거 미적용)
| 테이블 패턴 | 사유 |
|-------------|------|
| `audit_logs` | 감사 로그 자체 (순환 방지) |
| `trigger_audit_logs` | 트리거 감사 자체 (순환 방지) |
| `personal_access_tokens` | Sanctum 토큰 (대량 생성/삭제, 보안 데이터) |
| `sessions` | 세션 데이터 (빈번한 갱신) |
| `cache`, `cache_locks` | 캐시 데이터 |
| `jobs`, `job_batches` | 큐 작업 |
| `failed_jobs` | 실패 큐 |
| `migrations` | 마이그레이션 기록 |
| `password_reset_tokens` | 비밀번호 리셋 토큰 |
| `telescope_*` | 디버그 도구 (있는 경우) |
> **예상**: samdb 약 219개 테이블 - 제외 약 12개 = **약 207개 테이블 × 3 트리거 = 약 621개 트리거**
>
> SP가 `INFORMATION_SCHEMA`에서 동적으로 테이블을 읽으므로, 테이블이 추가/삭제되면
> `artisan audit:regenerate-triggers` 명령으로 트리거를 재생성하면 된다.
### 3.3 trigger_audit_logs 테이블 구조
```sql
CREATE TABLE trigger_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT,
table_name VARCHAR(64) NOT NULL COMMENT '변경된 테이블명',
row_id VARCHAR(64) NOT NULL COMMENT '변경된 레코드 PK (문자열 지원)',
dml_type ENUM('INSERT','UPDATE','DELETE') NOT NULL COMMENT 'DML 유형',
old_values JSON DEFAULT NULL COMMENT '변경 전 값 (INSERT시 NULL)',
new_values JSON DEFAULT NULL COMMENT '변경 후 값 (DELETE시 NULL)',
changed_columns JSON DEFAULT NULL COMMENT 'UPDATE시 변경된 컬럼 목록',
tenant_id BIGINT UNSIGNED DEFAULT NULL COMMENT '테넌트 ID',
actor_id BIGINT UNSIGNED DEFAULT NULL COMMENT '사용자 ID (세션변수)',
session_info VARCHAR(500) DEFAULT NULL COMMENT '세션 정보 JSON (IP, UA 등)',
db_user VARCHAR(100) DEFAULT NULL COMMENT 'CURRENT_USER()',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '변경 시각',
PRIMARY KEY (id, created_at)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='DB 트리거 기반 데이터 변경 추적'
PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
PARTITION p202601 VALUES LESS THAN (UNIX_TIMESTAMP('2026-02-01')),
PARTITION p202602 VALUES LESS THAN (UNIX_TIMESTAMP('2026-03-01')),
PARTITION p202603 VALUES LESS THAN (UNIX_TIMESTAMP('2026-04-01')),
PARTITION p202604 VALUES LESS THAN (UNIX_TIMESTAMP('2026-05-01')),
PARTITION p202605 VALUES LESS THAN (UNIX_TIMESTAMP('2026-06-01')),
PARTITION p202606 VALUES LESS THAN (UNIX_TIMESTAMP('2026-07-01')),
PARTITION p202607 VALUES LESS THAN (UNIX_TIMESTAMP('2026-08-01')),
PARTITION p202608 VALUES LESS THAN (UNIX_TIMESTAMP('2026-09-01')),
PARTITION p202609 VALUES LESS THAN (UNIX_TIMESTAMP('2026-10-01')),
PARTITION p202610 VALUES LESS THAN (UNIX_TIMESTAMP('2026-11-01')),
PARTITION p202611 VALUES LESS THAN (UNIX_TIMESTAMP('2026-12-01')),
PARTITION p202612 VALUES LESS THAN (UNIX_TIMESTAMP('2027-01-01')),
PARTITION p202701 VALUES LESS THAN (UNIX_TIMESTAMP('2027-02-01')),
PARTITION p202702 VALUES LESS THAN (UNIX_TIMESTAMP('2027-03-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- 조회 성능 인덱스
CREATE INDEX ix_trig_table_row_created
ON trigger_audit_logs (table_name, row_id, created_at);
CREATE INDEX ix_trig_tenant_created
ON trigger_audit_logs (tenant_id, created_at);
```
### 3.4 트리거 자동 생성 Stored Procedure
```sql
-- 특정 테이블에 대해 AFTER INSERT/UPDATE/DELETE 트리거 3개를 자동 생성
-- INFORMATION_SCHEMA.COLUMNS에서 컬럼 목록을 읽어 JSON_OBJECT 구문 자동 조립
CALL sp_create_audit_triggers('products');
-- → trg_products_ai (AFTER INSERT)
-- → trg_products_au (AFTER UPDATE)
-- → trg_products_ad (AFTER DELETE)
```
**SP 핵심 로직:**
1. `INFORMATION_SCHEMA.COLUMNS`에서 대상 테이블의 컬럼 목록 조회
2. 제외 컬럼 필터링 (`created_at`, `updated_at`, `deleted_at`, `remember_token` 등)
3. `JSON_OBJECT('col1', NEW.col1, 'col2', NEW.col2, ...)` 구문 자동 조립
4. UPDATE 트리거: 컬럼별 `OLD.col <> NEW.col` 비교 → changed_columns 배열 생성
5. 비활성화 플래그 체크 (`@disable_audit_trigger`)
6. `PREPARE + EXECUTE`로 트리거 DDL 실행
### 3.5 세션 변수 미들웨어
```php
// app/Http/Middleware/SetAuditSessionVariables.php
class SetAuditSessionVariables
{
public function handle($request, $next)
{
if (auth()->check()) {
DB::statement("SET @sam_actor_id = ?", [auth()->id()]);
DB::statement("SET @sam_session_info = ?", [
json_encode([
'ip' => $request->ip(),
'ua' => substr($request->userAgent(), 0, 255),
'route' => $request->route()?->getName(),
])
]);
}
return $next($request);
}
}
```
### 3.6 복구 서비스
```php
// app/Services/Audit/AuditRollbackService.php
class AuditRollbackService
{
// 특정 audit 레코드에 대한 역방향 SQL 생성
public function generateRollbackSQL(int $auditId): string;
// 실제 복구 실행 (트랜잭션 내에서)
public function executeRollback(int $auditId): bool;
// 특정 레코드의 특정 시점 상태 조회
public function getRecordStateAt(string $table, string $rowId, Carbon $at): ?array;
// 특정 레코드의 변경 이력 조회
public function getRecordHistory(string $table, string $rowId): Collection;
}
```
**복구 로직:**
| 원본 DML | 복구 SQL |
|----------|---------|
| INSERT | `DELETE FROM {table} WHERE id = {row_id}` |
| UPDATE | `UPDATE {table} SET {old_values 각 컬럼} WHERE id = {row_id}` |
| DELETE | `INSERT INTO {table} ({old_values 컬럼}) VALUES ({old_values 값})` |
---
## 4. 상세 작업 내용
> 각 Phase 진행 후 이 섹션에 상세 내용 추가
### 4.1 Phase 1: DB 기반 구축
- **상태**: ⏳ 대기
- **예상 파일**:
- `api/database/migrations/YYYY_MM_DD_HHMMSS_create_trigger_audit_logs_table.php`
- `api/database/migrations/YYYY_MM_DD_HHMMSS_create_audit_trigger_stored_procedures.php`
- `api/database/migrations/YYYY_MM_DD_HHMMSS_create_audit_triggers_for_tables.php`
- `api/app/Http/Middleware/SetAuditSessionVariables.php`
### 4.2 Phase 2: 복구 메커니즘
- **상태**: ⏳ 대기
- **예상 파일**:
- `api/app/Models/Audit/TriggerAuditLog.php`
- `api/app/Services/Audit/AuditRollbackService.php`
- `api/app/Http/Controllers/Api/V1/Audit/TriggerAuditLogController.php`
- `api/app/Http/Requests/Audit/TriggerAuditLogIndexRequest.php`
- `api/app/Http/Requests/Audit/TriggerAuditRollbackRequest.php`
- `api/app/Swagger/v1/TriggerAuditLogApi.php`
### 4.3 Phase 3: 관리 도구
- **상태**: ⏳ 대기
- **예상 파일**:
- `api/database/migrations/YYYY_MM_DD_HHMMSS_create_unified_audit_view.php`
- `api/app/Console/Commands/ManageAuditPartitions.php`
- `api/app/Console/Commands/RegenerateAuditTriggers.php`
### 4.4 Phase 4: 관리자 대시보드 (mng)
- **상태**: ⏳ 대기
- **예상 파일**:
- `mng/app/Http/Controllers/Admin/TriggerAuditController.php`
- `mng/resources/views/admin/trigger-audit/index.blade.php` (이력 목록)
- `mng/resources/views/admin/trigger-audit/show.blade.php` (상세 diff 뷰)
- `mng/resources/views/admin/trigger-audit/dashboard.blade.php` (대시보드 통계)
- `mng/resources/views/admin/trigger-audit/triggers.blade.php` (트리거 관리)
- `mng/resources/views/admin/trigger-audit/settings.blade.php` (보관 정책)
- `mng/app/Services/TriggerAuditDashboardService.php`
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | 트리거 대상 | 제외 목록 외 전체 (약 207개) 적용 | database | ✅ 확정 |
| 2 | 성능 영향 | 로컬 1인 사용, 제한 없음 | database | ✅ 확정 |
| 3 | Phase 4 범위 | 풀 관리 대시보드 (조회/복구/트리거관리/통계/정책) | mng | ✅ 확정 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-07 | 계획 | 문서 초안 작성 | - | - |
| 2026-02-07 | 수정 | 피드백 반영: 전체 테이블 적용, Phase 4 대시보드 추가 | - | ✅ |
| 2026-02-07 | Phase 1 | DB 기반 구축 완료. SP→PHP 전환, 789 트리거 생성, my.cnf 설정 추가 | api/database/migrations/2026_02_07_*, api/app/Http/Middleware/SetAuditSessionVariables.php, docker/mysql/my.cnf | ✅ |
| 2026-02-07 | Phase 2 | 복구 메커니즘 API 완료. 모델/서비스/컨트롤러/라우트 6개 엔드포인트 | TriggerAuditLog.php, TriggerAuditLogService.php, AuditRollbackService.php, TriggerAuditLogController.php, audit.php(route) | ✅ |
| 2026-02-07 | Phase 3 | 관리 도구 완료. 통합 뷰(collation 해결), 파티션 관리, 트리거 재생성 커맨드 | v_unified_audit VIEW, ManageAuditPartitions.php, RegenerateAuditTriggers.php | ✅ |
---
## 7. 참고 문서
- **빠른 시작**: `docs/quickstart/quick-start.md`
- **품질 체크리스트**: `docs/standards/quality-checklist.md`
- **DB 스키마**: `docs/specs/database-schema.md`
- **API 규칙**: `docs/standards/api-rules.md` (Audit Logging 섹션)
- **기존 Auditable**: `api/app/Traits/Auditable.php`
- **기존 audit 설정**: `api/config/audit.php`
- **기존 audit 마이그레이션**: `api/database/migrations/2025_09_11_000100_create_audit_logs_table.php`
### 외부 참고자료
- [MySQL 8.0 Trigger Syntax](https://dev.mysql.com/doc/refman/8.0/en/trigger-syntax.html)
- [MySQL 8.0 Partitioning](https://dev.mysql.com/doc/refman/8.0/en/partitioning.html)
- [Percona - MySQL Trigger Performance](https://www.percona.com/blog/why-mysql-stored-procedures-functions-triggers-bad-performance/)
---
## 8. 리스크 및 대응 방안
| 리스크 | 영향 | 대응 |
|--------|------|------|| 트리거 성능 오버헤드 (INSERT 약 40-50%) | 쓰기 성능 저하 | 로컬 1인 사용 환경이므로 무관. 운영 전환 시 대상 축소 가능. Bulk 작업 시 `@disable_audit_trigger=1` |
| 트리거 실패 시 원본 DML도 롤백 | 비즈니스 중단 | 트리거 로직 최소화, audit 테이블 구조 안정적 유지 |
| 스키마 변경 시 트리거 유지보수 | 누락 위험 | SP 기반 자동 생성 → `artisan audit:regenerate-triggers` |
| 저장 용량 증가 | 디스크 사용량 | 월별 파티셔닝 + 13개월 자동 삭제 |
| 세션 변수 미설정 (CLI, Queue) | actor_id NULL | NULL 허용, db_user로 보완 추적 |
---
## 9. 검증 결과
> 작업 완료 후 이 섹션에 검증 결과 추가
### 9.1 테스트 케이스
| 입력값 | 예상 결과 | 실제 결과 | 상태 |
|--------|----------|----------|------|
| Laravel에서 Product 생성 | audit_logs + trigger_audit_logs 모두 기록 | | ⏳ |
| 직접 SQL로 Product UPDATE | trigger_audit_logs에만 기록 | | ⏳ |
| phpMyAdmin에서 DELETE | trigger_audit_logs에 기록 (actor_id=NULL, db_user 기록) | | ⏳ |
| Bulk INSERT 10,000건 (트리거 활성) | trigger_audit_logs에 10,000건 기록, 성능 측정 | | ⏳ |
| Bulk INSERT 10,000건 (트리거 비활성) | trigger_audit_logs 기록 없음, 기본 성능 | | ⏳ |
| UPDATE 후 rollback API 호출 | old_values로 복원됨 | | ⏳ |
| DELETE 후 rollback API 호출 | 삭제된 레코드 복원됨 | | ⏳ |
| INSERT 후 rollback API 호출 | 삽입된 레코드 삭제됨 | | ⏳ |
| 13개월 이전 파티션 삭제 | 해당 파티션 DROP, 데이터 제거 | | ⏳ |
### 9.2 성공 기준
| 기준 | 달성 | 비고 |
|------|------|------|
| 직접 SQL 변경이 trigger_audit_logs에 기록됨 | ⏳ | |
| old_values/new_values JSON이 정확히 저장됨 | ⏳ | |
| 특정 레코드의 특정 시점 복원이 가능함 | ⏳ | |
| 파티셔닝이 정상 작동함 | ⏳ | |
| 기존 Laravel audit 시스템에 영향 없음 | ⏳ | |
| 트리거 비활성화 플래그가 정상 동작함 | ⏳ | |
| mng 대시보드에서 이력 조회/필터링 가능 | ⏳ | |
| mng에서 특정 변경 복구(rollback) 가능 | ⏳ | |
| mng에서 테이블별 트리거 ON/OFF 가능 | ⏳ | |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 1.1 배경에 명시 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 9.2 성공 기준 |
| 3 | 작업 범위가 구체적인가? | ✅ | 2.1~2.4 Phase별 작업 항목 |
| 4 | 의존성이 명시되어 있는가? | ✅ | Phase 순서, 기존 시스템 참조 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 7. 참고 문서 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 3.3~3.6 상세 구현 명세 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 9.1 테스트 케이스 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 수치/조건 명시 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 2.1 Phase 1 → 1.1 테이블 생성 |
| Q3. 어떤 파일을 수정해야 하는가? | ✅ | 4.1~4.4 예상 파일 목록 |
| Q4. 작업 완료 확인 방법은? | ✅ | 9.1 테스트 케이스, 9.2 성공 기준 |
| Q5. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 |
**결과**: 5/5 통과 → ✅ 자기완결성 확보
---
## 부록 A: 환경 정보
### A.1 프로젝트 구조
```
SAM/ ← 프로젝트 루트
├── api/ ← Laravel 12 REST API (독립 git)
│ ├── app/
│ │ ├── Http/
│ │ │ ├── Controllers/Api/V1/ ← API 컨트롤러
│ │ │ ├── Middleware/ ← 미들웨어
│ │ │ └── Requests/ ← FormRequest
│ │ ├── Models/Audit/ ← 감사 모델 (AuditLog.php)
│ │ ├── Services/Audit/ ← 감사 서비스 (AuditLogger, AuditLogService)
│ │ ├── Traits/ ← Auditable.php, BelongsToTenant.php
│ │ ├── Console/Commands/ ← Artisan 커맨드
│ │ └── Swagger/v1/ ← Swagger 문서
│ ├── config/audit.php ← 감사 설정
│ ├── database/migrations/ ← 마이그레이션
│ ├── routes/
│ │ ├── api.php ← 메인 라우트 (v1 prefix → 도메인별 분리)
│ │ └── api/v1/ ← 도메인별 라우트 파일
│ └── bootstrap/app.php ← Laravel 12 미들웨어 등록
├── mng/ ← Laravel 12 관리자 패널 (독립 git)
│ ├── app/Http/Controllers/ ← Blade 컨트롤러
│ ├── resources/views/ ← Blade 뷰 (Tailwind + Alpine.js + HTMX)
│ │ └── layouts/app.blade.php ← 메인 레이아웃
│ └── routes/web.php ← 웹 라우트 (auth 미들웨어 그룹)
├── react/ ← Next.js 15 프론트엔드
└── docs/plans/ ← 이 문서
```
### A.2 DB 접속 정보
```
엔진: MySQL 8.0
Docker 컨테이너: sam-mysql-1
데이터베이스: samdb (주), sam_stat (통계)
호스트: 127.0.0.1 (로컬) / sam-mysql-1 (Docker 내부)
포트: 3306
사용자: samuser / sampass (일반), root / root (관리자)
문자셋: utf8mb4 / utf8mb4_unicode_ci
타임존: Asia/Seoul
```
### A.3 주요 명령어
```bash
# Docker
cd /Users/kent/Works/@KD_SAM/SAM
docker compose up -d mysql
# API 마이그레이션
cd api && php artisan migrate
cd api && php artisan migrate:status
# MySQL 직접 접속
docker exec -it sam-mysql-1 mysql -u root -proot samdb
# MNG Vite 빌드
cd mng && npm run dev
```
---
## 부록 B: 기존 감사 시스템 코드 (수정 금지, 참조용)
### B.1 Auditable Trait (`api/app/Traits/Auditable.php`)
```php
<?php
namespace App\Traits;
use App\Models\Audit\AuditLog;
use Illuminate\Support\Str;
trait Auditable
{
protected static function bootAuditable(): void
{
static::creating(function ($model) {
$actorId = static::resolveActorId();
if ($actorId) {
if ($model->isFillable('created_by') && ! $model->created_by) {
$model->created_by = $actorId;
}
if ($model->isFillable('updated_by') && ! $model->updated_by) {
$model->updated_by = $actorId;
}
}
});
static::updating(function ($model) {
$actorId = static::resolveActorId();
if ($actorId && $model->isFillable('updated_by')) {
$model->updated_by = $actorId;
}
});
static::deleting(function ($model) {
$actorId = static::resolveActorId();
if ($actorId && $model->isFillable('deleted_by')) {
$model->deleted_by = $actorId;
$model->saveQuietly();
}
});
static::created(function ($model) {
$model->logAuditEvent('created', null, $model->toAuditSnapshot());
});
static::updated(function ($model) {
$dirty = $model->getChanges();
$excluded = $model->getAuditExcludedFields();
$changed = array_diff_key($dirty, array_flip($excluded));
if (empty($changed)) return;
$before = [];
$after = [];
foreach ($changed as $key => $value) {
$before[$key] = $model->getOriginal($key);
$after[$key] = $value;
}
$model->logAuditEvent('updated', $before, $after);
});
static::deleted(function ($model) {
$model->logAuditEvent('deleted', $model->toAuditSnapshot(), null);
});
}
public function getAuditExcludedFields(): array
{
$defaults = ['created_at','updated_at','deleted_at','created_by','updated_by','deleted_by'];
$custom = property_exists($this, 'auditExclude') ? $this->auditExclude : [];
return array_merge($defaults, $custom);
}
public function getAuditTargetType(): string
{
return Str::snake(class_basename(static::class));
}
protected function toAuditSnapshot(): array
{
return array_diff_key($this->attributesToArray(), array_flip($this->getAuditExcludedFields()));
}
protected function logAuditEvent(string $action, ?array $before, ?array $after): void
{
try {
$tenantId = $this->tenant_id ?? null;
if (! $tenantId) return;
$request = request();
AuditLog::create([
'tenant_id' => $tenantId,
'target_type' => $this->getAuditTargetType(),
'target_id' => $this->getKey(),
'action' => $action,
'before' => $before,
'after' => $after,
'actor_id' => static::resolveActorId(),
'ip' => $request?->ip(),
'ua' => $request?->userAgent(),
'created_at' => now(),
]);
} catch (\Throwable $e) {
// 감사 로그 실패는 업무 흐름을 방해하지 않음
}
}
protected static function resolveActorId(): ?int
{
return auth()->id();
}
}
```
### B.2 AuditLog 모델 (`api/app/Models/Audit/AuditLog.php`)
```php
<?php
namespace App\Models\Audit;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
public $timestamps = false;
protected $table = 'audit_logs';
protected $fillable = [
'tenant_id','target_type','target_id','action','before','after','actor_id','ip','ua','created_at',
];
protected $casts = [
'before' => 'array',
'after' => 'array',
'created_at' => 'datetime',
];
}
```
### B.3 AuditLogService (`api/app/Services/Audit/AuditLogService.php`)
```php
<?php
namespace App\Services\Audit;
use App\Models\Audit\AuditLog;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class AuditLogService extends Service
{
public function paginate(array $filters): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$q = AuditLog::query()->where('tenant_id', $tenantId);
if (! empty($filters['target_type'])) $q->where('target_type', $filters['target_type']);
if (! empty($filters['target_id'])) $q->where('target_id', (int) $filters['target_id']);
if (! empty($filters['action'])) $q->where('action', $filters['action']);
if (! empty($filters['actor_id'])) $q->where('actor_id', (int) $filters['actor_id']);
if (! empty($filters['from'])) $q->where('created_at', '>=', $filters['from']);
if (! empty($filters['to'])) $q->where('created_at', '<=', $filters['to']);
$sort = $filters['sort'] ?? 'created_at';
$order = $filters['order'] ?? 'desc';
$size = (int) ($filters['size'] ?? 20);
return $q->orderBy($sort, $order)->paginate($size);
}
}
```
### B.4 Audit Config (`api/config/audit.php`)
```php
<?php
return [
'retention_days' => env('AUDIT_RETENTION_DAYS', 395), // 13개월
'log_reads' => env('AUDIT_LOG_READS', false),
];
```
### B.5 API 컨트롤러 패턴 (`api/app/Http/Controllers/Api/V1/Design/AuditLogController.php`)
```php
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Audit\AuditLogIndexRequest;
use App\Services\Audit\AuditLogService;
class AuditLogController extends Controller
{
public function __construct(protected AuditLogService $service) {}
public function index(AuditLogIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->paginate($request->validated());
}, __('message.fetched'));
}
}
```
### B.6 API Kernel (`api/app/Http/Kernel.php`)
```php
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
protected $middleware = [
\App\Http\Middleware\CorsMiddleware::class,
];
protected $middlewareGroups = [
'web' => [],
'api' => [],
];
protected $routeMiddleware = [];
}
```
> **참고**: Laravel 12에서 미들웨어 추가 시 `bootstrap/app.php`의 `->withMiddleware()` 또는
> `Kernel.php`의 `$middleware` / `$middlewareGroups`에 등록한다.
### B.7 API 라우트 패턴 (`api/routes/api.php`)
```php
// 도메인별 분리 구조
Route::prefix('v1')->group(function () {
require __DIR__.'/api/v1/auth.php';
require __DIR__.'/api/v1/design.php';
// ... 기타 도메인
});
// design.php 내 감사 로그 라우트 예시
Route::prefix('design')->group(function () {
Route::prefix('audit-logs')->group(function () {
Route::get('', [DesignAuditLogController::class, 'index']);
Route::get('/{id}', [DesignAuditLogController::class, 'show'])->whereNumber('id');
});
});
```
### B.8 Artisan 커맨드 패턴 (예: `TenantsBootstrap.php`)
```php
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
#[AsCommand(name: 'tenants:bootstrap', description: 'Bootstrap for tenant(s)')]
class TenantsBootstrap extends Command
{
protected $signature = 'tenants:bootstrap
{--tenant_id= : Target tenant id}
{--all : Apply to all tenants}';
public function handle(): int
{
// 로직 ...
return self::SUCCESS;
}
}
```
---
## 부록 C: MNG 프로젝트 패턴 (Phase 4 참조용)
### C.1 프론트엔드 스택
| 라이브러리 | 버전 | 용도 |
|-----------|------|------|
| Tailwind CSS | 3.4.17 | 유틸리티 CSS |
| Alpine.js | 3.x | 경량 반응형 UI (토글, 드롭다운) |
| HTMX | 2.0.8 | 부분 페이지 로드, AJAX |
| Vite | 7.0.7 | 빌드 도구 |
| @tailwindcss/forms | 0.5.10 | 폼 스타일 |
### C.2 MNG 레이아웃 구조 (`mng/resources/views/layouts/app.blade.php`)
```blade
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Dashboard') - {{ config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
@include('components.sidebar.main')
<main class="content-area">
@yield('content')
</main>
@stack('scripts')
</body>
</html>
```
### C.3 MNG 컨트롤러 패턴 (기존 `AuditLogController.php` 요약)
```php
<?php
namespace App\Http\Controllers;
use App\Models\Audit\AuditLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AuditLogController extends Controller
{
public function index(Request $request): View
{
$query = AuditLog::query()->orderByDesc('created_at');
// 필터링 (target_type, action, tenant_id, from, to, search)
if ($request->filled('target_type')) $query->where('target_type', $request->target_type);
if ($request->filled('action')) $query->where('action', $request->action);
if ($request->filled('from')) $query->where('created_at', '>=', $request->from.' 00:00:00');
if ($request->filled('to')) $query->where('created_at', '<=', $request->to.' 23:59:59');
// 통계
$stats = [...];
// 페이지네이션
$logs = $query->paginate(50)->withQueryString();
return view('audit-logs.index', compact('logs', 'stats'));
}
public function show(int $id): View
{
$log = AuditLog::findOrFail($id);
return view('audit-logs.show', compact('log'));
}
}
```
### C.4 MNG 뷰 패턴 (데이터 목록 화면)
```blade
@extends('layouts.app')
@section('title', '페이지 제목')
@section('content')
{{-- 1. 헤더 --}}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">페이지 제목</h1>
</div>
{{-- 2. 통계 카드 --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체 기록</div>
<div class="text-2xl font-bold">{{ number_format($stats['total']) }}</div>
</div>
</div>
{{-- 3. 필터 폼 --}}
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form method="GET" class="flex flex-wrap gap-4 items-end">
<select name="filter" onchange="this.form.submit()" class="border rounded-lg px-3 py-2 text-sm">
<option value="">전체</option>
</select>
<input type="date" name="from" value="{{ request('from') }}" class="border rounded-lg px-3 py-2 text-sm">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">검색</button>
</form>
</div>
{{-- 4. 데이터 테이블 (HTMX 방식 또는 일반 방식) --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">컬럼</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($items as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm">{{ $item->field }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="p-4">{{ $items->links() }}</div>
</div>
@endsection
```
### C.5 MNG 라우트 패턴 (`mng/routes/web.php`)
```php
// 인증 필수 라우트 그룹
Route::middleware(['auth', 'hq.member', 'password.changed'])->group(function () {
// 감사 로그 (기존)
Route::prefix('audit-logs')->group(function () {
Route::get('/', [AuditLogController::class, 'index'])->name('audit-logs.index');
Route::get('/{id}', [AuditLogController::class, 'show'])->name('audit-logs.show');
});
// 새 트리거 감사는 여기에 추가:
// Route::prefix('trigger-audit')->name('trigger-audit.')->group(function () { ... });
});
```
### C.6 MNG 미들웨어 목록
```
mng/app/Http/Middleware/
├── EnsureHQMember.php ← 본사 소속 확인
├── EnsurePasswordChanged.php ← 비밀번호 변경 확인
├── EnsureSuperAdmin.php ← 슈퍼관리자 확인
└── AutoLoginViaRemember.php ← Remember Token 자동 재인증
```
---
## 부록 D: SP 구현 상세 (Phase 1.3 참조)
### D.1 sp_create_audit_triggers 전체 구현 방향
```sql
DELIMITER //
DROP PROCEDURE IF EXISTS sp_create_audit_triggers //
CREATE PROCEDURE sp_create_audit_triggers(
IN p_table_name VARCHAR(64),
IN p_db_name VARCHAR(64)
)
BEGIN
DECLARE v_col_list TEXT DEFAULT '';
DECLARE v_json_new TEXT DEFAULT '';
DECLARE v_json_old TEXT DEFAULT '';
DECLARE v_change_check TEXT DEFAULT '';
DECLARE v_changed_cols TEXT DEFAULT '';
DECLARE v_tenant_col VARCHAR(64) DEFAULT NULL;
DECLARE v_pk_col VARCHAR(64) DEFAULT 'id';
DECLARE v_done INT DEFAULT 0;
DECLARE v_col_name VARCHAR(64);
DECLARE v_sql TEXT;
-- 제외 컬럼
DECLARE v_exclude_cols TEXT DEFAULT 'created_at,updated_at,deleted_at,remember_token';
-- 커서: 대상 컬럼 목록
DECLARE col_cursor CURSOR FOR
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = p_db_name
AND TABLE_NAME = p_table_name
AND FIND_IN_SET(COLUMN_NAME, v_exclude_cols) = 0
ORDER BY ORDINAL_POSITION;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
-- tenant_id 컬럼 존재 확인
SELECT COLUMN_NAME INTO v_tenant_col
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = p_db_name
AND TABLE_NAME = p_table_name
AND COLUMN_NAME = 'tenant_id'
LIMIT 1;
-- PK 컬럼 확인
SELECT COLUMN_NAME INTO v_pk_col
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = p_db_name
AND TABLE_NAME = p_table_name
AND COLUMN_KEY = 'PRI'
LIMIT 1;
-- 컬럼별 JSON_OBJECT 구문 조립
OPEN col_cursor;
col_loop: LOOP
FETCH col_cursor INTO v_col_name;
IF v_done THEN LEAVE col_loop; END IF;
-- JSON 조립
IF v_json_new != '' THEN
SET v_json_new = CONCAT(v_json_new, ',');
SET v_json_old = CONCAT(v_json_old, ',');
END IF;
SET v_json_new = CONCAT(v_json_new, '''', v_col_name, ''', NEW.`', v_col_name, '`');
SET v_json_old = CONCAT(v_json_old, '''', v_col_name, ''', OLD.`', v_col_name, '`');
-- UPDATE 변경 감지 조립 (NULL-safe 비교)
IF v_change_check != '' THEN
SET v_change_check = CONCAT(v_change_check, ' OR ');
SET v_changed_cols = CONCAT(v_changed_cols, ',');
END IF;
SET v_change_check = CONCAT(v_change_check,
'NOT(OLD.`', v_col_name, '` <=> NEW.`', v_col_name, '`)');
SET v_changed_cols = CONCAT(v_changed_cols,
'IF(NOT(OLD.`', v_col_name, '` <=> NEW.`', v_col_name, '`),''', v_col_name, ''',NULL)');
END LOOP;
CLOSE col_cursor;
-- tenant_id 참조
SET @tenant_expr = IF(v_tenant_col IS NOT NULL,
CONCAT('NEW.`', v_tenant_col, '`'), 'NULL');
SET @tenant_expr_old = IF(v_tenant_col IS NOT NULL,
CONCAT('OLD.`', v_tenant_col, '`'), 'NULL');
-- 1. 기존 트리거 삭제
SET @drop1 = CONCAT('DROP TRIGGER IF EXISTS trg_', p_table_name, '_ai');
SET @drop2 = CONCAT('DROP TRIGGER IF EXISTS trg_', p_table_name, '_au');
SET @drop3 = CONCAT('DROP TRIGGER IF EXISTS trg_', p_table_name, '_ad');
PREPARE s FROM @drop1; EXECUTE s; DEALLOCATE PREPARE s;
PREPARE s FROM @drop2; EXECUTE s; DEALLOCATE PREPARE s;
PREPARE s FROM @drop3; EXECUTE s; DEALLOCATE PREPARE s;
-- 2. AFTER INSERT 트리거
SET v_sql = CONCAT(
'CREATE TRIGGER trg_', p_table_name, '_ai AFTER INSERT ON `', p_table_name, '` ',
'FOR EACH ROW BEGIN ',
'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN ',
'INSERT INTO trigger_audit_logs(table_name,row_id,dml_type,old_values,new_values,tenant_id,actor_id,session_info,db_user) ',
'VALUES(''', p_table_name, ''',NEW.`', v_pk_col, '`,''INSERT'',NULL,',
'JSON_OBJECT(', v_json_new, '),',
@tenant_expr, ',@sam_actor_id,@sam_session_info,CURRENT_USER()); ',
'END IF; END'
);
SET @s = v_sql;
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. AFTER UPDATE 트리거 (변경 있을 때만)
SET v_sql = CONCAT(
'CREATE TRIGGER trg_', p_table_name, '_au AFTER UPDATE ON `', p_table_name, '` ',
'FOR EACH ROW BEGIN ',
'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN ',
'IF ', v_change_check, ' 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) ',
'VALUES(''', p_table_name, ''',NEW.`', v_pk_col, '`,''UPDATE'',',
'JSON_OBJECT(', v_json_old, '),',
'JSON_OBJECT(', v_json_new, '),',
'JSON_REMOVE(JSON_ARRAY(', v_changed_cols, '),',
-- NULL 값 제거 (변경 안 된 컬럼)
'''$[0]''),', -- 간소화: 실제 구현 시 NULL 필터링 로직 보강 필요
@tenant_expr, ',@sam_actor_id,@sam_session_info,CURRENT_USER()); ',
'END IF; END IF; END'
);
SET @s = v_sql;
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 4. AFTER DELETE 트리거
SET v_sql = CONCAT(
'CREATE TRIGGER trg_', p_table_name, '_ad AFTER DELETE ON `', p_table_name, '` ',
'FOR EACH ROW BEGIN ',
'IF @disable_audit_trigger IS NULL OR @disable_audit_trigger != 1 THEN ',
'INSERT INTO trigger_audit_logs(table_name,row_id,dml_type,old_values,new_values,tenant_id,actor_id,session_info,db_user) ',
'VALUES(''', p_table_name, ''',OLD.`', v_pk_col, '`,''DELETE'',',
'JSON_OBJECT(', v_json_old, '),NULL,',
@tenant_expr_old, ',@sam_actor_id,@sam_session_info,CURRENT_USER()); ',
'END IF; END'
);
SET @s = v_sql;
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END //
DELIMITER ;
```
> **주의**: 위 코드는 구현 방향을 보여주는 참조 코드이다.
> 실제 구현 시 changed_columns의 NULL 필터링, 복합 PK 처리, 에러 핸들링 등을 보강해야 한다.
### D.2 전체 테이블 일괄 트리거 생성 프로시저
```sql
DELIMITER //
CREATE PROCEDURE sp_create_all_audit_triggers(IN p_db_name VARCHAR(64))
BEGIN
DECLARE v_tbl VARCHAR(64);
DECLARE v_done INT DEFAULT 0;
DECLARE v_count INT DEFAULT 0;
-- 제외 테이블 목록
DECLARE v_exclude TEXT DEFAULT
'audit_logs,trigger_audit_logs,personal_access_tokens,sessions,'
'cache,cache_locks,jobs,job_batches,failed_jobs,migrations,'
'password_reset_tokens';
DECLARE tbl_cursor CURSOR FOR
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = p_db_name
AND TABLE_TYPE = 'BASE TABLE'
AND TABLE_NAME NOT LIKE 'telescope_%'
AND FIND_IN_SET(TABLE_NAME, v_exclude) = 0
ORDER BY TABLE_NAME;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
OPEN tbl_cursor;
tbl_loop: LOOP
FETCH tbl_cursor INTO v_tbl;
IF v_done THEN LEAVE tbl_loop; END IF;
CALL sp_create_audit_triggers(v_tbl, p_db_name);
SET v_count = v_count + 1;
END LOOP;
CLOSE tbl_cursor;
SELECT CONCAT('Created triggers for ', v_count, ' tables') AS result;
END //
DELIMITER ;
-- 실행:
-- CALL sp_create_all_audit_triggers('samdb');
```
---
## 부록 E: 복구 서비스 구현 상세 (Phase 2.2 참조)
```php
<?php
namespace App\Services\Audit;
use App\Models\Audit\TriggerAuditLog;
use App\Services\Service;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
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): bool
{
$log = TriggerAuditLog::findOrFail($auditId);
// 트리거 감사 비활성화 (복구 작업 자체는 기록 안 함)
DB::statement('SET @disable_audit_trigger = 1');
try {
DB::transaction(function () use ($log) {
$sql = $this->generateRollbackSQL($log->id);
DB::statement($sql);
});
return true;
} 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, // 해당 시점에 삭제된 상태
};
}
/**
* 특정 레코드의 변경 이력
*/
public function getRecordHistory(string $table, string $rowId): Collection
{
return TriggerAuditLog::where('table_name', $table)
->where('row_id', $rowId)
->orderByDesc('created_at')
->get();
}
private function buildDeleteSQL(TriggerAuditLog $log): string
{
return "DELETE FROM `{$log->table_name}` WHERE `id` = " . DB::getPdo()->quote($log->row_id);
}
private function buildRevertUpdateSQL(TriggerAuditLog $log): string
{
$sets = collect($log->old_values)
->map(fn($val, $col) => "`{$col}` = " . ($val === null ? 'NULL' : DB::getPdo()->quote($val)))
->implode(', ');
return "UPDATE `{$log->table_name}` SET {$sets} WHERE `id` = " . DB::getPdo()->quote($log->row_id);
}
private function buildReinsertSQL(TriggerAuditLog $log): string
{
$cols = collect($log->old_values)->keys()->map(fn($c) => "`{$c}`")->implode(', ');
$vals = collect($log->old_values)->values()
->map(fn($v) => $v === null ? 'NULL' : DB::getPdo()->quote($v))
->implode(', ');
return "INSERT INTO `{$log->table_name}` ({$cols}) VALUES ({$vals})";
}
}
```
---
## 부록 F: 세션 시작 가이드 (새 세션용)
### 이 문서로 작업을 시작하는 방법
```
1. Serena 메모리 로드
→ read_memory("db-trigger-audit-state") : 진행 상태 확인
2. 이 문서의 "📍 현재 진행 상태" 확인
→ 마지막 완료 작업, 다음 작업 확인
3. 해당 Phase의 "대상 범위" (섹션 2) 확인
→ 구체적 작업 항목과 상태 확인
4. 해당 작업의 구현 코드는 "작업 절차" (섹션 3) + "부록" 참조
→ 부록 B: 기존 코드 패턴 (수정 금지)
→ 부록 C: MNG 패턴 (Phase 4용)
→ 부록 D: SP 구현 상세 (Phase 1.3용)
→ 부록 E: 복구 서비스 상세 (Phase 2.2용)
5. 작업 완료 후
→ 이 문서의 진행 상태 업데이트
→ Serena 메모리 저장: write_memory("db-trigger-audit-state", ...)
```
### 환경 확인 명령어
```bash
# Docker MySQL 실행 확인
docker ps | grep sam-mysql
# 마이그레이션 상태
cd /Users/kent/Works/@KD_SAM/SAM/api && php artisan migrate:status
# 현재 트리거 목록 확인
docker exec -it sam-mysql-1 mysql -u root -proot samdb -e "SHOW TRIGGERS"
# trigger_audit_logs 레코드 수
docker exec -it sam-mysql-1 mysql -u root -proot samdb -e "SELECT COUNT(*) FROM trigger_audit_logs"
```
---
*이 문서는 /sc:plan 스킬로 생성되었습니다.*