세 갈래의 결함을 한 브랜치에서 정리한다.
## 목록 컨텍스트 왕복 시 URL 상태 소실 ( @jiwonpapa 님께서 제보해주셨습니다.)
목록에서 상세·형제 상세(이전/다음)·작성/수정 폼에 다녀오면 보고 있던
page/search/category/filters 가 사라지던 문제를 전 도메인에서 수정했다.
- 엔진(engine-v1.54.2): `mergeQuery: true` 만 적고 `query` 를 생략하면 병합이
통째로 건너뛰어지던 함정을 교정 — `ActionDispatcher.handleNavigate`/`handleReplaceUrl`.
- 게시판·이커머스·페이지·회원·마이페이지·gdpr 등 9개 확장 레이아웃의 왕복 leg 전수
적용(mergeQuery: true). 의도적 리셋(검색/필터 초기화·탭 전환·프리셋)은 면제 주석으로 구분.
- 무한스크롤 목록(브랜드·상품 공통정보·고시정보)의 새로고침이 URL 검색·정렬을 떨구던
결함 수정.
- 재발 차단: audit 룰 `layout-list-context-navigate-merge-query`(목록 클러스터 자동 도출,
page/필터 URL 신호 4종) + `layout-navigate-path-absolute`(navigate path 동작 키워드 금지).
## cellChildren 등 반복 렌더에서 단일 바인딩 파이프 미적용 ( @glitter-gim 님께서 제보해주셨습니다.)
목록 표의 각 칸에 넣은 날짜·숫자 서식(`{{row.x | datetime(...)}}`)이 빈 값이 되거나
서식 없는 원본으로 나오던 문제를, 표현식 판정 로직이 엔진 전역에 복제되며 갈라진
구조적 결함으로 진단하고 판정 경로를 단일화했다(engine-v1.54.3).
- `RenderHelpers`(renderItemChildren·evaluateIfCondition)·`ConditionEvaluator`·
`DataBindingEngine.resolveObject`·`DynamicRenderer` props 5곳에 단일 바인딩 파이프 분기 추가.
- 계획: `g7-scalable-lobster.md`(렌더 경로 비대칭 결함 일괄 수정).
## 공개 문서 내부 도구 귀속 제거
release 에 포함되는 공개 문서(`docs/**`)에서 내부 audit 룰 ID 귀속 서술을
도구 비귀속 표현("정적 검사")으로 정리. 재발 차단 룰 `public-no-internal-audit-reference` 신설.
전 계층 테스트(PHPUnit·Vitest·Playwright)·회귀 테스트 동반, 버전/CHANGELOG/활성 디렉토리 동기 완료.
35 KiB
활동 로그 시스템 (Activity Log System)
G7의 활동 로그는 Monolog 기반 커스텀 채널을 통해 DB에 기록됩니다.
Service 훅 이벤트를 Listener가 수신하여 Log::channel('activity') 로 전달하고,
ActivityLogHandler가 activity_logs 테이블에 저장합니다.
TL;DR (5초 요약)
1. Monolog 기반: Service 훅 → Listener → Log::channel('activity') → Handler → DB (3단계)
2. ActivityLogService는 조회 전용 (기록 메서드 없음 — Log::channel 직접 사용)
3. description_params에 ID 저장 → ActivityLogDescriptionResolver가 표시 시 이름 변환
4. 변경 추적: $activityLogFields 정의 필수 + ChangeDetector → changes JSON (미정의 시 null)
5. Bulk 작업 Per-Item 필수: N건 처리 시 N건 개별 로그 (집계 1건 금지), 삭제 시 loggable_type/loggable_id 직접 지정
6. 새 모듈 추가 시: Listener + DescriptionResolver + module.php 등록 + lang + $activityLogFields
목차
- 아키텍처
- 로깅 흐름
- Listener 작성 패턴
- 변경 추적 (ChangeDetector)
- 다국어 키 규칙
- description_params 저장 정책
- ActivityLogDescriptionResolver 규정
- 모듈 리스너 등록
- 모델별 로그 조회 API 패턴
- Bulk 로깅 패턴 (Per-Item 필수)
- DB 스키마
- 인덱스
- 금지 사항
- 개발자 체크리스트
아키텍처
Monolog 기반 3단계 파이프라인
Service (doAction 훅 발행)
↓
Listener (CoreActivityLogListener / 모듈 XxxActivityLogListener)
↓
Log::channel('activity')->info($action, $context)
↓
ActivityLogProcessor (user_id, ip_address, user_agent 자동 주입)
↓
ActivityLogHandler (Monolog Handler → activity_logs 테이블 INSERT)
핵심 컴포넌트
| 컴포넌트 | 위치 | 역할 |
|---|---|---|
ActivityLogChannel |
app/ActivityLog/ActivityLogChannel.php |
Monolog Logger 팩토리 (config/logging.php의 via 클래스) |
ActivityLogHandler |
app/ActivityLog/ActivityLogHandler.php |
Monolog Handler — context에서 데이터 추출 후 DB 저장 |
ActivityLogProcessor |
app/ActivityLog/ActivityLogProcessor.php |
Monolog Processor — user_id, ip_address, user_agent 자동 주입 |
ResolvesActivityLogType |
app/ActivityLog/Traits/ResolvesActivityLogType.php |
log_type 자동 결정 트레이트 — Auth::user() 기반 Admin/User/System 분류 |
ChangeDetector |
app/ActivityLog/ChangeDetector.php |
모델 스냅샷 비교 → 구조화된 변경 이력 생성 |
CoreActivityLogListener |
app/Listeners/CoreActivityLogListener.php |
코어 서비스 훅 구독 리스너 |
ActivityLog |
app/Models/ActivityLog.php |
Eloquent 모델 (localized_description 접근자 포함) |
ActivityLogService |
app/Services/ActivityLogService.php |
조회 전용 서비스 (기록 메서드 없음) |
config/logging.php 채널 설정
'activity' => [
'driver' => 'custom',
'via' => \App\ActivityLog\ActivityLogChannel::class,
'level' => 'debug',
],
config/activity_log.php
return [
'enabled' => env('ACTIVITY_LOG_ENABLED', true), // false 시 Handler에서 DB 저장 건너뜀
'channel' => env('ACTIVITY_LOG_CHANNEL', 'activity'),
];
로깅 흐름
1단계: Service에서 훅 발행
Service 계층에서 비즈니스 로직 수행 후 doAction 으로 훅을 발행합니다.
// Service 내부 (예: UserService)
$this->hookManager->doAction('core.user.after_create', $user, $data);
2단계: Listener에서 로그 기록
Listener가 훅을 수신하여 Log::channel('activity')->info() 를 호출합니다.
use Illuminate\Support\Facades\Log;
public function handleUserAfterCreate(User $user, array $data): void
{
$this->logActivity('user.create', [
'log_type' => ActivityLogType::Admin,
'loggable' => $user,
'description_key' => 'activity_log.description.user_create',
'description_params' => ['user_id' => $user->uuid],
'properties' => ['email' => $user->email, 'name' => $user->name],
]);
}
private function logActivity(string $action, array $context): void
{
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
Log::error('Failed to record activity log', [
'action' => $action,
'error' => $e->getMessage(),
]);
}
}
3단계: Processor + Handler
- ActivityLogProcessor: 리스너에서 명시적으로 전달하지 않은
user_id,ip_address,user_agent를 자동 주입 - ActivityLogHandler: context에서 구조화 데이터를 추출하여
ActivityLog::create()호출
큐 워커에서의 사용자 컨텍스트
훅 리스너가 큐로 디스패치되는 경우(기본 동작), 큐 워커는 별도 프로세스이므로 Auth::user() / request()->ip() 등이 모두 리셋됩니다. 이로 인해 활동로그 행위자가 "시스템"으로 잘못 기록될 수 있습니다.
HookContextCapture가 디스패치 시점에 다음 항목을 자동 캡처하여 워커에서 복원합니다:
| 항목 | 복원 효과 |
|---|---|
user_id |
활동로그 actor가 실제 로그인 사용자로 정상 기록 |
ip_address / user_agent |
Processor가 자동 주입하는 IP/UA가 원래 요청 값으로 정상 기록 |
path |
ResolvesActivityLogType::resolveLogType()이 워커에서 정상 동작 (Admin/User/System 분류) |
locale |
다국어 메시지가 원래 요청 로케일로 발송 |
리스너 코드는 변경 불필요 — Log::channel('activity')->info() 호출만으로 동작합니다.
자세한 큐 컨텍스트 동작은 extension/hooks.md "사용자 컨텍스트 자동 복원" 참조
Context 배열 구조
| 키 | 타입 | 필수 | 설명 |
|---|---|---|---|
log_type |
ActivityLogType |
O | 로그 유형 (Admin/User/System) |
loggable |
Model |
- | 대상 모델 (morph 관계). 삭제 엔티티는 loggable_type + loggable_id 직접 지정 |
loggable_type |
string |
- | loggable 대신 morph 타입 직접 지정 (삭제된 엔티티용) |
loggable_id |
int |
- | loggable 대신 morph ID 직접 지정 (삭제된 엔티티용) |
description_key |
string |
O | 다국어 번역 키 |
description_params |
array |
- | 번역 파라미터 |
properties |
array |
- | 추가 속성 (JSON) |
changes |
array |
- | 변경 이력 (ChangeDetector 결과) |
user_id |
int |
- | 자동 주입 (Processor) |
ip_address |
string |
- | 자동 주입 (Processor) |
user_agent |
string |
- | 자동 주입 (Processor, 500자 제한) |
Listener 작성 패턴
기본 구조
<?php
namespace Modules\Vendor\Module\Listeners;
use App\ActivityLog\ChangeDetector;
use App\ActivityLog\Traits\ResolvesActivityLogType;
use App\Contracts\Extension\HookListenerInterface;
class XxxActivityLogListener implements HookListenerInterface
{
use ResolvesActivityLogType;
/** @var array<string, array> 스냅샷 저장소 */
private array $snapshots = [];
public static function getSubscribedHooks(): array
{
return [
// before_update: 스냅샷 캡처 (priority 5 — 다른 리스너보다 먼저 실행)
'vendor-module.entity.before_update' => ['method' => 'captureEntitySnapshot', 'priority' => 5],
// after_xxx: 로그 기록 (priority 20)
'vendor-module.entity.after_create' => ['method' => 'handleEntityAfterCreate', 'priority' => 20],
'vendor-module.entity.after_update' => ['method' => 'handleEntityAfterUpdate', 'priority' => 20],
'vendor-module.entity.after_delete' => ['method' => 'handleEntityAfterDelete', 'priority' => 20],
];
}
public function handle(...$args): void
{
// 개별 메서드에서 처리
}
// ─── 스냅샷 캡처 ───
public function captureEntitySnapshot(Model $entity, array $data): void
{
$this->snapshots['entity_' . $entity->id] = $entity->toArray();
}
// ─── 핸들러 ───
public function handleEntityAfterCreate(Model $entity, array $data): void
{
// log_type 미지정 시 ResolvesActivityLogType 트레이트가 Auth::user() 기반 자동 결정
$this->logActivity('entity.create', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_create',
'description_params' => ['entity_name' => $entity->name ?? ''],
'properties' => ['id' => $entity->id],
]);
}
public function handleEntityAfterUpdate(Model $entity, array $data): void
{
$snapshot = $this->snapshots['entity_' . $entity->id] ?? null;
$changes = ChangeDetector::detect($entity, $snapshot);
$this->logActivity('entity.update', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_update',
'description_params' => ['entity_name' => $entity->name ?? ''],
'changes' => $changes,
]);
unset($this->snapshots['entity_' . $entity->id]);
}
public function handleEntityAfterDelete(Model $entity): void
{
$this->logActivity('entity.delete', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_delete',
'description_params' => ['entity_name' => $entity->name ?? ''],
]);
}
}
ResolvesActivityLogType 트레이트
모든 ActivityLog 리스너는 ResolvesActivityLogType 트레이트를 사용합니다.
이 트레이트는 두 가지 메서드를 제공합니다:
-
resolveLogType()—Auth::user()기반으로 log_type을 동적 결정:- Admin 역할 보유자 →
ActivityLogType::Admin - 인증된 비관리자 →
ActivityLogType::User - 비인증(비회원/CLI/시스템) →
ActivityLogType::System
- Admin 역할 보유자 →
-
logActivity(string $action, array $context)—Log::channel('activity')->info()래퍼:- context에
log_type이 없으면resolveLogType()으로 자동 주입 - try-catch로 로그 실패가 비즈니스 로직을 중단하지 않도록 보호
- context에
log_type 명시가 필요한 경우 (예: 시스템 자동 작업):
$this->logActivity('schedule.auto_run', [
'log_type' => ActivityLogType::System, // 명시 시 auto-resolve 건너뜀
'description_key' => 'activity_log.description.schedule_auto_run',
]);
핵심 규칙
| 규칙 | 설명 |
|---|---|
HookListenerInterface 구현 필수 |
getSubscribedHooks() + handle() 메서드 |
use ResolvesActivityLogType 필수 |
모든 ActivityLog 리스너에 트레이트 적용 |
| before_update priority 5 | 다른 리스너보다 먼저 실행하여 변경 전 스냅샷 확보 |
| after_xxx priority 20 | 실제 로직 완료 후 기록 |
log_type 하드코딩 금지 |
트레이트가 Auth::user() 기반으로 자동 결정 (필요 시 명시 가능) |
스냅샷 사용 후 unset |
메모리 누수 방지 |
다국어 키 커버리지
신규 logActivity('action.key', ...) 호출을 추가할 때마다 두 가지 다국어 키가 함께 정의되어 있어야 한다. 미정의 시 관리자 활동 로그 화면에 raw 키(action.key) 가 그대로 노출된다.
| 키 종류 | 위치 | 해석 경로 |
|---|---|---|
| action 라벨 | lang/{ko,en}/activity_log.php action 배열 (코어 SSoT) |
action.{full_dotted_key} 우선, 없으면 마지막 세그먼트 action.{last_segment} 로 fallback (ActivityLog::getActionLabelAttribute) |
| description 본문 | lang/{ko,en}/activity_log.php description 배열 또는 모듈/플러그인 namespace 의 (src|resources)/lang/{ko,en}/activity_log.php |
description_key 옵션 값이 가리키는 키 |
모듈/플러그인 리스너도 action 라벨은 코어 lang SSoT 를 fallback 으로 사용한다. 모듈 고유 action 마지막 세그먼트(예: bulk_status_update)가 코어 action 배열에 없다면 코어 lang 파일에 추가해야 한다 — 모듈 lang 파일에 추가해도 해석되지 않는다.
번들 일본어 언어팩(lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php)도 함께 동기화한다.
정적 검사: 코어/모듈/플러그인 ActivityLog 리스너 전 영역에서 키 누락 자동 검출
변경 추적 (ChangeDetector)
모델에 $activityLogFields 정의 (필수)
주의: ChangeDetector를 호출하는 모든 모델은
$activityLogFields정의 필수입니다. 미정의 시ChangeDetector::detect()가 항상null을 반환하여 changes 컬럼이 빈 채로 기록됩니다.
추적할 필드를 모델에 public static array $activityLogFields 로 선언합니다.
class Product extends Model
{
/** @var array<string, array> 활동 로그 추적 필드 */
public static array $activityLogFields = [
'name' => [
'label_key' => 'sirsoft-ecommerce::product.name',
'type' => 'text',
],
'price' => [
'label_key' => 'sirsoft-ecommerce::product.price',
'type' => 'currency',
],
'status' => [
'label_key' => 'sirsoft-ecommerce::product.status',
'type' => 'enum',
'enum' => ProductStatus::class, // Backed Enum 클래스 (labelKey() 메서드 필수)
],
'is_visible' => [
'label_key' => 'sirsoft-ecommerce::product.is_visible',
'type' => 'boolean',
],
'published_at' => [
'label_key' => 'sirsoft-ecommerce::product.published_at',
'type' => 'datetime',
],
];
}
지원 타입
| type | 설명 | 비고 |
|---|---|---|
text |
일반 문자열 | 기본값 (type 생략 시) |
number |
숫자 | 정수/실수 |
currency |
통화 금액 | 프론트엔드에서 포맷팅 시 사용 |
date |
날짜 | Y-m-d |
datetime |
날짜+시간 | Y-m-d H:i:s |
enum |
Enum 값 | enum 키에 Backed Enum 클래스 지정 필수 |
boolean |
참/거짓 | true/false |
json |
JSON 데이터 | 객체/배열 |
ChangeDetector 사용법
// 1. before_update 훅에서 스냅샷 캡처
public function captureSnapshot(Model $model, array $data): void
{
$this->snapshots['model_' . $model->id] = $model->toArray();
}
// 2. after_update 훅에서 변경 감지
public function handleAfterUpdate(Model $model, array $data): void
{
$snapshot = $this->snapshots['model_' . $model->id] ?? null;
$changes = ChangeDetector::detect($model, $snapshot);
// $changes 는 null (변경 없음) 또는 변경 배열
}
changes 배열 구조
[
{
"field": "status",
"label_key": "sirsoft-ecommerce::product.status",
"old": "draft",
"new": "active",
"type": "enum",
"old_label_key": "sirsoft-ecommerce::enums.product_status.draft",
"new_label_key": "sirsoft-ecommerce::enums.product_status.active"
},
{
"field": "name",
"label_key": "sirsoft-ecommerce::product.name",
"old": "기존 상품명",
"new": "새 상품명",
"type": "text"
}
]
old_label_key/new_label_key는type: "enum"일 때만 추가됩니다.- Enum 클래스에
labelKey(): string메서드가 필수입니다. - Backed Enum 값은 자동으로
->value로 변환하여 비교합니다.
다국어 키 규칙
코어
activity_log.description.{entity}_{verb}
| 예시 키 | 설명 |
|---|---|
activity_log.description.user_create |
사용자 생성 |
activity_log.description.user_update |
사용자 수정 |
activity_log.description.user_delete |
사용자 삭제 |
activity_log.description.role_create |
역할 생성 |
activity_log.description.settings_save |
설정 저장 |
lang 파일 위치: lang/ko/activity_log.php, lang/en/activity_log.php
// lang/ko/activity_log.php
return [
'description' => [
'user_create' => '회원 :user_id 생성',
'user_update' => '회원 :user_id 정보 수정',
'user_delete' => '회원 :user_id 삭제',
],
];
모듈
{module-id}::activity_log.description.{entity}_{verb}
| 예시 키 | 설명 |
|---|---|
sirsoft-ecommerce::activity_log.description.order_create |
주문 생성 |
sirsoft-page::activity_log.description.page_update |
페이지 수정 |
sirsoft-board::activity_log.description.post_delete |
게시글 삭제 |
lang 파일 위치: modules/_bundled/{id}/resources/lang/ko/activity_log.php
// modules/_bundled/sirsoft-page/resources/lang/ko/activity_log.php
return [
'description' => [
'page_create' => '페이지 ":page_title" 생성',
'page_update' => '페이지 ":page_title" 수정',
'page_delete' => '페이지 ":page_title" 삭제',
'page_publish' => '페이지 ":page_title" 공개',
'page_unpublish' => '페이지 ":page_title" 비공개 전환',
],
];
규칙
- 파라미터는 Laravel 표준
:param문법 사용 (예::user_id,:page_title) ko와enlang 파일 모두 정의 필수- DB에는 번역 키(
description_key)와 파라미터(description_params)만 저장 - 실시간 번역은
ActivityLog::localized_description접근자에서 수행
description_params 저장 정책
핵심 규칙: ID를 저장하고, 표시 시 이름으로 변환
description_params 에는 표시용 이름이 아닌 ID 를 저장합니다.
이름 변경 시 과거 로그도 최신 이름으로 표시되어야 하기 때문입니다.
// ✅ 올바른 사용 — ID 저장
'description_params' => ['brand_id' => $brand->id],
// ❌ 금지 — 이름 직접 저장
'description_params' => ['brand_name' => $brand->name],
삭제 시 예외: properties에 이름 스냅샷 보존
엔티티 삭제 시에는 DB 조회가 불가하므로 properties 에 이름 스냅샷을 함께 저장합니다.
ActivityLogDescriptionResolver 가 properties.name 을 우선 사용합니다.
public function handleEntityAfterDelete(Model $entity): void
{
$this->logActivity('entity.delete', [
'loggable' => $entity,
'description_key' => 'vendor-module::activity_log.description.entity_delete',
'description_params' => ['entity_id' => $entity->id],
'properties' => ['name' => $entity->name], // 삭제 후 DB 조회 불가 → 스냅샷 보존
]);
}
properties vs changes vs description_params 역할 구분
| 컬럼 | 용도 | 저장 내용 |
|---|---|---|
description_params |
번역 키의 :placeholder 치환용 |
엔티티 ID (ActivityLogDescriptionResolver 가 표시 시 이름으로 변환) |
changes |
ChangeDetector 결과 |
필드별 old/new 구조화 데이터 (자동 생성) |
properties |
추가 메타데이터 | bulk IDs, 삭제 시 이름 스냅샷, 기타 컨텍스트 데이터 |
ActivityLogDescriptionResolver 규정
역할
core.activity_log.filter_description_params 필터 훅을 구독하는 리스너입니다.
활동 로그에 저장된 엔티티 ID를 표시 시점에 사람이 읽을 수 있는 이름으로 변환합니다.
해석 우선순위
properties.name스냅샷 — 로그 기록 시점의 이름 (삭제된 엔티티도 처리 가능)- DB 조회 —
description_params의 ID로 모델 조회 → 현재 이름 반환 - Fallback — DB 조회 실패 시
"ID: {id}"문자열 반환
모듈별 구현 패턴
각 모듈은 자체 ActivityLogDescriptionResolver 를 구현합니다.
<?php
namespace Modules\Vendor\Module\Listeners;
use App\Contracts\Extension\HookListenerInterface;
class ActivityLogDescriptionResolver implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
'core.activity_log.filter_description_params' => [
'method' => 'resolveDescriptionParams',
'type' => 'filter', // ← Filter 훅 — type 명시 필수
'priority' => 10,
],
];
}
public function handle(...$args): void
{
// 기본 핸들러는 사용하지 않음
}
public function resolveDescriptionParams(array $params, string $descriptionKey, array $properties): array
{
$prefix = 'vendor-module::activity_log.description.';
if (! str_starts_with($descriptionKey, $prefix)) {
return $params; // 다른 모듈의 키 → 무시
}
$keySuffix = str_replace($prefix, '', $descriptionKey);
return match (true) {
str_starts_with($keySuffix, 'entity_') => $this->resolveEntityName($params, $properties),
default => $params,
};
}
private function resolveEntityName(array $params, array $properties): array
{
if (! empty($params['entity_name'])) {
return $params; // 이미 해석됨
}
// 1순위: properties.name 스냅샷
if (! empty($properties['name'])) {
$params['entity_name'] = $properties['name'];
return $params;
}
// 2순위: DB 조회
$id = $params['entity_id'] ?? null;
if ($id) {
$entity = Entity::find($id);
$params['entity_name'] = $entity?->name ?? "ID: {$id}";
}
return $params;
}
}
등록
모듈의 module.php 에서 getHookListeners() 에 등록합니다.
public function getHookListeners(): array
{
return [
XxxActivityLogListener::class,
ActivityLogDescriptionResolver::class, // ← 추가
];
}
다국어 이름 처리
이름이 다국어 배열(['ko' => '한국어', 'en' => 'English'])인 경우 현재 로케일에 맞게 해석합니다.
private function resolveI18nName(mixed $name): string
{
if (is_string($name)) {
return $name;
}
if (is_array($name)) {
$locale = app()->getLocale();
return $name[$locale] ?? $name['ko'] ?? (array_values($name)[0] ?? '');
}
return '';
}
모듈 리스너 등록
모듈의 module.php (AbstractModule 상속) 에서 getHookListeners() 메서드에 등록합니다.
// modules/_bundled/sirsoft-page/module.php
namespace Modules\Sirsoft\Page;
use App\Extension\AbstractModule;
use Modules\Sirsoft\Page\Listeners\PageActivityLogListener;
class Module extends AbstractModule
{
public function getHookListeners(): array
{
return [
PageActivityLogListener::class,
// 기타 리스너...
];
}
}
AbstractModule이 boot() 시점에 HookManager::registerListener() 를 자동 호출합니다.
별도의 HookManager::registerListener() 직접 호출은 불필요합니다.
모델별 로그 조회 API 패턴
특정 모델(상품, 주문 등)에 대한 활동 로그를 조회하는 API 패턴입니다.
Controller에 logs() 메서드 추가
use App\Helpers\ResponseHelper;
use App\Http\Resources\ActivityLogResource;
use App\Services\ActivityLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProductController extends AdminBaseController
{
public function __construct(
private ActivityLogService $activityLogService,
// ...
) {}
/**
* 상품 활동 로그 목록 조회
*
* @param Request $request 요청
* @param Product $product 대상 상품
* @return JsonResponse 로그 목록 응답
*/
public function logs(Request $request, Product $product): JsonResponse
{
try {
$filters = [
'per_page' => (int) ($request->query('per_page', 10)),
'page' => (int) ($request->query('page', 1)),
'sort_order' => $request->query('sort_order', 'desc'),
];
$logs = $this->activityLogService->getLogsForModel($product, $filters);
return ResponseHelper::moduleSuccess(
'vendor-module',
'messages.entity.logs_fetch_success',
ActivityLogResource::collection($logs)->response()->getData(true)
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'vendor-module',
'messages.entity.logs_fetch_failed',
500
);
}
}
}
라우트 등록
// routes/api.php (모듈)
Route::get('products/{product}/logs', [ProductController::class, 'logs']);
레이아웃 JSON에서 처리로그 탭 구현
{
"id": "activity_logs",
"endpoint": "/api/admin/vendor-module/products/{{route.id}}/logs",
"method": "GET",
"autoFetch": false,
"params": { "per_page": 10, "page": 1 }
}
Bulk 로깅 패턴 (Per-Item 필수)
핵심 원칙
모든 bulk 작업은 Per-Item 로깅 필수 — 변경된 엔티티 건별로
loggable_type/loggable_id가 기록되어야 합니다. N건 일괄 처리 시 N건의 개별 로그가 생성됩니다.
필수: bulk 작업은 Per-Item 개별 로그 기록 (1건 집계 로그 금지)
필수: 각 엔티티별 개별 로그 기록 (loggable => $model 또는 loggable_type/loggable_id 직접 지정)
패턴 1: Bulk Update — Service에서 스냅샷 + 개별 모델 update
// Service 내부
public function bulkUpdateStatus(array $ids, string $newStatus): array
{
$models = Model::whereIn('id', $ids)->get();
$snapshots = [];
foreach ($models as $model) {
$snapshots[$model->id] = $model->toArray();
$model->update(['status' => $newStatus]);
}
// 훅에 ids + snapshots 전달
$this->hookManager->doAction('module.entity.after_bulk_status_update', $ids, count($models), $snapshots);
return $snapshots;
}
패턴 2: Bulk Update — Listener에서 per-item 로그 기록
public function handleAfterBulkStatusUpdate(array $ids, int $updatedCount, array $snapshots = []): void
{
$models = Model::whereIn('id', $ids)->get()->keyBy('id');
foreach ($ids as $id) {
$model = $models->get($id);
if (! $model) {
continue; // 존재하지 않는 ID는 건너뜀
}
$snapshot = $snapshots[$id] ?? null;
$changes = $snapshot ? ChangeDetector::detect($model, $snapshot) : null;
$this->logActivity('entity.bulk_status_update', [
'loggable' => $model, // ← 개별 모델 지정 필수
'description_key' => 'vendor-module::activity_log.description.entity_status_update',
'description_params' => ['entity_id' => $id],
'changes' => $changes,
'properties' => ['entity_id' => $id],
]);
}
}
패턴 3: Bulk Delete — 삭제된 엔티티의 per-item 로깅
엔티티가 이미 삭제되어 DB 조회가 불가한 경우, loggable_type/loggable_id를 직접 지정합니다.
ActivityLogHandler가 loggable 모델이 없을 때 이 값을 fallback으로 사용합니다.
public function handleAfterBulkDelete(array $ids, int $deletedCount, array $snapshots = []): void
{
foreach ($ids as $id) {
$snapshot = $snapshots[$id] ?? null;
$this->logActivity('entity.bulk_delete', [
'loggable_type' => Model::class, // ← morph 타입 직접 지정
'loggable_id' => $id, // ← morph ID 직접 지정
'description_key' => 'vendor-module::activity_log.description.entity_delete',
'description_params' => ['entity_id' => $id],
'properties' => [
'entity_id' => $id,
'snapshot' => $snapshot, // 삭제 전 데이터 보존
],
]);
}
}
패턴 4: Bulk Toggle — 모델 조회 후 per-item 로깅
public function handleAfterBulkToggleActive(array $ids, bool $isActive, int $count, array $snapshots = []): void
{
$models = Model::whereIn('id', $ids)->get()->keyBy('id');
foreach ($ids as $id) {
$model = $models->get($id);
if (! $model) {
continue;
}
$snapshot = $snapshots[$id] ?? null;
$changes = $snapshot ? ChangeDetector::detect($model, $snapshot) : null;
$this->logActivity('entity.bulk_toggle_active', [
'loggable' => $model,
'description_key' => 'vendor-module::activity_log.description.entity_toggle_active',
'description_params' => ['entity_id' => $id],
'changes' => $changes,
'properties' => ['entity_id' => $id, 'is_active' => $isActive],
]);
}
}
loggable vs loggable_type/loggable_id 선택 기준
| 상황 | 사용 방식 |
|---|---|
| 모델이 존재함 (update/toggle) | 'loggable' => $model |
| 모델이 삭제됨 (delete) | 'loggable_type' => Model::class, 'loggable_id' => $id |
| 두 값 모두 존재 시 | loggable 우선 (Handler에서 loggable instanceof Model 체크) |
삭제 작업 ChangeDetector
bulk delete 는 변경 전후 비교가 불필요하므로 ChangeDetector를 사용하지 않습니다.
대신 properties.snapshot 에 삭제 전 데이터를 보존합니다.
DB 스키마
activity_logs 테이블
| 컬럼 | 타입 | Nullable | 설명 |
|---|---|---|---|
id |
bigint (PK) | N | 활동 로그 ID |
log_type |
varchar(20) | N | 로그 유형 (admin/user/system) |
loggable_type |
varchar | Y | 대상 모델 morph 타입 |
loggable_id |
bigint | Y | 대상 모델 ID |
user_id |
bigint (FK) | Y | 행위자 사용자 ID (삭제 시 NULL) |
action |
varchar(50) | N | 액션 유형 (예: user.create, page.update) |
description_key |
varchar(150) | Y | 다국어 번역 키 |
description_params |
json | Y | 다국어 번역 파라미터 |
properties |
json | Y | 추가 속성 데이터 |
changes |
json | Y | 구조화된 변경 이력 (ChangeDetector 결과) |
ip_address |
varchar(45) | Y | IP 주소 (IPv6 대응) |
user_agent |
varchar(500) | Y | User Agent (500자 제한) |
created_at |
timestamp | N | 생성일시 (updated_at 없음) |
timestamps = false—created_at만 사용 (boot 시 자동 설정)user_id는nullOnDelete— 사용자 삭제 시 NULL 처리
인덱스
단일 인덱스
| 컬럼 | 용도 |
|---|---|
log_type |
유형별 필터링 |
user_id |
사용자별 조회 |
action |
액션별 필터링 |
created_at |
시간순 정렬/범위 조회 |
loggable_type + loggable_id |
morphs 자동 생성 인덱스 |
복합 인덱스
| 인덱스명 | 컬럼 | 용도 |
|---|---|---|
idx_activity_logs_loggable |
(loggable_type, loggable_id, created_at) |
특정 모델의 로그 시간순 조회 |
idx_activity_logs_type_action_date |
(log_type, action, created_at) |
유형+액션 기반 필터링 |
금지 사항
| 금지 | 올바른 사용 |
|---|---|
ActivityLogService->log() / logAdmin() / logUser() / logSystem() |
제거됨 — Log::channel('activity')->info() 사용 |
ActivityLogManager / Driver 패턴 |
제거됨 — Monolog 기반 아키텍처로 전환 |
| Controller에서 직접 로그 기록 | Listener에서만 기록 (Service 훅 → Listener → Log) |
| description 컬럼에 번역된 텍스트 저장 | description_key + description_params 사용 |
| ActivityLogService를 기록용으로 사용 | 조회 전용 — 기록은 Log::channel('activity') |
Listener 밖에서 ActivityLog::create() 직접 호출 |
Log::channel('activity') 를 통해 기록 (Processor 자동 주입 보장) |
$activityLogFields 미정의 상태에서 ChangeDetector 호출 |
ChangeDetector를 사용하는 모든 모델에 $activityLogFields 정의 필수 |
description_params 에 표시용 이름 직접 저장 |
ID 저장 + ActivityLogDescriptionResolver 에서 표시 시 변환 |
별도 로그 테이블/서비스 운영 (예: ecommerce_product_logs) |
activity_logs 단일 테이블로 통합 |
| Bulk 작업을 1건의 집계 로그로 기록 (loggable 없이 count만 저장) | Per-Item: 변경된 각 엔티티별 개별 로그 기록 필수 |
| Bulk delete에서 loggable 미지정 | loggable_type + loggable_id 직접 지정 (삭제 엔티티 대응) |
참고 파일
| 파일 | 경로 |
|---|---|
| ActivityLogChannel | app/ActivityLog/ActivityLogChannel.php |
| ActivityLogHandler | app/ActivityLog/ActivityLogHandler.php |
| ActivityLogProcessor | app/ActivityLog/ActivityLogProcessor.php |
| ChangeDetector | app/ActivityLog/ChangeDetector.php |
| CoreActivityLogListener | app/Listeners/CoreActivityLogListener.php |
| ActivityLog 모델 | app/Models/ActivityLog.php |
| ActivityLogService | app/Services/ActivityLogService.php |
| ActivityLogType Enum | app/Enums/ActivityLogType.php |
| config/activity_log.php | config/activity_log.php |
| config/logging.php (activity 채널) | config/logging.php |
| 마이그레이션 (생성) | database/migrations/2026_04_01_000027_create_activity_logs_table.php |
| 마이그레이션 (i18n 전환) | database/migrations/2026_04_01_000657_update_i18n_fields_in_activity_logs_table.php |
개발자 체크리스트
새 모델 추가 시
□ 모델에 public static array $activityLogFields 정의
□ ActivityLogListener에 before_update (스냅샷 캡처) + after_create/update/delete 훅 등록
□ description_params에 ID 저장 (이름 직접 저장 금지)
□ ActivityLogDescriptionResolver에 해당 모델의 ID→이름 해석 로직 추가
□ lang/{ko,en}/activity_log.php에 description 키 추가
□ (선택) Controller에 logs() 메서드 + 라우트 추가 (모델별 로그 조회 필요 시)
CRUD 훅 추가 시
□ before_update 훅: priority 5 (스냅샷 캡처용)
□ after_xxx 훅: priority 20 (로그 기록)
□ update 핸들러에서 ChangeDetector::detect() 호출
□ delete 핸들러에서 properties에 이름 스냅샷 보존
□ ko/en 다국어 키 모두 추가
Bulk Update 추가 시
□ Service: 변경 전 모델을 일괄 로드 (whereIn()->get())
□ Service: 각 모델별 toArray() 스냅샷 캡처 → $snapshots 배열
□ Service: 개별 모델 update 실행
□ Service: 훅에 $ids + $count + $snapshots 전달
□ Listener: per-item 루프 — 각 ID별 모델 조회 + ChangeDetector + 개별 logActivity 호출
□ Listener: 존재하지 않는 ID는 continue로 건너뜀
□ 1건의 집계 로그가 아닌, N건의 개별 로그 생성 확인
□ 각 로그에 loggable => $model (또는 loggable_type/loggable_id) 포함 확인
□ ko/en 다국어 키 모두 추가
□ 테스트: N건 처리 시 N건 로그 생성 검증 + loggable 정확성 검증
Bulk Delete 추가 시
□ Service: 삭제 전 스냅샷 캡처 → $snapshots 배열 (keyBy id)
□ Service: 훅에 $ids + $count + $snapshots 전달
□ Listener: per-item 루프 — 각 ID별 loggable_type/loggable_id 직접 지정
□ Listener: properties.snapshot에 삭제 전 데이터 보존
□ 테스트: loggable_type/loggable_id 정확성 검증