Files
Gnuboard7/docs/backend/activity-log.md
T
HeuJung b505ac7ba7 fix(core,board,ecommerce,page,gdpr): 목록 컨텍스트 왕복·엔진 렌더 파이프 결함 일괄 수정 + 공개문서 정리
세 갈래의 결함을 한 브랜치에서 정리한다.

## 목록 컨텍스트 왕복 시 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/활성 디렉토리 동기 완료.
2026-07-26 15:17:56 +09:00

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

목차

  1. 아키텍처
  2. 로깅 흐름
  3. Listener 작성 패턴
  4. 변경 추적 (ChangeDetector)
  5. 다국어 키 규칙
  6. description_params 저장 정책
  7. ActivityLogDescriptionResolver 규정
  8. 모듈 리스너 등록
  9. 모델별 로그 조회 API 패턴
  10. Bulk 로깅 패턴 (Per-Item 필수)
  11. DB 스키마
  12. 인덱스
  13. 금지 사항
  14. 개발자 체크리스트

아키텍처

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

  1. ActivityLogProcessor: 리스너에서 명시적으로 전달하지 않은 user_id, ip_address, user_agent를 자동 주입
  2. 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
  • logActivity(string $action, array $context) — Log::channel('activity')->info() 래퍼:

    • context에 log_type이 없으면 resolveLogType()으로 자동 주입
    • try-catch로 로그 실패가 비즈니스 로직을 중단하지 않도록 보호

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 와 en lang 파일 모두 정의 필수
  • 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를 표시 시점에 사람이 읽을 수 있는 이름으로 변환합니다.

해석 우선순위

  1. properties.name 스냅샷 — 로그 기록 시점의 이름 (삭제된 엔티티도 처리 가능)
  2. DB 조회 — description_params 의 ID로 모델 조회 → 현재 이름 반환
  3. 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 정확성 검증