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
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Query;
|
||||
|
||||
use App\Contracts\Pagination\BoundedTotalAware;
|
||||
use App\Enums\TotalRelation;
|
||||
|
||||
/**
|
||||
* 상한을 건 총 건수 집계 결과
|
||||
*
|
||||
* 목록을 조회하지 않고 건수만 필요한 자리(탭 배지, 요약 수치)를 위한 값 객체다.
|
||||
*
|
||||
* `int` 하나만 돌려주면 상한에 걸려 잘린 값과 정확히 센 값이 구분되지 않는다.
|
||||
* 잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나가는 것은 오류로 드러나지 않고
|
||||
* 그냥 틀린 숫자로만 보이므로, 건수와 정확도를 한 덩어리로 옮긴다.
|
||||
*
|
||||
* @see BoundedPaginator::count() 이 값을 만드는 유일한 자리
|
||||
*/
|
||||
final class BoundedCount implements BoundedTotalAware
|
||||
{
|
||||
/**
|
||||
* @param int $total 총 건수 (정확도가 AtLeast 면 "이 값 이상")
|
||||
* @param TotalRelation $relation 총 건수 정확도
|
||||
* @param int|null $resultCap 집계에 적용된 상한 (null 이면 무제한)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $total,
|
||||
private readonly TotalRelation $relation,
|
||||
private readonly ?int $resultCap,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 총 건수 정확도를 반환합니다.
|
||||
*
|
||||
* @return TotalRelation 정확도
|
||||
*/
|
||||
public function totalRelation(): TotalRelation
|
||||
{
|
||||
return $this->relation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계에 적용된 상한을 반환합니다.
|
||||
*
|
||||
* @return int|null 상한 (무제한이면 null)
|
||||
*/
|
||||
public function resultCap(): ?int
|
||||
{
|
||||
return $this->resultCap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상한에 걸려 잘렸는지 여부를 반환합니다.
|
||||
*
|
||||
* @return bool 잘렸으면 true
|
||||
*/
|
||||
public function isTruncated(): bool
|
||||
{
|
||||
return ! $this->relation->isExact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 건수를 반환합니다.
|
||||
*
|
||||
* @return int 총 건수
|
||||
*/
|
||||
public function total(): int
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답에 실을 정확도 필드 묶음을 반환합니다.
|
||||
*
|
||||
* 배지·요약 자리마다 같은 키를 손으로 조립하면 한 군데만 빠져도
|
||||
* 그 화면에서만 잘린 값이 정확한 것처럼 나간다.
|
||||
*
|
||||
* @return array{total: int, total_relation: string, total_is_exact: bool, result_cap: int|null}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total' => $this->total,
|
||||
'total_relation' => $this->relation->value,
|
||||
'total_is_exact' => $this->relation->isExact(),
|
||||
'result_cap' => $this->resultCap,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Query;
|
||||
|
||||
use App\Contracts\Pagination\BoundedTotalAware;
|
||||
use App\Enums\TotalRelation;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 상한 집계 기반 페이지 결과
|
||||
*
|
||||
* 한 페이지의 조회 결과와 그 총 건수의 정확도를 함께 담는 불변 값이다. 생성 후 상태를
|
||||
* 바꾸는 메서드를 두지 않으며, 값은 전적으로 {@see BoundedPaginator} 가 채운다.
|
||||
*
|
||||
* 표준 `LengthAwarePaginator` 를 상속하는 이유는 재사용 때문이다. 기존 컬렉션·리소스·
|
||||
* 컨트롤러는 전부 페이지네이터 인터페이스에 맞춰져 있으므로, 별도 타입을 새로 정의하면
|
||||
* 소비 지점마다 언랩 코드가 생긴다. 상속하면 기존 컬렉션이 코드 변경 없이 그대로 받고,
|
||||
* {@see BoundedTotalAware} 구현 덕에 정확도 메타까지 자동으로 얻는다.
|
||||
*
|
||||
* 표준 페이지네이터와 다른 점은 셋뿐이다:
|
||||
* - `total()` 은 상한 이하일 때만 실제 건수다 (초과 시 상한값 = 하한 보증)
|
||||
* - `lastPage()` 는 총 건수가 부정확하면 null 이다 (마지막 페이지 점프 계산 불가)
|
||||
* - `hasMorePages()` 는 총 건수가 아니라 `per_page + 1` 실측으로 판정한다
|
||||
*/
|
||||
class BoundedPage extends LengthAwarePaginator implements BoundedTotalAware
|
||||
{
|
||||
/**
|
||||
* 총 건수와 실제 매칭 건수의 관계
|
||||
*/
|
||||
private TotalRelation $totalRelation;
|
||||
|
||||
/**
|
||||
* 총 건수 집계에 적용된 상한 (무제한이면 null)
|
||||
*/
|
||||
private ?int $resultCap;
|
||||
|
||||
/**
|
||||
* 다음 페이지 존재 여부 (per_page + 1 실측 결과)
|
||||
*/
|
||||
private bool $probedHasMorePages;
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed>|array<int, mixed> $items 현재 페이지 항목
|
||||
* @param int $total 총 건수 (상한 초과 시 상한값)
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @param int $currentPage 현재 페이지 번호
|
||||
* @param TotalRelation $totalRelation 총 건수 정확도
|
||||
* @param int|null $resultCap 적용된 상한 (무제한이면 null)
|
||||
* @param bool $hasMorePages 다음 페이지 존재 여부 (per_page + 1 실측)
|
||||
* @param array<string, mixed> $options 페이지네이터 옵션 (path, pageName 등)
|
||||
*/
|
||||
public function __construct(
|
||||
$items,
|
||||
int $total,
|
||||
int $perPage,
|
||||
int $currentPage,
|
||||
TotalRelation $totalRelation,
|
||||
?int $resultCap,
|
||||
bool $hasMorePages,
|
||||
array $options = []
|
||||
) {
|
||||
parent::__construct($items, $total, $perPage, $currentPage, $options);
|
||||
|
||||
$this->totalRelation = $totalRelation;
|
||||
$this->resultCap = $resultCap;
|
||||
$this->probedHasMorePages = $hasMorePages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 건수와 실제 매칭 건수의 관계를 반환합니다.
|
||||
*
|
||||
* @return TotalRelation 정확(Exact) 또는 하한(AtLeast)
|
||||
*/
|
||||
public function totalRelation(): TotalRelation
|
||||
{
|
||||
return $this->totalRelation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 건수 집계에 적용된 상한을 반환합니다.
|
||||
*
|
||||
* @return int|null 상한 (무제한이면 null)
|
||||
*/
|
||||
public function resultCap(): ?int
|
||||
{
|
||||
return $this->resultCap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 건수가 상한에 걸려 잘렸는지 여부를 반환합니다.
|
||||
*
|
||||
* @return bool 잘렸으면 true
|
||||
*/
|
||||
public function isTruncated(): bool
|
||||
{
|
||||
return ! $this->totalRelation->isExact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 페이지 번호를 반환합니다.
|
||||
*
|
||||
* 총 건수가 부정확하면 마지막 페이지를 계산할 수 없으므로 null 을 반환합니다.
|
||||
* 화면은 이 값이 null 이면 마지막 페이지 점프 버튼을 감춥니다.
|
||||
*
|
||||
* @return int|null 마지막 페이지 번호 (총 건수 부정확 시 null)
|
||||
*/
|
||||
public function lastPage(): ?int
|
||||
{
|
||||
return $this->isTruncated() ? null : $this->lastPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 페이지 존재 여부를 반환합니다.
|
||||
*
|
||||
* 총 건수를 몰라도 `per_page + 1` 조회로 정확히 판정되므로, 상한에 걸린 상태에서도
|
||||
* "다음" 이동은 끝까지 열려 있습니다.
|
||||
*
|
||||
* @return bool 다음 페이지가 있으면 true
|
||||
*/
|
||||
public function hasMorePages(): bool
|
||||
{
|
||||
return $this->probedHasMorePages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Query;
|
||||
|
||||
use App\Enums\TotalRelation;
|
||||
use Illuminate\Contracts\Database\Query\Builder as BuilderContract;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 상한을 건 총 건수 집계 페이지네이터
|
||||
*
|
||||
* 임의의 빌더를 받아 한 페이지를 조회하고, 총 건수는 상한까지만 센다. 검색이나 특정
|
||||
* 도메인에 대한 지식이 없으므로 어떤 목록에서도 쓸 수 있다.
|
||||
*
|
||||
* 표준 `paginate()` 와 다른 점 둘:
|
||||
*
|
||||
* 1. **총 건수만 상한을 받는다.** `SELECT COUNT(*) FROM (SELECT 1 FROM ... LIMIT cap+1) t`
|
||||
* 로 감싸므로, 상한 이하면 표준 `COUNT(*)` 와 값이 같고 초과할 때만 "이상" 이 된다.
|
||||
* 2. **다음 페이지 판정은 총 건수와 무관하다.** `per_page + 1` 건을 읽어 초과분 존재
|
||||
* 여부로 판정하므로, 총 건수를 몰라도 "다음" 이동은 끝까지 정확하다.
|
||||
*
|
||||
* 상한을 넘겨 잘린 경우에도 페이지 이동이 막히지 않는다. 계산이 불가능해지는 것은
|
||||
* 마지막 페이지 번호 하나뿐이며, 그 사실은 {@see BoundedPage::lastPage()} 가 null 로 알린다.
|
||||
*
|
||||
* **입력 범위는 표준 `paginate()` 와 같아야 한다.** Eloquent 빌더뿐 아니라 쿼리 빌더
|
||||
* (`DB::table(...)`)와 관계(`$user->notifications()`)도 받는다. 관계에서 `->paginate()` 가
|
||||
* 되는 이유는 관계가 빌더로 호출을 전달해 주기 때문인데, 정적 메서드는 그 전달을 받지
|
||||
* 못한다. 그래서 여기서 직접 이해한다 — 이 입구가 표준보다 좁으면, 표준을 이 계약으로
|
||||
* 바꾸는 것만으로 멀쩡하던 목록이 TypeError 로 죽는다.
|
||||
*/
|
||||
class BoundedPaginator
|
||||
{
|
||||
/**
|
||||
* 관계를 그 밑의 빌더로 환원합니다.
|
||||
*
|
||||
* 관계의 소속 조건(`where user_id = ?` 등)은 관계 생성 시점에 빌더에 이미 들어가 있어
|
||||
* 그대로 보존된다.
|
||||
*
|
||||
* @param EloquentBuilder|QueryBuilder|Relation $query 대상
|
||||
* @return EloquentBuilder|QueryBuilder 빌더
|
||||
*/
|
||||
private static function toBuilder(EloquentBuilder|QueryBuilder|Relation $query): EloquentBuilder|QueryBuilder
|
||||
{
|
||||
return $query instanceof Relation ? $query->getQuery() : $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 페이지를 조회하고 상한 총 건수를 함께 계산합니다.
|
||||
*
|
||||
* @param EloquentBuilder|QueryBuilder|Relation $query 대상 빌더/관계 (정렬·필터가 이미 적용된 상태)
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @param int|null $page 현재 페이지 번호 (null 이면 요청에서 해석)
|
||||
* @param int|null $resultCap 총 건수 집계 상한 (null 이면 무제한 = 정확한 COUNT)
|
||||
* @param array<int, string> $columns 조회 컬럼
|
||||
* @param string $pageName 페이지 쿼리 파라미터 이름
|
||||
* @return BoundedPage 페이지 결과 (총 건수 정확도 포함)
|
||||
*/
|
||||
public static function paginate(
|
||||
EloquentBuilder|QueryBuilder|Relation $query,
|
||||
int $perPage,
|
||||
?int $page = null,
|
||||
?int $resultCap = null,
|
||||
array $columns = ['*'],
|
||||
string $pageName = 'page'
|
||||
): BoundedPage {
|
||||
$query = self::toBuilder($query);
|
||||
|
||||
$page = $page ?: Paginator::resolveCurrentPage($pageName);
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, $perPage);
|
||||
|
||||
// per_page + 1 건을 읽어 다음 페이지 존재 여부를 실측한다.
|
||||
// 총 건수 상한과 무관하게 항상 정확하다. 호출자의 빌더는 건드리지 않는다.
|
||||
//
|
||||
// offset 은 per_page 기준으로 계산한다. forPage($page, $perPage + 1) 을 쓰면
|
||||
// offset 까지 per_page + 1 배수가 되어 페이지가 깊어질수록 경계가 밀린다.
|
||||
$items = (clone $query)
|
||||
->offset(($page - 1) * $perPage)
|
||||
->limit($perPage + 1)
|
||||
->get($columns);
|
||||
|
||||
$hasMorePages = $items->count() > $perPage;
|
||||
|
||||
if ($hasMorePages) {
|
||||
$items = $items->slice(0, $perPage)->values();
|
||||
}
|
||||
|
||||
[$total, $relation] = self::countWithCap($query, $resultCap);
|
||||
|
||||
// 상한 이하인데 이번 페이지가 상한 경계를 넘겨 조회된 경우를 방어한다.
|
||||
// (동시 삽입으로 카운트 시점과 조회 시점의 모수가 다를 수 있다)
|
||||
$seenSoFar = ($page - 1) * $perPage + $items->count();
|
||||
if ($total < $seenSoFar) {
|
||||
$total = $seenSoFar;
|
||||
}
|
||||
|
||||
return new BoundedPage(
|
||||
items: $items,
|
||||
total: $total,
|
||||
perPage: $perPage,
|
||||
currentPage: $page,
|
||||
totalRelation: $relation,
|
||||
resultCap: $resultCap,
|
||||
hasMorePages: $hasMorePages,
|
||||
options: [
|
||||
'path' => Paginator::resolveCurrentPath(),
|
||||
'pageName' => $pageName,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 조회 없이 상한을 건 총 건수만 셉니다.
|
||||
*
|
||||
* 탭 배지처럼 건수만 필요한 자리에서 쓴다. {@see countWithCap()} 과 같은 쿼리를
|
||||
* 실행하지만 정확도를 값에 담아 돌려주므로, 잘린 값이 정확한 것처럼 화면에
|
||||
* 나가는 일이 생기지 않는다.
|
||||
*
|
||||
* @param EloquentBuilder|QueryBuilder|Relation $query 대상 빌더/관계
|
||||
* @param int|null $resultCap 상한 (null 이면 무제한 = 정확한 COUNT)
|
||||
* @return BoundedCount 건수 + 정확도
|
||||
*/
|
||||
public static function count(EloquentBuilder|QueryBuilder|Relation $query, ?int $resultCap): BoundedCount
|
||||
{
|
||||
[$total, $relation] = self::countWithCap($query, $resultCap);
|
||||
|
||||
return new BoundedCount($total, $relation, $resultCap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상한을 건 총 건수를 셉니다.
|
||||
*
|
||||
* 상한을 넘는지만 알면 되므로 `LIMIT cap + 1` 로 감싼 파생 테이블을 센다.
|
||||
* 결과가 `cap + 1` 이면 실제 건수는 그 이상이므로 상한값을 하한으로 보고한다.
|
||||
*
|
||||
* `GROUP BY` 가 걸린 쿼리도 파생 테이블 안에서 그룹이 만들어지므로 그룹 수가
|
||||
* 그대로 센다 (표준 `count()` 가 그룹별 건수를 돌려주는 문제가 없다).
|
||||
*
|
||||
* @param EloquentBuilder|QueryBuilder|Relation $query 대상 빌더/관계
|
||||
* @param int|null $resultCap 상한 (null 이면 무제한 = 정확한 COUNT)
|
||||
* @return array{0: int, 1: TotalRelation} [총 건수, 정확도]
|
||||
*/
|
||||
public static function countWithCap(EloquentBuilder|QueryBuilder|Relation $query, ?int $resultCap): array
|
||||
{
|
||||
$base = self::toCountableBase(self::toBuilder($query));
|
||||
|
||||
if ($resultCap === null || $resultCap <= 0) {
|
||||
return [$base->getCountForPagination(), TotalRelation::Exact];
|
||||
}
|
||||
|
||||
$bounded = $base->limit($resultCap + 1);
|
||||
|
||||
// newQuery() 는 같은 커넥션의 빈 빌더를 만든다. fromSub 가 바인딩까지 옮겨 주므로
|
||||
// 서브쿼리 SQL 을 문자열로 조립하거나 mergeBindings 를 부를 필요가 없다.
|
||||
$counted = (int) $bounded->newQuery()
|
||||
->fromSub($bounded, 'g7_bounded_total')
|
||||
->count();
|
||||
|
||||
return $counted > $resultCap
|
||||
? [$resultCap, TotalRelation::AtLeast]
|
||||
: [$counted, TotalRelation::Exact];
|
||||
}
|
||||
|
||||
/**
|
||||
* 총 건수 집계용 기본 빌더를 만듭니다.
|
||||
*
|
||||
* 정렬은 건수에 영향을 주지 않으므로 제거하고, `DISTINCT`·`GROUP BY` 가 없으면
|
||||
* SELECT 목록을 상수로 좁혀 불필요한 컬럼 읽기를 없앤다.
|
||||
*
|
||||
* @param EloquentBuilder|QueryBuilder $query 대상 빌더
|
||||
* @return QueryBuilder 집계용 기본 빌더 (원본 불변)
|
||||
*/
|
||||
private static function toCountableBase(EloquentBuilder|QueryBuilder $query): QueryBuilder
|
||||
{
|
||||
// toBase() 는 Eloquent 빌더에만 있다. 이 클래스는 쿼리 빌더도 받겠다고 선언했으므로
|
||||
// (supports() 가 그것을 허용한다) 종류를 보고 갈라야 한다 — 그러지 않으면
|
||||
// `DB::table(...)` 을 그대로 넘긴 호출이 BadMethodCallException 으로 죽는다.
|
||||
$clone = clone $query;
|
||||
$base = $clone instanceof EloquentBuilder ? $clone->toBase() : $clone;
|
||||
|
||||
$base->reorder();
|
||||
$base->limit(null)->offset(null);
|
||||
|
||||
// DISTINCT / GROUP BY 는 어떤 컬럼을 세는지가 결과를 좌우하므로 SELECT 를 건드리지 않는다.
|
||||
if (! $base->distinct && empty($base->groups)) {
|
||||
$base->select(DB::raw('1'));
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌더가 상한 집계에 쓸 수 있는 형태인지 확인합니다.
|
||||
*
|
||||
* @param mixed $query 검사 대상
|
||||
* @return bool 사용 가능하면 true
|
||||
*/
|
||||
public static function supports(mixed $query): bool
|
||||
{
|
||||
return $query instanceof EloquentBuilder
|
||||
|| $query instanceof QueryBuilder
|
||||
|| $query instanceof Relation
|
||||
|| $query instanceof BuilderContract;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Query;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Pagination\Cursor;
|
||||
use Illuminate\Pagination\CursorPaginator;
|
||||
|
||||
/**
|
||||
* 커서(키셋) 기반 페이지네이터
|
||||
*
|
||||
* OFFSET 방식은 페이지가 깊어질수록 건너뛸 행을 실제로 읽어야 해서 마지막 페이지에 갈수록
|
||||
* 느려진다. 커서 방식은 직전 페이지 마지막 행의 정렬 키를 WHERE 절 경계로 삼으므로 깊이와
|
||||
* 무관하게 일정하다.
|
||||
*
|
||||
* 커서 문자열의 인코딩·디코딩은 전부 이 클래스를 통한다. 각 목록이 자기 방식으로 커서를
|
||||
* 만들면 형식이 갈라져 한 화면의 커서를 다른 화면이 해석하지 못한다.
|
||||
*
|
||||
* 적용 조건: 정렬 키가 모두 **실제 컬럼**이어야 한다. 계산값(예: FULLTEXT 관련도 점수)은
|
||||
* WHERE 절 경계로 쓸 수 없으므로 그 정렬에서는 커서를 쓰지 못하고 OFFSET 을 유지한다.
|
||||
* 판정은 {@see self::supports()} 가 담당한다.
|
||||
*/
|
||||
class KeysetPaginator
|
||||
{
|
||||
/**
|
||||
* 커서 쿼리 파라미터의 표준 이름
|
||||
*/
|
||||
public const CURSOR_PARAM = 'cursor';
|
||||
|
||||
/**
|
||||
* 커서 기반으로 한 페이지를 조회합니다.
|
||||
*
|
||||
* 정렬 키 마지막에 고유 컬럼(기본키)이 없으면 자동으로 덧붙입니다. 동률 구간에서
|
||||
* 전순서가 보장되지 않으면 인접 페이지가 같은 행을 중복 노출하고 다른 행을 누락합니다.
|
||||
*
|
||||
* @param EloquentBuilder|QueryBuilder $query 대상 빌더 (정렬 미적용 상태로 전달)
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @param array<int, array{0: string, 1: string}> $sortKeys [[컬럼, 방향], ...] 순서대로 적용
|
||||
* @param string $uniqueKey 전순서 보장용 고유 컬럼 (보통 기본키)
|
||||
* @param string|null $cursor 인코딩된 커서 문자열 (첫 페이지면 null)
|
||||
* @param array<int, string> $columns 조회 컬럼
|
||||
* @return CursorPaginator 커서 페이지 결과
|
||||
*/
|
||||
public static function paginate(
|
||||
EloquentBuilder|QueryBuilder $query,
|
||||
int $perPage,
|
||||
array $sortKeys,
|
||||
string $uniqueKey,
|
||||
?string $cursor = null,
|
||||
array $columns = ['*']
|
||||
): CursorPaginator {
|
||||
$perPage = max(1, $perPage);
|
||||
|
||||
$applied = [];
|
||||
|
||||
foreach ($sortKeys as [$column, $direction]) {
|
||||
$query->orderBy($column, self::normalizeDirection($direction));
|
||||
$applied[] = $column;
|
||||
}
|
||||
|
||||
// 전순서 보장 — 마지막 정렬 키가 고유하지 않으면 페이지 경계에서 행이 새거나 겹친다.
|
||||
if (! in_array($uniqueKey, $applied, true)) {
|
||||
$lastDirection = $sortKeys === [] ? 'desc' : self::normalizeDirection(end($sortKeys)[1]);
|
||||
$query->orderBy($uniqueKey, $lastDirection);
|
||||
}
|
||||
|
||||
return $query->cursorPaginate($perPage, $columns, self::CURSOR_PARAM, self::decode($cursor));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 키 전부가 커서로 쓸 수 있는 실제 컬럼인지 판정합니다.
|
||||
*
|
||||
* 계산값·표현식·별칭은 WHERE 절 경계로 쓸 수 없습니다.
|
||||
*
|
||||
* @param array<int, array{0: string, 1: string}> $sortKeys [[컬럼, 방향], ...]
|
||||
* @param array<int, string> $columnWhitelist 커서로 허용된 실제 컬럼 목록
|
||||
* @return bool 전부 허용 컬럼이면 true
|
||||
*/
|
||||
public static function supports(array $sortKeys, array $columnWhitelist): bool
|
||||
{
|
||||
if ($sortKeys === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($sortKeys as [$column]) {
|
||||
if (! in_array($column, $columnWhitelist, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 문자열을 디코딩합니다.
|
||||
*
|
||||
* 형식이 깨진 값은 예외 대신 null 로 취급해 첫 페이지로 되돌립니다. 사용자가 URL 을
|
||||
* 손으로 고쳤다는 이유로 목록 화면이 오류를 띄우게 두지 않습니다.
|
||||
*
|
||||
* @param string|null $cursor 인코딩된 커서 문자열
|
||||
* @return Cursor|null 디코딩된 커서 (부재·해석 실패 시 null)
|
||||
*/
|
||||
public static function decode(?string $cursor): ?Cursor
|
||||
{
|
||||
if ($cursor === null || $cursor === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Cursor::fromEncoded($cursor);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 페이지에서 다음 페이지 커서 문자열을 뽑아냅니다.
|
||||
*
|
||||
* @param CursorPaginator $page 커서 페이지 결과
|
||||
* @return string|null 다음 페이지 커서 (마지막 페이지면 null)
|
||||
*/
|
||||
public static function nextCursor(CursorPaginator $page): ?string
|
||||
{
|
||||
return $page->nextCursor()?->encode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 페이지에서 이전 페이지 커서 문자열을 뽑아냅니다.
|
||||
*
|
||||
* @param CursorPaginator $page 커서 페이지 결과
|
||||
* @return string|null 이전 페이지 커서 (첫 페이지면 null)
|
||||
*/
|
||||
public static function previousCursor(CursorPaginator $page): ?string
|
||||
{
|
||||
return $page->previousCursor()?->encode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 방향을 asc/desc 로 정규화합니다.
|
||||
*
|
||||
* @param string $direction 입력 방향
|
||||
* @return string 'asc' 또는 'desc'
|
||||
*/
|
||||
private static function normalizeDirection(string $direction): string
|
||||
{
|
||||
return strtolower($direction) === 'asc' ? 'asc' : 'desc';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Query;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
|
||||
/**
|
||||
* 목록 한계값의 단일 출처
|
||||
*
|
||||
* 총 건수 집계 상한과 페이지 번호 상한을 한 곳에서 해석한다. 값의 출처는
|
||||
* 관리자 환경설정(`g7_settings.core.pagination.*`) 이고, 미설정 시 `config('core.pagination.*')`
|
||||
* 로 폴백한다. 확장은 이 값을 리터럴로 다시 적지 않고 필터 훅으로만 조정한다.
|
||||
*
|
||||
* 필터 훅:
|
||||
* - `core.pagination.filter_result_cap` — 총 건수 집계 상한 (int, 0 이하 = 무제한)
|
||||
* - `core.pagination.filter_max_page` — 페이지 번호 상한 (int, 0 이하 = 무제한)
|
||||
*/
|
||||
class PaginationLimits
|
||||
{
|
||||
/**
|
||||
* 총 건수 집계 상한 기본값 (환경설정·config 모두 부재 시)
|
||||
*/
|
||||
private const FALLBACK_RESULT_CAP = 10000;
|
||||
|
||||
/**
|
||||
* 페이지 번호 상한 기본값 (환경설정·config 모두 부재 시)
|
||||
*/
|
||||
private const FALLBACK_MAX_PAGE = 1000;
|
||||
|
||||
/**
|
||||
* 총 건수 집계 상한을 반환합니다.
|
||||
*
|
||||
* 이 값을 넘는 매칭은 정확히 세지 않고 "이상" 으로만 보고합니다.
|
||||
*
|
||||
* @param string|null $context 상한을 조정할 확장이 구분에 쓸 컨텍스트 키 (예: 'search', 'admin.users')
|
||||
* @return int|null 상한 (무제한이면 null)
|
||||
*/
|
||||
public static function resultCap(?string $context = null): ?int
|
||||
{
|
||||
$value = self::setting('result_cap', self::FALLBACK_RESULT_CAP);
|
||||
$value = (int) HookManager::applyFilters('core.pagination.filter_result_cap', $value, $context);
|
||||
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 번호 상한을 반환합니다.
|
||||
*
|
||||
* 정상 탐색은 `has_more_pages` 와 커서로 열려 있고, 이 값은 임의의 큰 페이지 번호를
|
||||
* 직접 던지는 남용을 막기 위한 것입니다.
|
||||
*
|
||||
* @param string|null $context 상한을 조정할 확장이 구분에 쓸 컨텍스트 키
|
||||
* @return int|null 상한 (무제한이면 null)
|
||||
*/
|
||||
public static function maxPage(?string $context = null): ?int
|
||||
{
|
||||
$value = self::setting('max_page', self::FALLBACK_MAX_PAGE);
|
||||
$value = (int) HookManager::applyFilters('core.pagination.filter_max_page', $value, $context);
|
||||
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 환경설정 값을 읽고, 없으면 config 기본값으로 폴백합니다.
|
||||
*
|
||||
* @param string $key pagination 하위 키
|
||||
* @param int $fallback 둘 다 부재할 때 쓸 값
|
||||
* @return int 해석된 값
|
||||
*/
|
||||
private static function setting(string $key, int $fallback): int
|
||||
{
|
||||
$configured = config('g7_settings.core.pagination.'.$key);
|
||||
|
||||
if ($configured === null || $configured === '') {
|
||||
$configured = config('core.pagination.'.$key, $fallback);
|
||||
}
|
||||
|
||||
return (int) $configured;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user