Skip to main content

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);
});

감사로그 계층 가시성 (Hierarchical Visibility)

v1.39 (Phase B)

관리페이지의 CRUD 와 개별 기능 사용내역을 운영자 계층에 맞는 범위로만 열람하도록 가시성을 분리했습니다. 독립사이트 관리자(L1 SaaS Admin)가 자기 SaaS 범위를 기본 열람하고, 보안/시스템 내부 이벤트는 Platform(L0) 전용으로 격리됩니다.

계층 컨텍스트 컬럼

기존 audit_logs 에 계층 가시성을 위한 두 컬럼이 추가되었습니다.

컬럼설명가시성 용도
saas_product_id기록 시점의 SaaS Product(독립사이트)L1 SaaS Admin 스코프
organization_id기록 시점의 조직L3+ 조직 서브트리 스코프
  • 마이그레이션: 2026_06_01_000002_add_saas_org_context_to_audit_logs.php (파티션 부모 ALTER → 자식 자동 상속, 멱등 hasColumn 가드, 인덱스 추가, FK 생략, 보존형 down)
  • 컨텍스트는 App\Core\Base\Audit\Support\AuditContext 가 현재 요청에서 해석합니다.
    • saasProductId(): current.saas_product 바인딩 → 세션(current_saas_product_id) → 인증 사용자 saas_product_id
    • organizationId(): 인증 사용자 organization_id
    • tenantId(): 세션(tenant_id) → 사용자 getTenantId()
  • 기록 지점(AuditObserver, AuditLog::logAction() / logSystem() / logView(), ActionLogger)이 이 컨텍스트를 자동으로 함께 저장합니다.

graceful 가드 — 미마이그레이션 프로젝트 보호

백포트 안전 (v1.39.1 hotfix)

Core 는 symlink 로 여러 프로젝트가 공유합니다. 위 두 컬럼은 Phase B 마이그레이션으로 추가되는데, 아직 migrate 하지 않은 프로젝트의 운영 DB 에는 컬럼이 없습니다. 컬럼 부재 시 saas_product_id 를 INSERT 하면 column does not exist 500 이 발생하므로, 모든 기록 지점은 컬럼이 존재할 때만 계층 컨텍스트를 기록합니다.

App\Core\Base\Audit\Models\AuditLog::supportsHierarchicalContext()Schema::hasColumn() 결과를 memoize 하여 이를 판정합니다. (true 로 확인되면 캐시, false/null 은 매 호출 재확인 — 테스트의 RefreshDatabase 나 마이그레이션 직후의 "없음→있음" 전이를 놓치지 않기 위함)

if (AuditLog::supportsHierarchicalContext()) {
$attributes['saas_product_id'] = AuditContext::saasProductId();
$attributes['organization_id'] = AuditContext::organizationId();
}
  • 미마이그레이션 프로젝트: 계층 컨텍스트는 생략되고 나머지 로깅은 정상 동작(500 없음)
  • 마이그레이션 후: 자동으로 계층 가시성 활성
  • 기존 로그(saas/org 가 NULL 인 과거 기록)는 backfill 하지 않으며, 신규 로그부터 계층 스코프가 적용됩니다.

조회 표면 노출 게이트 (사이트 유형)

데이터 스코프(아래 buildQuery)가 적용되기 이전에, 감사 로그 화면(Resource) 자체의 노출 여부를 사이트 유형으로 게이트합니다 (BaseAuditLogResource::passesSiteTypeGate).

  • 노출 = 독립사이트(미러 표면 포함) OR Platform(L0). 비독립 Tenant/Org 패널에서는 감사 로그 화면이 아예 노출되지 않습니다(네비게이션 메뉴 + 직접 URL 모두 차단 — shouldRegisterNavigation/canAccess/canViewAny).
  • 게이트는 패널 정체성(소속 패널 Platform/SaaS/Tenant/Org — 런타임 현재 패널, 정적 컨텍스트는 namespace 추론)으로 판정하며, Resource 의 권한 레벨(getMinimumLevel)에 의존하지 않습니다. 따라서 프로젝트가 감사 Resource 를 (쿼리 범위 제한 등 목적으로) 커스터마이즈해도 노출 게이트는 일관되게 동작합니다.
  • 미러 도메인 컨텍스트는 독립사이트로 resolve되어 자연히 통과합니다(미러는 독립사이트에 귀속).

설계: ADR-090(사이트 모델 — 2유형 + route mount) · ADR-087(관리자/감사 가시성). 노출 게이트를 통과한 패널에서만 아래 buildQuery 데이터 스코프가 적용됩니다.

패널별 AuditLogResource 스코프

각 패널의 감사 로그 화면은 App\Core\Base\Audit\Filament\Resources\BaseAuditLogResourcebuildQuery() 로 운영자 레벨에 따라 자동 스코프됩니다.

레벨패널열람 범위스코프 기준
L0Platform전체스코프 없음
L1SaaS자기 SaaSsaas_product_id = {내 SaaS}
L2Tenant자기 테넌트tenant_id = {내 테넌트}
L3+Org자기 조직 서브트리(depth-무관)organization_id ∈ ViewerScopeResolver::viewerOrgIds()
  • L3+ 에서 org 가 해석되지 않으면(null) 보수적으로 차단(1 = 0)하여 과다 노출을 방지합니다.
  • 감사 로그 화면은 읽기 전용입니다(canCreate() = false, 생성/수정/삭제 액션 없음).

이벤트 민감도 분리선 (visible_events)

패널별로 노출 가능한 이벤트 allowlistconfig/core.phpaudit.visible_events 로 분리합니다. 계층 스코프(어떤 레코드)와 별개로, 민감 이벤트(어떤 종류)를 패널 단위로 통제합니다.

// config/core.php — audit.visible_events
'visible_events' => [
'platform' => ['created', 'updated', 'deleted', 'restored', 'force_deleted', 'viewed', 'login', 'logout', 'login_failed', 'password_reset', 'action'],
'saas' => ['created', 'updated', 'deleted', 'restored', 'login', 'logout', 'action'],
'tenant' => ['created', 'updated', 'deleted', 'action'],
'org' => ['created', 'updated', 'deleted', 'action'],
],
패널노출 이벤트비고
platform (L0)CUD + restored/force_deleted + viewed + login/logout/login_failed/password_reset + action전체 — 보안/시스템 내부 이벤트 포함
saas (L1)CUD + restored + login/logout + action보안 민감 이벤트 제외
tenant (L2)CUD + action로그인/보안 이벤트 제외
org (L3)CUD + action로그인/보안 이벤트 제외 — 조직 운영 범위

Platform 전용(보안/시스템 내부) 이벤트: login_failed, password_reset, viewed, force_deleted — 다른 패널 allowlist 에는 포함되지 않습니다.

'action' 이 포함된 패널은 CUD/인증 표준 이벤트가 아닌 커스텀 액션 이벤트(점 표기, 예: inquiry.escalate)도 일괄 노출합니다. BaseAuditLogResource 의 표준 이벤트 집합(created, updated, deleted, restored, force_deleted, login, logout, login_failed, password_reset, viewed, action) 이외의 모든 이벤트가 "action" 범주로 취급됩니다.

운영자가 보는 범위 요약

운영자어떤 레코드어떤 이벤트
Platform Admin모든 SaaS/테넌트/조직모든 이벤트(보안/시스템 포함)
SaaS Admin자기 SaaS ProductCUD + 로그인/로그아웃 + 커스텀 액션
Tenant Admin자기 테넌트CUD + 커스텀 액션
Org Admin (L3+)자기 조직 + 하위 조직CUD + 커스텀 액션

재사용 로깅 모듈 (ActionLogger / audited)

v1.39 (Phase B / AD8)

CRUD 는 Auditable Trait + AuditObserver 가 자동 기록하지만, 비-CRUD 액션(에스컬레이션, 내보내기, 배정 등)은 별도 기록이 필요합니다. 이를 위한 단일 진입점 로깅 모듈을 제공합니다 — "어떤 기능이든 모듈만 포함하면 자동 로깅".

세 가지 적용 방법

방법적용 대상사용
ActionLogger::log()어디서나app(ActionLogger::class)->log('export.users', $subject, $meta)
LogsActions trait서비스 / Job / 핸들러use LogsActions;$this->logAction($event, $subject, $meta)
->audited('event')Filament ActionAction 체인에 한 줄 추가

세 방법 모두 내부적으로 App\Core\Base\Audit\Services\ActionLogger 로 위임되며, 계층 컨텍스트(saas_product_id / organization_id / tenant_id)를 자동으로 함께 기록합니다. 로깅 실패가 기능 흐름을 막지 않도록 try/catch 로 감싸져 있습니다(실패 시 null 반환).

ActionLogger 서비스 / LogsActions trait

<?php

namespace App\Services;

use App\Core\Base\Audit\Traits\LogsActions;
use App\Models\Inquiry;
use App\Models\User;

class InquiryService
{
use LogsActions;

public function assign(Inquiry $inquiry, User $staff): void
{
// ... 배정 로직 ...

// 점 표기 액션 이름 권장 → visible_events 의 'action' 으로 노출
$this->logAction('inquiry.assign', $inquiry, ['staff_id' => $staff->id]);
}
}

ActionLogger::log() 의 시그니처:

인자타입설명
$eventstring점 표기 액션 이름 (예: inquiry.assign, users.export)
$subject?Model액션 대상 모델(선택) — auditable_type / auditable_id 로 기록
$metadataarray구조화 메타데이터(선택)

Filament Action ->audited('event') 매크로

App\Core\Base\Audit 의 매크로는 CoreServiceProvider::boot() 에서 Filament\Actions\Action::macro('audited', ...) 로 등록됩니다. 어떤 Action 이든 체인에 한 줄만 추가하면, Action 실행 후(after() 훅) ActionLogger 로 자동 기록됩니다.

use Filament\Actions\Action;

Action::make('escalate')
->label('에스컬레이션')
->action(fn ($record) => $this->escalate($record))
->audited('inquiry.escalate');

메타데이터를 함께 기록하려면 두 번째 인자에 클로저를 전달합니다.

Action::make('escalate')
->audited('inquiry.escalate', fn ($record) => ['priority' => $record->priority]);
CRUD 중복 주의

생성/수정/삭제(CRUD)는 AuditObserver 가 이미 자동 기록합니다(created / updated / deleted 이벤트). ->audited()LogsActionsCRUD 외 비-CRUD 액션 기록에 사용하세요. CRUD 액션에 중복 적용하면 동일 작업이 두 번(updated + 커스텀 이벤트) 기록될 수 있습니다.

커스텀 액션의 가시성 노출

점 표기 커스텀 액션 이벤트(예: inquiry.escalate, users.export)는 표준 이벤트가 아니므로 패널 화면에서 visible_events'action' 이 포함된 패널에서만 노출됩니다(앞 절의 민감도 분리선 참조). 즉 Platform / SaaS / Tenant / Org 모든 패널이 커스텀 액션을 열람할 수 있습니다.

실제 적용 예

기능이벤트적용 방식
문의 에스컬레이션inquiry.escalateFilament Action ->audited('inquiry.escalate')
회원 CSV 내보내기users.exportBaseAllUsersResource::exportUsersCsv 에서 스트림 직전 ActionLogger::log('users.export', ...) 명시 호출
다운로드 액션은 명시 호출

->audited() 는 Action 의 after() 훅에 의존합니다. 파일 스트림 다운로드처럼 after() 훅 실행이 보장되지 않는 경우, 회원 CSV 내보내기처럼 스트림 직전에 ActionLogger::log() 를 직접 호출하는 것이 안전합니다.

관련 문서