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

48 KiB
Raw Blame History

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 테이블 구조

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

-- 특정 테이블에 대해 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 세션 변수 미들웨어

// 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 복구 서비스

// 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

외부 참고자료


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 주요 명령어

# 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
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
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
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
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
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
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)

// 도메인별 분리 구조
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
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)

<!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
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 뷰 패턴 (데이터 목록 화면)

@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)

// 인증 필수 라우트 그룹
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 전체 구현 방향

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 전체 테이블 일괄 트리거 생성 프로시저

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
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", ...)

환경 확인 명령어

# 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 스킬로 생성되었습니다.