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:
1 parent
97ab99b7cd
commit
2ae1972a3b
101 files changed
+6160
-322
No files matched your search
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace App\Repositories\Concerns;
|
||||
|
||||
use App\Enums\TotalRelation;
|
||||
use App\Support\Query\BoundedPage;
|
||||
use App\Support\Query\BoundedPaginator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Database\Query\Expression;
|
||||
use Illuminate\Contracts\Pagination\Paginator as PaginatorContract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
@@ -66,10 +70,11 @@ trait PaginatesWithDeferredJoin
|
||||
* @param bool $preserveIdOrder true = inner 가 정한 ID 순서를 outer 에서 그대로 복원
|
||||
* @param string $pageName 페이지 쿼리 파라미터명
|
||||
* @param Closure|null $outerUsing outer 에만 적용할 조인/집계 (inner 에서는 실행되지 않는다)
|
||||
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, 아니면 LengthAwarePaginator)
|
||||
* @param int|null $resultCap 총 건수 집계 상한 (null = 항상 정확한 COUNT)
|
||||
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, $resultCap 지정 시 BoundedPage, 아니면 LengthAwarePaginator)
|
||||
*/
|
||||
protected function paginateWithDeferredJoin(
|
||||
Builder $query,
|
||||
Builder|Relation $query,
|
||||
array $columns,
|
||||
array $sort,
|
||||
int $perPage,
|
||||
@@ -82,7 +87,14 @@ trait PaginatesWithDeferredJoin
|
||||
bool $preserveIdOrder = false,
|
||||
string $pageName = 'page',
|
||||
?Closure $outerUsing = null,
|
||||
?int $resultCap = null,
|
||||
): PaginatorContract {
|
||||
// 관계(`$model->items()`)도 받는다 — 표준 `paginate()` 가 되는 자리는 이 계약으로
|
||||
// 바꿔도 되어야 한다. 관계의 소속 조건은 그 밑 빌더에 이미 들어가 있어 보존된다.
|
||||
// (쿼리 빌더는 받지 않는다. 이 계약은 모델의 키 컬럼·eager load 를 다루므로
|
||||
// Eloquent 가 전제다 — 넓힐 수 있는 범위와 없는 범위를 구분한다.)
|
||||
$query = $query instanceof Relation ? $query->getQuery() : $query;
|
||||
|
||||
$page = $page ?: Paginator::resolveCurrentPage($pageName);
|
||||
$page = max(1, $page);
|
||||
|
||||
@@ -94,7 +106,16 @@ trait PaginatesWithDeferredJoin
|
||||
// whereHas 는 결과 집합을 결정하는 조건이라 setEagerLoads 로 지워지지 않는다 (의도된 구분).
|
||||
$inner = (clone $query)->setEagerLoads([]);
|
||||
|
||||
if (! $simple && $total === null) {
|
||||
// 상한이 지정되면 총 건수를 상한까지만 센다. 다음 페이지 판정은 총 건수와 무관하게
|
||||
// per_page + 1 실측으로 하므로, 마지막 페이지 번호 하나만 계산 불가가 된다.
|
||||
$bounded = ! $simple && $resultCap !== null && $resultCap > 0;
|
||||
$relation = null;
|
||||
|
||||
if ($bounded && $total === null) {
|
||||
// Eloquent 빌더를 그대로 넘긴다 — countWithCap 이 내부에서 toBase() 로
|
||||
// 글로벌 스코프까지 적용한 기반 쿼리를 만든다.
|
||||
[$total, $relation] = BoundedPaginator::countWithCap(clone $inner, $resultCap);
|
||||
} elseif (! $simple && $total === null) {
|
||||
// Laravel 의 paginate() 와 같은 집계 경로를 쓴다. count() 는 groupBy/having 이 있는
|
||||
// 쿼리에서 `select count(*) ... group by ...` 를 그대로 실행해 **첫 그룹의 행 수**를
|
||||
// 총 건수로 돌려준다. getCountForPagination() 은 그룹 쿼리를 서브쿼리로 감싸므로
|
||||
@@ -105,10 +126,11 @@ trait PaginatesWithDeferredJoin
|
||||
|
||||
$this->applySortSpec($inner, $sort);
|
||||
|
||||
// simple 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다 (Paginator 가 잘라낸다).
|
||||
// simple/bounded 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다.
|
||||
// forPage() 를 쓰면 offset 이 (page-1) * (perPage+1) 로 계산돼 페이지가 넘어갈수록
|
||||
// 건너뛰는 행이 어긋나므로, offset 은 perPage 기준으로 직접 지정한다.
|
||||
$fetchCount = $simple ? $perPage + 1 : $perPage;
|
||||
$probesNextPage = $simple || $bounded;
|
||||
$fetchCount = $probesNextPage ? $perPage + 1 : $perPage;
|
||||
|
||||
$ids = $inner
|
||||
->select($inner->getModel()->qualifyColumn($keyName))
|
||||
@@ -116,6 +138,12 @@ trait PaginatesWithDeferredJoin
|
||||
->limit($fetchCount)
|
||||
->pluck($keyName);
|
||||
|
||||
$hasMorePages = $probesNextPage && $ids->count() > $perPage;
|
||||
|
||||
if ($bounded && $hasMorePages) {
|
||||
$ids = $ids->slice(0, $perPage)->values();
|
||||
}
|
||||
|
||||
$items = $ids->isEmpty()
|
||||
? new Collection
|
||||
: $this->fetchDeferredJoinRows($query, $columns, $sort, $ids->all(), $relations, $withCount, $keyName, $preserveIdOrder, $outerUsing);
|
||||
@@ -129,6 +157,22 @@ trait PaginatesWithDeferredJoin
|
||||
return new Paginator($items, $perPage, $page, $options);
|
||||
}
|
||||
|
||||
if ($bounded) {
|
||||
// 동시 삽입으로 집계 시점과 조회 시점의 모수가 달라질 수 있다
|
||||
$seenSoFar = ($page - 1) * $perPage + $items->count();
|
||||
|
||||
return new BoundedPage(
|
||||
items: $items,
|
||||
total: max((int) $total, $seenSoFar),
|
||||
perPage: $perPage,
|
||||
currentPage: $page,
|
||||
totalRelation: $relation ?? TotalRelation::Exact,
|
||||
resultCap: $resultCap,
|
||||
hasMorePages: $hasMorePages,
|
||||
options: $options,
|
||||
);
|
||||
}
|
||||
|
||||
return new LengthAwarePaginator($items, $total ?? $items->count(), $perPage, $page, $options);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user