perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리

공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

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

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

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

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

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
This commit is contained in:
HeuJung committed 2026-08-06 11:13:51 +09:00
1 parent 97ab99b7cd
commit 2ae1972a3b
101 files changed
+6160 -322

No files matched your search

+114 -50
View File
@@ -51,6 +51,22 @@ class User extends Authenticatable implements HasLocalePreference
*/
protected array $effectiveScopeCache = [];
/**
* 보유 권한 부여 내역 캐시 (인스턴스 레벨)
*
* 한 요청에서 권한 판정은 수십~수백 번 일어난다 — 레이아웃 노드마다, 목록 행마다,
* 리소스 필드마다 부른다. 판정마다 DB 에 물으면 노드/행 수에 비례해 쿼리가 늘어난다.
* 첫 판정에서 `roles.permissions` 를 한 번 적재해 두고 이후로는 배열만 본다.
*
* 캐시 수명은 **모델 인스턴스**, 즉 요청 스코프다. 크로스 요청 캐시를 두지 않으므로
* 권한을 바꾸면 다음 요청부터 즉시 반영된다.
*
* 구조: 권한 식별자 ⇒ [{type, scope_type}, ...] (같은 권한을 여러 역할이 주면 여러 건)
*
* @var array<string, array<int, array{type: string|null, scope_type: string|null}>>|null
*/
protected ?array $permissionGrantsCache = null;
/**
* 테이블명
*
@@ -244,14 +260,79 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function hasPermission(string $permission, ?PermissionType $type = null): bool
{
return $this->roles()
->whereHas('permissions', function ($query) use ($permission, $type) {
$query->where('identifier', $permission);
if ($type !== null) {
$query->where('type', $type);
}
})
->exists();
$grants = $this->permissionGrants()[$permission] ?? null;
if ($grants === null) {
return false;
}
if ($type === null) {
return true;
}
foreach ($grants as $grant) {
if ($grant['type'] === $type->value) {
return true;
}
}
return false;
}
/**
* 보유 권한 부여 내역을 반환합니다 (인스턴스 캐시).
*
* 역할 → 권한을 한 번만 적재하고, 이후 판정은 전부 이 배열에서 이루어집니다.
*
* @return array<string, array<int, array{type: string|null, scope_type: string|null}>> 권한 식별자 ⇒ 부여 내역
*/
protected function permissionGrants(): array
{
if ($this->permissionGrantsCache !== null) {
return $this->permissionGrantsCache;
}
$grants = [];
foreach ($this->roles()->with('permissions')->get() as $role) {
foreach ($role->permissions as $permission) {
$grants[$permission->identifier][] = [
'type' => $this->enumValue($permission->type),
'scope_type' => $this->enumValue($permission->pivot->scope_type ?? null),
];
}
}
return $this->permissionGrantsCache = $grants;
}
/**
* Enum 또는 원시 값을 문자열로 정규화합니다.
*
* 캐스팅 설정 유무에 따라 Enum 인스턴스와 문자열이 섞여 들어오므로 한 형태로 맞춥니다.
*
* @param mixed $value Enum 인스턴스 · 문자열 · null
* @return string|null 정규화된 문자열 (부재 시 null)
*/
private function enumValue(mixed $value): ?string
{
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
return $value === null ? null : (string) $value;
}
/**
* 권한 판정 캐시를 비웁니다.
*
* 같은 인스턴스에서 역할을 바꾼 직후 다시 판정해야 하는 경우에 호출합니다.
* (역할 동기화 서비스가 호출하며, 일반 조회 경로에서는 필요하지 않습니다)
*/
public function flushPermissionCaches(): void
{
$this->permissionGrantsCache = null;
$this->effectiveScopeCache = [];
}
/**
@@ -263,27 +344,17 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function hasPermissions(array $permissions, bool $requireAll = true, ?PermissionType $type = null): bool
{
$userPermissions = $this->roles()
->whereHas('permissions', function ($query) use ($permissions, $type) {
$query->whereIn('identifier', $permissions);
if ($type !== null) {
$query->where('type', $type);
}
})
->with(['permissions' => function ($query) use ($permissions, $type) {
$query->whereIn('identifier', $permissions);
if ($type !== null) {
$query->where('type', $type);
}
}])
->get()
->pluck('permissions')
->flatten()
->pluck('identifier')
->unique()
->count();
// 같은 권한 집합을 hasPermission 과 공유한다 — 권한 개수만큼 쿼리가 늘지 않는다.
// 비교 기준(고유 매칭 수 vs 인자 개수)은 종전과 동일하게 유지한다.
$matched = 0;
return $requireAll ? $userPermissions === count($permissions) : $userPermissions > 0;
foreach (array_unique($permissions) as $permission) {
if ($this->hasPermission($permission, $type)) {
$matched++;
}
}
return $requireAll ? $matched === count($permissions) : $matched > 0;
}
/**
@@ -303,33 +374,21 @@ class User extends Authenticatable implements HasLocalePreference
return $this->effectiveScopeCache[$identifier];
}
$scopeTypes = $this->roles()
->whereHas('permissions', function ($query) use ($identifier) {
$query->where('identifier', $identifier);
})
->with(['permissions' => function ($query) use ($identifier) {
$query->where('identifier', $identifier);
}])
->get()
->pluck('permissions')
->flatten()
->pluck('pivot.scope_type');
// 같은 권한 집합에서 scope_type 만 뽑는다 — 권한마다 쿼리를 다시 내지 않는다.
$values = array_column($this->permissionGrants()[$identifier] ?? [], 'scope_type');
// 권한 미보유 시 null 반환 (기본값: 전체 접근)
if ($scopeTypes->isEmpty()) {
if ($values === []) {
return $this->effectiveScopeCache[$identifier] = null;
}
// union 정책: 하나라도 null → 전체 접근
if ($scopeTypes->contains(null)) {
if (in_array(null, $values, true)) {
return $this->effectiveScopeCache[$identifier] = null;
}
// ScopeType Enum 값을 문자열로 변환하여 비교
$values = $scopeTypes->map(fn ($scope) => $scope instanceof ScopeType ? $scope->value : $scope);
// 하나라도 'role' → role 적용
if ($values->contains('role')) {
if (in_array(ScopeType::Role->value, $values, true)) {
return $this->effectiveScopeCache[$identifier] = 'role';
}
@@ -369,11 +428,16 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function isAdmin(): bool
{
return $this->roles()
->whereHas('permissions', function ($query) {
$query->where('type', PermissionType::Admin);
})
->exists();
// 같은 권한 집합을 재사용한다 — 관리자 판정은 미들웨어·리소스·레이아웃에서 반복 호출된다.
foreach ($this->permissionGrants() as $grants) {
foreach ($grants as $grant) {
if ($grant['type'] === PermissionType::Admin->value) {
return true;
}
}
}
return false;
}
/**