본문으로 건너뛰기

Audit 모듈

📝 초안 (Draft)

이 문서는 검토 중입니다. 내용이 변경될 수 있습니다.

모델 변경 이력을 자동으로 기록하는 감사 로그 Core 모듈입니다.

개요

Audit 모듈은 모델의 CRUD 작업을 자동으로 추적하여 감사 로그를 생성합니다. Auditable Trait을 적용한 모델은 생성, 수정, 삭제 시 자동으로 변경 이력이 기록됩니다.

┌─────────────────────────────────────────────────────────────┐
│ Audit 모듈 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Model (Auditable Trait) │ │
│ │ │ │
│ │ create() ──┐ │ │
│ │ update() ──┼──► AuditObserver ──► AuditLog │ │
│ │ delete() ──┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 기록 정보: │
│ • 이벤트 (created, updated, deleted) │
│ • 변경 전/후 값 │
│ • 실행 사용자, 테넌트, IP, URL │
└─────────────────────────────────────────────────────────────┘

핵심 컴포넌트

컴포넌트역할
Auditable모델에 적용하는 Trait
AuditableInterfaceAuditable 모델 계약
AuditObserver모델 이벤트 감지 및 로그 생성
AuditLog감사 로그 저장 모델

Auditable Trait 적용

기본 사용법

<?php

namespace App\Models;

use App\Core\Base\Audit\Contracts\AuditableInterface;
use App\Core\Base\Audit\Traits\Auditable;
use Illuminate\Database\Eloquent\Model;

class Product extends Model implements AuditableInterface
{
use Auditable;

protected $fillable = [
'name',
'price',
'description',
];
}

적용 즉시 동작

// 생성 → created 이벤트 기록
$product = Product::create([
'name' => 'New Product',
'price' => 10000,
]);

// 수정 → updated 이벤트 기록 (변경된 필드만)
$product->update(['price' => 15000]);

// 삭제 → deleted 이벤트 기록
$product->delete();

기록되는 정보

필드설명예시
auditable_type모델 클래스명App\Models\Product
auditable_id모델 ID123
user_id실행한 사용자 ID1
tenant_id테넌트 ID5
event이벤트 유형created, updated, deleted
old_values변경 전 값 (JSON){"price": 10000}
new_values변경 후 값 (JSON){"price": 15000}
ip_address클라이언트 IP192.168.1.1
user_agent브라우저 정보Mozilla/5.0...
url요청 URL/admin/products/123
metadata추가 메타데이터{"order_number": "ORD-001"}
created_at기록 시간2024-01-15 10:30:00

커스터마이징

특정 필드만 기록

class Order extends Model implements AuditableInterface
{
use Auditable;

/**
* 이 필드들만 감사 로그에 기록
*/
protected array $auditInclude = [
'status',
'total_amount',
'payment_status',
];
}

민감 정보 제외

class User extends Model implements AuditableInterface
{
use Auditable;

/**
* 이 필드들은 감사 로그에서 제외
*/
protected array $auditExclude = [
'password',
'remember_token',
'two_factor_secret',
'api_key',
];
}

특정 이벤트만 기록

class Document extends Model implements AuditableInterface
{
use Auditable;

/**
* 이 이벤트들만 기록 (삭제는 기록 안 함)
*/
protected array $auditEvents = [
'created',
'updated',
];
}

추가 메타데이터 포함

class Order extends Model implements AuditableInterface
{
use Auditable;

/**
* 감사 로그에 추가할 메타데이터
*/
public function getAuditMetadata(): array
{
return [
'order_number' => $this->order_number,
'customer_id' => $this->customer_id,
'customer_name' => $this->customer?->name,
];
}
}

조건부 활성화/비활성화

class Message extends Model implements AuditableInterface
{
use Auditable;

/**
* 감사 로그 활성화 여부
*/
public function isAuditEnabled(): bool
{
// Draft 상태에서는 기록 안 함
if ($this->status === 'draft') {
return false;
}

return config('core.audit.enabled', true);
}
}

일시 비활성화

특정 인스턴스만 비활성화

// 이 저장에서만 감사 로그 비활성화
$user->withoutAudit()->save();

// 체인 가능
$user->withoutAudit()->update(['name' => 'New Name']);

전역 비활성화 (마이그레이션, 시더)

// 콜백 내에서 모든 Auditable 모델의 로깅 비활성화
User::withoutAuditing(function () {
// 대량 데이터 삽입
User::factory()->count(1000)->create();
});

// 마이그레이션에서 사용
public function run(): void
{
Product::withoutAuditing(function () {
Product::insert([
['name' => 'Product 1', 'price' => 1000],
['name' => 'Product 2', 'price' => 2000],
// ...
]);
});
}

감사 로그 조회

AuditLog 모델 스코프

use App\Core\Base\Audit\Models\AuditLog;

// 특정 모델의 로그
$logs = AuditLog::forModel(Product::class)->get();

// 특정 이벤트의 로그
$logs = AuditLog::event('deleted')->get();

// 특정 사용자의 로그
$logs = AuditLog::byUser($userId)->get();

// 특정 테넌트의 로그
$logs = AuditLog::byTenant($tenantId)->get();

// 날짜 범위 조회
$logs = AuditLog::between('2024-01-01', '2024-01-31')->get();

// 조합 사용
$logs = AuditLog::forModel(User::class)
->event('updated')
->byTenant($tenantId)
->between($startDate, $endDate)
->latest()
->paginate(20);

모델에서 로그 조회

$product = Product::find(1);

// 해당 모델의 모든 감사 로그 (최신순)
$auditLogs = $product->auditLogs;

// 페이지네이션
$auditLogs = $product->auditLogs()->paginate(10);

// 특정 이벤트만
$deletedLogs = $product->auditLogs()
->where('event', 'deleted')
->get();

변경 내역 확인

$auditLog = AuditLog::find(1);

// 모든 변경 사항 (old와 new가 다른 필드만)
$changes = $auditLog->changes;
// [
// 'price' => ['old' => 10000, 'new' => 15000],
// 'name' => ['old' => 'Old Name', 'new' => 'New Name'],
// ]

// 개별 값
$oldValues = $auditLog->old_values;
$newValues = $auditLog->new_values;

관계 조회

$auditLog = AuditLog::find(1);

// 변경을 수행한 사용자
$user = $auditLog->user;

// 테넌트
$tenant = $auditLog->tenant;

// 감사 대상 모델
$product = $auditLog->auditable;

설정 (config/core.php)

'audit' => [
// 감사 로그 활성화
'enabled' => env('CORE_AUDIT_ENABLED', true),

// 저장 테이블명
'table' => 'audit_logs',

// 기록할 이벤트 (기본값)
'events' => ['created', 'updated', 'deleted'],

// 전역 제외 속성 (모든 모델에 적용)
'exclude_attributes' => [
'password',
'remember_token',
'two_factor_secret',
],
],

마이그레이션

// database/migrations/xxxx_create_audit_logs_table.php
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->morphs('auditable'); // auditable_type, auditable_id
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
$table->string('event'); // created, updated, deleted, restored, force_deleted
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->text('url')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('created_at');

// 인덱스
$table->index(['auditable_type', 'auditable_id']);
$table->index('user_id');
$table->index('tenant_id');
$table->index('event');
$table->index('created_at');
});

지원 이벤트

이벤트설명old_valuesnew_values
created모델 생성-
updated모델 수정
deleted소프트/하드 삭제-
restored소프트 삭제 복원-
force_deleted영구 삭제-

Filament 통합

Filament Admin에서 감사 로그 표시:

// app/Filament/Resources/ProductResource.php
use App\Core\Base\Audit\Models\AuditLog;

class ProductResource extends Resource
{
public static function getRelations(): array
{
return [
AuditLogsRelationManager::class,
];
}
}

// app/Filament/Resources/ProductResource/RelationManagers/AuditLogsRelationManager.php
class AuditLogsRelationManager extends RelationManager
{
protected static string $relationship = 'auditLogs';

public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('event')
->badge()
->color(fn (string $state): string => match ($state) {
'created' => 'success',
'updated' => 'warning',
'deleted' => 'danger',
default => 'gray',
}),
Tables\Columns\TextColumn::make('user.name')
->label('사용자'),
Tables\Columns\TextColumn::make('created_at')
->label('시간')
->dateTime('Y-m-d H:i:s'),
])
->defaultSort('created_at', 'desc');
}
}

적용 기준

적용 필수적용 권장적용 불필요
User, 권한 관련비즈니스 엔티티캐시 테이블
Tenant, OrganizationOrder, Product로그 테이블
민감 데이터일반 CRUDSession, Job
금융 데이터문서, 게시물Pivot 테이블

테스트 작성

감사 로그 생성 테스트

use App\Core\Base\Audit\Models\AuditLog;
use App\Models\Product;
use App\Models\User;

test('모델 생성 시 감사 로그가 기록된다', function () {
$user = User::factory()->create();
$this->actingAs($user);

$product = Product::create([
'name' => 'Test Product',
'price' => 10000,
]);

$auditLog = AuditLog::where('auditable_type', Product::class)
->where('auditable_id', $product->id)
->where('event', 'created')
->first();

expect($auditLog)->not->toBeNull()
->and($auditLog->user_id)->toBe($user->id)
->and($auditLog->new_values['name'])->toBe('Test Product');
});

test('모델 수정 시 변경된 필드만 기록된다', function () {
$user = User::factory()->create();
$this->actingAs($user);

$product = Product::create([
'name' => 'Test Product',
'price' => 10000,
]);

$product->update(['price' => 15000]);

$auditLog = AuditLog::where('auditable_type', Product::class)
->where('auditable_id', $product->id)
->where('event', 'updated')
->first();

expect($auditLog)->not->toBeNull()
->and($auditLog->old_values['price'])->toBe(10000)
->and($auditLog->new_values['price'])->toBe(15000);
});

민감 정보 제외 테스트

test('민감 정보는 감사 로그에서 제외된다', function () {
$user = User::factory()->create([
'password' => bcrypt('password123'),
]);

$auditLog = AuditLog::where('auditable_type', User::class)
->where('auditable_id', $user->id)
->where('event', 'created')
->first();

expect($auditLog->new_values)->not->toHaveKey('password')
->and($auditLog->new_values)->not->toHaveKey('remember_token');
});

일시 비활성화 테스트

test('withoutAudit()로 감사 로그를 비활성화할 수 있다', function () {
$product = Product::create(['name' => 'Test', 'price' => 1000]);

$beforeCount = AuditLog::count();

$product->withoutAudit()->update(['price' => 2000]);

expect(AuditLog::count())->toBe($beforeCount);
});

test('withoutAuditing()으로 전역 비활성화할 수 있다', function () {
$beforeCount = AuditLog::count();

Product::withoutAuditing(function () {
Product::factory()->count(5)->create();
});

expect(AuditLog::count())->toBe($beforeCount);
});

보존 정책

대량의 감사 로그 관리를 위한 정리 전략:

Artisan 명령어

// app/Console/Commands/PruneAuditLogs.php
class PruneAuditLogs extends Command
{
protected $signature = 'audit:prune {--days=90}';

public function handle()
{
$days = $this->option('days');
$cutoff = now()->subDays($days);

$deleted = AuditLog::where('created_at', '<', $cutoff)->delete();

$this->info("{$deleted}개의 오래된 감사 로그가 삭제되었습니다.");
}
}

스케줄 등록

// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// 매일 새벽 2시에 90일 이상 된 로그 삭제
$schedule->command('audit:prune --days=90')
->dailyAt('02:00');
}

모범 사례

1. 모든 비즈니스 모델에 적용

// 모든 비즈니스 중요 모델에 Auditable 적용
class Customer extends Model implements AuditableInterface
{
use Auditable;
}

class Order extends Model implements AuditableInterface
{
use Auditable;
}

class Payment extends Model implements AuditableInterface
{
use Auditable;
}

2. 민감 정보 철저히 제외

class User extends Model implements AuditableInterface
{
use Auditable;

protected array $auditExclude = [
'password',
'remember_token',
'two_factor_secret',
'api_key',
'secret_question_answer',
];
}

3. 메타데이터로 컨텍스트 보강

class Order extends Model implements AuditableInterface
{
use Auditable;

public function getAuditMetadata(): array
{
return [
'order_number' => $this->order_number,
'customer_email' => $this->customer?->email,
'total_items' => $this->items()->count(),
];
}
}

4. 대량 작업 시 비활성화

// 시더, 마이그레이션, 배치 작업에서
Product::withoutAuditing(function () {
Product::insert($bulkData);
});

관련 문서