Files
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

126 lines
4.3 KiB
PHP

<?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;
}
}