Files
Gnuboard7/app/Repositories/ActivityLogRepository.php
T
HeuJung 2ae1972a3b perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리
공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이
기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은
per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지
번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로
정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은
계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다.

계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면
그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다.
응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새
필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate
응답은 필드 단위로 이전과 동일하다.

요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일
재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어
화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한
변경이 종전처럼 다음 요청에 반영된다.

검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한
엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어
활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩
대신 선언형 config 로 옮겼다.

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
2026-08-06 11:13:51 +09:00

320 lines
12 KiB
PHP

<?php
namespace App\Repositories;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Helpers\TimezoneHelper;
use App\Http\Resources\BaseApiCollection;
use App\Models\ActivityLog;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\KeysetPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
/**
* 활동 로그 Repository
*/
class ActivityLogRepository implements ActivityLogRepositoryInterface
{
use HasMultipleSearchFilters;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
/**
* 활동 로그 목록 정렬 허용 컬럼
*
* 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 컬럼
* 정렬을 강제할 수 있다.
*
* @var array<int, string>
*/
private const SORTABLE_COLUMNS = [
'id',
'created_at',
'action',
'log_type',
'user_id',
];
/**
* 특정 모델의 활동 로그를 페이지네이션하여 조회합니다.
*
* @param Model $model 대상 모델
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator
{
$sortOrder = $this->normalizeSortDirection($filters['sort_order'] ?? null);
$query = ActivityLog::forModel($model);
if (isset($filters['action'])) {
$query->action($filters['action']);
}
if (isset($filters['user_id'])) {
$query->byUser($filters['user_id']);
}
// 컬럼을 좁히지 않는 이유는 getPaginated() 와 같다 — 리소스가 변경 내역을 그대로 노출한다
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: [['column' => 'created_at', 'direction' => $sortOrder]],
perPage: (int) ($filters['per_page'] ?? 15),
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
}
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로, 없으면 페이지 번호 방식으로 응답합니다.
* 두 방식의 응답 봉투 차이는 {@see BaseApiCollection} 이 흡수합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator
{
// 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다)
$query = ActivityLog::query();
// 스코프 기반 권한 필터링 (self: 본인 로그만, role: 소유역할 로그, null: 전체)
PermissionHelper::applyPermissionScope($query, 'core.activities.read');
if (isset($filters['log_type'])) {
if (is_array($filters['log_type'])) {
$query->whereIn('log_type', $filters['log_type']);
} else {
$query->where('log_type', $filters['log_type']);
}
}
if (isset($filters['action'])) {
$query->action($filters['action']);
}
if (isset($filters['user_id'])) {
$query->byUser($filters['user_id']);
}
if (isset($filters['loggable_type'])) {
$query->where('loggable_type', $filters['loggable_type']);
}
if (isset($filters['created_by'])) {
$query->whereHas('user', function ($q) use ($filters) {
$q->where('uuid', $filters['created_by']);
});
}
if (isset($filters['search'])) {
$searchType = $filters['search_type'] ?? 'all';
$this->applyBilingualSearch($query, $filters['search'], $searchType);
}
if (isset($filters['date_from'])) {
$query->where('created_at', '>=', TimezoneHelper::fromSiteDateTime($filters['date_from']));
}
if (isset($filters['date_to'])) {
$query->where('created_at', '<=', TimezoneHelper::fromSiteDateTime($filters['date_to']));
}
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at');
$perPage = (int) ($filters['per_page'] ?? 15);
// 커서를 받은 요청은 키셋으로 응답한다. 로그는 계속 쌓이기만 해서 깊은 페이지를
// OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 하지만, 커서는 직전 페이지의 정렬
// 키를 WHERE 경계로 삼아 깊이와 무관하게 일정하다.
// 커서 모드에서는 OFFSET 자체가 없으므로 지연 조인이 해결하려던 문제도 함께 사라진다.
$sortKeys = array_map(
static fn (array $spec): array => [$spec['column'], $spec['direction']],
$sort
);
if (! empty($filters['cursor']) && KeysetPaginator::supports($sortKeys, self::SORTABLE_COLUMNS)) {
return KeysetPaginator::paginate(
query: $query->with('user:id,uuid,name,email'),
perPage: $perPage,
sortKeys: $sortKeys,
uniqueKey: 'id',
cursor: (string) $filters['cursor'],
);
}
// 목록 컬럼을 좁히지 않는 이유: 활동 로그 리소스는 변경 내역(mediumText `changes`)과
// 부가 정보(`properties`)까지 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다.
// 지연 조인만으로도 이 넓은 컬럼들을 읽는 행 수가 이번 페이지 분량으로 고정된다.
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: $sort,
perPage: $perPage,
relations: ['user:id,uuid,name,email'],
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
}
/**
* 양방향(한글/영어) 검색을 적용합니다.
*
* action/description 필드는 영어 키를 저장하므로,
* 검색어가 번역된 라벨과 일치하는 원본 키도 함께 검색합니다.
*
* @param Builder $query 쿼리 빌더
* @param string $search 검색어
* @param string $searchType 검색 유형 (all, action, description, ip_address)
*/
private function applyBilingualSearch($query, string $search, string $searchType): void
{
$query->where(function ($q) use ($search, $searchType) {
$searchLower = mb_strtolower($search);
if ($searchType === 'action' || $searchType === 'all') {
// 영어 원문 검색
$q->orWhere('action', 'LIKE', "%{$search}%");
// 번역된 라벨로 원본 action 값 역추적
$matchingActions = $this->findActionsByTranslatedLabel($searchLower);
if (! empty($matchingActions)) {
$q->orWhereIn('action', $matchingActions);
}
}
if ($searchType === 'description' || $searchType === 'all') {
// 영어 원문 검색
$q->orWhere('description_key', 'LIKE', "%{$search}%");
// 번역된 설명으로 원본 description_key 역추적
$matchingKeys = $this->findDescriptionKeysByTranslatedText($searchLower);
if (! empty($matchingKeys)) {
$q->orWhereIn('description_key', $matchingKeys);
}
}
if ($searchType === 'ip_address' || $searchType === 'all') {
$q->orWhere('ip_address', 'LIKE', "%{$search}%");
}
});
}
/**
* 번역된 액션 라벨로 원본 action 값을 역추적합니다.
*
* @param string $searchLower 소문자 검색어
* @return array<string> 일치하는 action 값 목록
*/
private function findActionsByTranslatedLabel(string $searchLower): array
{
$distinctActions = ActivityLog::distinct()->pluck('action')->toArray();
$matching = [];
foreach ($distinctActions as $action) {
// 전체 키 번역 (activity_log.action.user.create)
$fullKey = "activity_log.action.{$action}";
$translated = __($fullKey);
if ($translated !== $fullKey && str_contains(mb_strtolower($translated), $searchLower)) {
$matching[] = $action;
continue;
}
// 마지막 세그먼트 번역 (activity_log.action.create)
$lastSegment = last(explode('.', $action));
$segmentKey = "activity_log.action.{$lastSegment}";
$translated = __($segmentKey);
if ($translated !== $segmentKey && str_contains(mb_strtolower($translated), $searchLower)) {
$matching[] = $action;
}
}
return $matching;
}
/**
* 번역된 설명 텍스트로 원본 description_key를 역추적합니다.
*
* @param string $searchLower 소문자 검색어
* @return array<string> 일치하는 description_key 목록
*/
private function findDescriptionKeysByTranslatedText(string $searchLower): array
{
$distinctKeys = ActivityLog::distinct()->pluck('description_key')->filter()->toArray();
$matching = [];
foreach ($distinctKeys as $key) {
$translated = __($key);
if ($translated !== $key && str_contains(mb_strtolower($translated), $searchLower)) {
$matching[] = $key;
}
}
return $matching;
}
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
{
return ActivityLog::where('id', $id)->delete() > 0;
}
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int
{
return ActivityLog::whereIn('id', $ids)->delete();
}
/**
* 최근 활동 로그를 스코프 권한 적용하여 조회합니다.
*
* @param string $permission 권한 식별자
* @param int $limit 조회할 활동 수
* @return Collection 활동 로그 컬렉션
*/
public function getRecent(string $permission, int $limit = 5): Collection
{
$query = ActivityLog::query()->with('user:id,uuid,name,email');
// 스코프 기반 권한 필터링 (self: 본인 활동만, role: 소유역할 활동, null: 전체)
PermissionHelper::applyPermissionScope($query, $permission);
return $query->latest()
->limit($limit)
->get();
}
/**
* 사용자 삭제 시 해당 사용자의 모든 activity_logs.user_id 컬럼을 NULL 로 익명화합니다.
*
* @param int $userId 익명화 대상 사용자 ID
* @return int 익명화된 row 수
*/
public function anonymizeUserId(int $userId): int
{
return ActivityLog::where('user_id', $userId)->update(['user_id' => null]);
}
}