목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친 감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다. 검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다. 옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던 공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글 목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가 되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는 접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다. ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로 로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에 사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다. 하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서 통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가 요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다. 정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다. 탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
276 lines
13 KiB
PHP
276 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Repositories\Concerns;
|
|
|
|
use Closure;
|
|
use Illuminate\Contracts\Database\Query\Expression;
|
|
use Illuminate\Contracts\Pagination\Paginator as PaginatorContract;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Pagination\Paginator;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* 지연 조인(deferred join) 기반 목록 페이지네이션 Trait
|
|
*
|
|
* `OFFSET n` 은 건너뛸 n 건도 실제로 읽는다. 목록 SELECT 에 넓은 컬럼(text/longText/JSON)이
|
|
* 포함돼 있으면 그 n 건의 넓은 컬럼까지 함께 읽히고, 특히 InnoDB 는 행 밖 오버플로 페이지에
|
|
* 저장된 값을 앞부분만 필요해도 페이지째 읽는다. 그래서 페이지가 뒤로 갈수록 비용이 선형으로
|
|
* 커진다.
|
|
*
|
|
* 지연 조인은 이를 두 단계로 나눈다.
|
|
*
|
|
* 1. inner — 필터/정렬만으로 **키 컬럼만** 조회해 이번 페이지의 ID 집합을 구한다
|
|
* 2. outer — 그 ID 집합에 대해서만 목록 컬럼과 관계를 조회한다
|
|
*
|
|
* 넓은 컬럼을 읽는 행 수가 OFFSET 과 무관하게 `$perPage` 건으로 고정되므로 뒤쪽 페이지의
|
|
* 비용 증가가 사라진다.
|
|
*
|
|
* 호출 계약:
|
|
* - `$query` 에는 **필터/where 만** 적용한다 (`orderBy`/`with`/`select` 미적용)
|
|
* - `whereHas` 는 결과 집합을 결정하므로 그대로 둔다 — 제거되는 것은 `with`(eager load) 뿐이다
|
|
* - 관계는 **반드시 `$relations` 인자로** 넘긴다. `setEagerLoads([])` 는 inner 뿐 아니라
|
|
* **outer 에도** 적용되므로, `$query->with()` 만 하고 인자를 생략하면 관계가 로드되지 않는다.
|
|
* 이 실패는 예외도 쿼리 오류도 내지 않고 응답에서 관계 필드만 사라진다
|
|
* - 정렬은 닫힌 집합이어야 한다 (ResolvesSortSpec 참조). 인덱스 없는 컬럼 정렬은 inner 도
|
|
* 전체 스캔하므로 개선 폭이 보장되지 않는다
|
|
* - 키 컬럼은 정렬 마지막에 자동으로 덧붙는다 (동률 시 페이지 경계가 흔들리는 것을 방지)
|
|
*
|
|
* 목록 컬럼에 `SUBSTRING(content, 1, N)` 을 두는 것은 프루닝으로 인정하지 않는다. 오버플로
|
|
* 페이지 읽기가 그대로 발생하므로, 잘라내기는 outer(`$columns`)에서만 수행한다.
|
|
*
|
|
* @see ResolvesSortSpec 정렬 스펙 해석
|
|
*/
|
|
trait PaginatesWithDeferredJoin
|
|
{
|
|
/**
|
|
* 지연 조인으로 목록을 페이지네이션합니다.
|
|
*
|
|
* @param Builder $query 필터/where 만 적용된 쿼리 (orderBy/with/select 미적용)
|
|
* @param array $columns outer select 목록 (DB::raw 허용)
|
|
* @param array<int, array{column: string, direction: string}> $sort 정렬 스펙
|
|
* @param int $perPage 페이지당 건수
|
|
* @param int|null $page 현재 페이지 (null = 요청에서 해석)
|
|
* @param array<int, string> $relations outer 전용 eager load 관계
|
|
* @param array<int, string> $withCount outer 전용 관계 카운트
|
|
* @param string $keyName 키 컬럼명 (기본 id)
|
|
* @param int|null $total 전체 건수 (null = COUNT 수행, 값 지정 시 캐시 total 사용)
|
|
* @param bool $simple true = COUNT 없이 다음 페이지 유무만 판정
|
|
* @param bool $preserveIdOrder true = inner 가 정한 ID 순서를 outer 에서 그대로 복원
|
|
* @param string $pageName 페이지 쿼리 파라미터명
|
|
* @param Closure|null $outerUsing outer 에만 적용할 조인/집계 (inner 에서는 실행되지 않는다)
|
|
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, 아니면 LengthAwarePaginator)
|
|
*/
|
|
protected function paginateWithDeferredJoin(
|
|
Builder $query,
|
|
array $columns,
|
|
array $sort,
|
|
int $perPage,
|
|
?int $page = null,
|
|
array $relations = [],
|
|
array $withCount = [],
|
|
string $keyName = 'id',
|
|
?int $total = null,
|
|
bool $simple = false,
|
|
bool $preserveIdOrder = false,
|
|
string $pageName = 'page',
|
|
?Closure $outerUsing = null,
|
|
): PaginatorContract {
|
|
$page = $page ?: Paginator::resolveCurrentPage($pageName);
|
|
$page = max(1, $page);
|
|
|
|
$sort = $this->normalizeSortSpecForKey($sort, $keyName);
|
|
|
|
// inner — 키 컬럼만 조회한다. with() 를 비우는 이유 두 가지:
|
|
// (a) 성능 — 관계 쿼리가 inner/outer 두 번 실행된다
|
|
// (b) 정합성 — inner 는 키 컬럼만 select 하므로 관계 FK 가 없어 eager load 가 깨진다
|
|
// whereHas 는 결과 집합을 결정하는 조건이라 setEagerLoads 로 지워지지 않는다 (의도된 구분).
|
|
$inner = (clone $query)->setEagerLoads([]);
|
|
|
|
if (! $simple && $total === null) {
|
|
// Laravel 의 paginate() 와 같은 집계 경로를 쓴다. count() 는 groupBy/having 이 있는
|
|
// 쿼리에서 `select count(*) ... group by ...` 를 그대로 실행해 **첫 그룹의 행 수**를
|
|
// 총 건수로 돌려준다. getCountForPagination() 은 그룹 쿼리를 서브쿼리로 감싸므로
|
|
// 그룹 수가 정확히 나온다. toBase() 는 글로벌 스코프를 적용한 뒤 기반 쿼리를 주므로
|
|
// 소프트 삭제/필터 조건이 집계에서 누락되지 않는다.
|
|
$total = (clone $inner)->toBase()->getCountForPagination();
|
|
}
|
|
|
|
$this->applySortSpec($inner, $sort);
|
|
|
|
// simple 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다 (Paginator 가 잘라낸다).
|
|
// forPage() 를 쓰면 offset 이 (page-1) * (perPage+1) 로 계산돼 페이지가 넘어갈수록
|
|
// 건너뛰는 행이 어긋나므로, offset 은 perPage 기준으로 직접 지정한다.
|
|
$fetchCount = $simple ? $perPage + 1 : $perPage;
|
|
|
|
$ids = $inner
|
|
->select($inner->getModel()->qualifyColumn($keyName))
|
|
->offset(($page - 1) * $perPage)
|
|
->limit($fetchCount)
|
|
->pluck($keyName);
|
|
|
|
$items = $ids->isEmpty()
|
|
? new Collection
|
|
: $this->fetchDeferredJoinRows($query, $columns, $sort, $ids->all(), $relations, $withCount, $keyName, $preserveIdOrder, $outerUsing);
|
|
|
|
$options = [
|
|
'path' => Paginator::resolveCurrentPath(),
|
|
'pageName' => $pageName,
|
|
];
|
|
|
|
if ($simple) {
|
|
return new Paginator($items, $perPage, $page, $options);
|
|
}
|
|
|
|
return new LengthAwarePaginator($items, $total ?? $items->count(), $perPage, $page, $options);
|
|
}
|
|
|
|
/**
|
|
* outer 쿼리를 실행해 실제 행을 조회합니다.
|
|
*
|
|
* @param Builder $query 필터가 적용된 원본 쿼리 (soft delete 스코프 보존을 위해 재사용)
|
|
* @param array $columns select 목록
|
|
* @param array<int, array{column: string, direction: string}> $sort 정렬 스펙
|
|
* @param array<int, mixed> $ids 이번 페이지의 키 값 목록
|
|
* @param array<int, string> $relations eager load 관계
|
|
* @param array<int, string> $withCount 관계 카운트
|
|
* @param string $keyName 키 컬럼명
|
|
* @param bool $preserveIdOrder ID 순서 그대로 복원 여부
|
|
* @param Closure|null $outerUsing outer 전용 조인/집계
|
|
* @return Collection 조회된 행 컬렉션
|
|
*/
|
|
private function fetchDeferredJoinRows(
|
|
Builder $query,
|
|
array $columns,
|
|
array $sort,
|
|
array $ids,
|
|
array $relations,
|
|
array $withCount,
|
|
string $keyName,
|
|
bool $preserveIdOrder,
|
|
?Closure $outerUsing = null,
|
|
): Collection {
|
|
$outer = (clone $query)->setEagerLoads([]);
|
|
$qualifiedKey = $outer->getModel()->qualifyColumn($keyName);
|
|
|
|
$outer->whereIn($qualifiedKey, $ids);
|
|
|
|
// 결과 집합을 좁히지 않는 조인/집계(최근 댓글 서브쿼리, 상관 COUNT 등)는 여기서만
|
|
// 붙인다. inner 에 두면 건너뛸 행 전체에 대해 실행되므로 OFFSET 이 깊어질수록 그
|
|
// 비용이 그대로 누적된다. whereIn 뒤에 적용하므로 이번 페이지 건수에만 실행된다.
|
|
if ($outerUsing !== null) {
|
|
$outerUsing($outer);
|
|
}
|
|
|
|
if (! empty($relations)) {
|
|
$outer->with($relations);
|
|
}
|
|
|
|
if (! empty($withCount)) {
|
|
$outer->withCount($withCount);
|
|
}
|
|
|
|
if ($preserveIdOrder) {
|
|
$this->applyIdOrder($outer, $qualifiedKey, $ids);
|
|
} else {
|
|
// 정렬 스펙에 키 컬럼이 포함돼 전순서가 보장되므로, 같은 스펙을 다시 적용하면
|
|
// inner 가 정한 순서와 결과가 동일하다 (MySQL 전용 FIELD() 가 필요 없다).
|
|
$this->applySortSpec($outer, $sort);
|
|
}
|
|
|
|
return $outer->get($columns);
|
|
}
|
|
|
|
/**
|
|
* 정렬 스펙을 쿼리에 적용합니다.
|
|
*
|
|
* 컬럼이 상관 서브쿼리(Builder)면 `orderBy()` 가 서브 select 로 전개한다. 서브쿼리는
|
|
* 원 행의 키에 상관되므로 inner/outer 어느 쪽에 적용해도 같은 순서를 만든다 —
|
|
* outer 에서는 이번 페이지 건수만큼만 실행된다.
|
|
*
|
|
* @param Builder $query 대상 쿼리
|
|
* @param array<int, array{column: string|Builder|Expression, direction: string}> $sort 정렬 스펙
|
|
*/
|
|
private function applySortSpec(Builder $query, array $sort): void
|
|
{
|
|
foreach ($sort as $spec) {
|
|
$query->orderBy($spec['column'], $spec['direction']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 정렬 스펙 끝에 키 컬럼을 덧붙여 전순서를 만듭니다.
|
|
*
|
|
* 정렬 컬럼에 중복이 많으면(상태/조회수/평점 등) 동률 행의 순서가 실행마다 달라져 페이지
|
|
* 경계에서 행이 중복되거나 누락된다. inner 와 outer 가 같은 순서를 갖도록 하는 전제이기도 하다.
|
|
*
|
|
* 정렬 컬럼은 문자열 외에 **상관 서브쿼리(Builder)** 나 Expression 도 올 수 있다
|
|
* (관계 테이블 컬럼 기준 정렬 — `SortsByRelatedColumn`). 이 경우 키 컬럼일 수 없으므로
|
|
* 전순서 보장을 위해 키 컬럼이 항상 덧붙는다.
|
|
*
|
|
* @param array<int, array{column: string|Builder|Expression, direction: string}> $sort 정렬 스펙
|
|
* @param string $keyName 키 컬럼명
|
|
* @return array<int, array{column: string|Builder|Expression, direction: string}> 키 컬럼이 보장된 정렬 스펙
|
|
*/
|
|
private function normalizeSortSpecForKey(array $sort, string $keyName): array
|
|
{
|
|
$normalized = [];
|
|
|
|
foreach ($sort as $spec) {
|
|
$column = $spec['column'] ?? null;
|
|
|
|
if (is_string($column) ? $column === '' : ! ($column instanceof Builder || $column instanceof QueryBuilder || $column instanceof Expression)) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = [
|
|
'column' => $column,
|
|
'direction' => strtolower($spec['direction'] ?? 'desc') === 'asc' ? 'asc' : 'desc',
|
|
];
|
|
}
|
|
|
|
foreach ($normalized as $spec) {
|
|
// 이미 키 컬럼으로 정렬한다면 그것으로 전순서가 성립한다
|
|
if (! is_string($spec['column'])) {
|
|
continue;
|
|
}
|
|
|
|
if ($spec['column'] === $keyName || str_ends_with($spec['column'], '.'.$keyName)) {
|
|
return $normalized;
|
|
}
|
|
}
|
|
|
|
$lastDirection = $normalized === [] ? 'desc' : end($normalized)['direction'];
|
|
|
|
$normalized[] = ['column' => $keyName, 'direction' => $lastDirection];
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* inner 가 정한 ID 순서를 outer 에서 그대로 복원합니다.
|
|
*
|
|
* outer 에 존재하지 않는 표현식(집계/조인 서브쿼리 등)으로 정렬한 경우에만 사용한다.
|
|
* MySQL 전용 `FIELD()` 대신 표준 SQL `CASE WHEN` 을 쓴다 (DB 비의존 규정).
|
|
*
|
|
* @param Builder $query 대상 쿼리
|
|
* @param string $qualifiedKey 테이블명이 붙은 키 컬럼
|
|
* @param array<int, mixed> $ids 복원할 순서의 키 값 목록
|
|
*/
|
|
private function applyIdOrder(Builder $query, string $qualifiedKey, array $ids): void
|
|
{
|
|
$wrapped = $query->getQuery()->getGrammar()->wrap($qualifiedKey);
|
|
|
|
$cases = [];
|
|
$bindings = [];
|
|
|
|
foreach (array_values($ids) as $index => $id) {
|
|
$cases[] = "WHEN {$wrapped} = ? THEN {$index}";
|
|
$bindings[] = $id;
|
|
}
|
|
|
|
$query->orderByRaw('CASE '.implode(' ', $cases).' ELSE '.count($ids).' END', $bindings);
|
|
}
|
|
}
|