총 건수를 상한까지만 세는 목록(last_page: null)에서 Pagination 이 번호 목록을 통째로 비우고 현재 페이지 숫자 하나만 남겼다. 합의되고 CHANGELOG 에 약속된 동작은 "마지막 페이지 점프만 감춤" 이었으므로, 1..현재 페이지와 (다음이 있으면) 현재+1 까지는 그대로 그리고 그 너머만 계속됨 표시로 둔다. 활동 로그처럼 10,000건을 넘는 화면에서 앞 페이지로 직접 이동할 방법이 없던 것을 되돌린다. 두 템플릿 컴포넌트 회귀 테스트(작성 시 6/7 fail 확인)와 활동 로그 E2E 를 추가하고, pagination.md 의 화면 규약 서술을 실제 동작으로 정정했다.
16 KiB
대용량 목록 페이지네이션 (Pagination)
관련 문서: Service-Repository 패턴 | API 리소스 | 검색 시스템
TL;DR (5초 요약)
1. 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least)
2. "다음" 이동은 상한과 무관하게 끝까지 열려 있다 (per_page + 1 실측)
3. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐 → last_page = null
4. 최신순 등 실제 컬럼 정렬은 커서(키셋)로 전환 가능, 관련도순은 offset 유지
5. 한계값은 관리자 환경설정 > 고급이 SSoT — 확장은 필터 훅으로만 조정
목차
- 왜 상한을 두는가
- 상한과 페이지 이동의 관계
- 구성 요소
- 저장소에서 쓰는 법
- 상한 없는 전량 조회
- 건수만 필요할 때
- 응답 계약
- 커서(키셋) 페이지네이션
- 한계값 설정
- 화면 쪽 규약
- 체크리스트
왜 상한을 두는가
목록 화면은 보통 한 페이지 분량만 보여 주면서 총 건수를 함께 표시한다. 그 총 건수를 구하는
COUNT(*) 는 조건에 맞는 행을 전부 세야 하므로, 매칭이 많을수록 한 페이지를 그리는 데
드는 비용이 커진다. 검색처럼 매칭이 수십만 건에 이를 수 있는 목록에서는 페이지 조회보다
건수 집계가 더 비싸지는 역전이 일어난다.
한편 사용자가 총 건수에서 실제로 얻는 정보는 "많다/적다" 와 "마지막 페이지가 어디쯤인가" 정도다. 12,842건과 "10,000건 이상" 은 실질적으로 같은 정보를 준다. 그래서 세는 범위에만 상한을 두고, 상한을 넘으면 정확한 수 대신 하한을 보고한다.
상한이 줄여 주지 못하는 것 — FULLTEXT 술어
상한은 세는 행 수를 줄인다. 그러나 조건을 만족하는 행을 찾는 일 자체는 줄이지 못한다.
FULLTEXT(MATCH ... AGAINST) 검색에서 이 차이가 크게 드러난다.
30만 행 전부가 한 토큰에 매칭되는 조건으로 실측한 결과다.
| 쿼리 | 소요 |
|---|---|
상한 COUNT (LIMIT 10001 로 감싼 파생 테이블) |
588.96초 |
| 상한 없는 COUNT | 560.48초 |
페이지 조회 (LIMIT 21) |
594.76초 |
세 경우가 사실상 같다. InnoDB 전문 검색은 매칭 집합을 먼저 구성하고 그 뒤에 LIMIT 을
적용하므로, 몇 건을 돌려주든 매칭 집합을 만드는 비용은 동일하게 든다. 같은 조건에서
실행계획을 보면 상한 COUNT 는 파생 테이블로 감싸는 과정에서 FULLTEXT 인덱스 사용
계획을 잃고 전체 스캔이 되기도 한다.
따라서 검색 목록에서 상한이 주는 이득은 PHP 메모리·전송량·중복 실행 제거이며,
데이터베이스가 매칭을 찾는 시간은 상한으로 해결되지 않는다. 이 축이 문제라면 전용 검색
엔진(core.search.engine_drivers 훅으로 교체)을 쓰는 것이 남은 경로다.
토큰 결합을 OR 에서 AND 로 바꿔 매칭 집합 자체를 줄이는 안은 채택하지 않는다. 입력한 단어 중 하나만 든 문서가 결과에서 통째로 사라지는 손실이 성능 이득보다 크다고 판단했다 (한글은 ngram 파서가 2글자 단위로 토큰을 쪼개 축소 폭이 특히 크다). 상세는 search-system.md "검색어 토큰은 OR 로 결합한다" 참조.
측정 한계도 함께 적는다. 위 수치는 한 토큰이 전 행에 걸리도록 만든 인위적 조건이고,
단일 개발 장비 기준이다. 매칭이 적은 일반적인 검색어에는 해당하지 않는다.
innodb_ft_result_cache_limit 에 대한 메모리 증가분은 기준선을 확보하지 못해 확정하지
못했다 — 확정된 것은 위 시간 축뿐이다.
상한과 페이지 이동의 관계
총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이 기능이 깎인다.
| 항목 | 상한을 넘었을 때 |
|---|---|
| 총 건수 | "N건 이상" 으로 표기 (total_relation = at_least) |
| 다음 페이지 이동 | 그대로 가능 — per_page + 1 조회로 정확히 판정 |
| 이전 페이지 이동 | 그대로 가능 |
| 마지막 페이지 점프 | 감춤 — 총 건수를 알아야 계산되는 유일한 값 |
| 페이지 번호 목록 | 그대로 — 1 부터 현재 페이지까지와, 다음이 있으면 현재+1 을 그린다. 그 너머는 존재 여부를 모르므로 계속됨 표시(...)만 둔다 |
마지막 페이지 점프를 감추는 것은 기능 축소가 아니라 계산 불가 사실의 정직한 표시다. 그 버튼은 대량 매칭에서 초대형 OFFSET 을 실행하므로, 눌러도 정상 응답이 오지 않는 경우가 많다.
구성 요소
| 구성 요소 | 위치 | 역할 |
|---|---|---|
TotalRelation |
app/Enums/TotalRelation.php |
총 건수 정확도 (Exact / AtLeast) |
BoundedPaginator |
app/Support/Query/BoundedPaginator.php |
per_page + 1 조회 + 상한 파생 테이블 COUNT |
BoundedPage |
app/Support/Query/BoundedPage.php |
페이지 결과. 표준 페이지네이터 인터페이스를 그대로 만족 |
KeysetPaginator |
app/Support/Query/KeysetPaginator.php |
커서 인코딩/디코딩 표준 |
PaginationLimits |
app/Support/Query/PaginationLimits.php |
한계값 해석 (설정 → 필터 훅) |
BoundedTotalAware |
app/Contracts/Pagination/BoundedTotalAware.php |
정확도를 밝히는 페이지 결과 계약 |
BoundedPage 는 LengthAwarePaginator 를 상속한다. 기존 컬렉션·리소스·컨트롤러가 전부
페이지네이터 인터페이스에 맞춰져 있으므로, 별도 타입을 새로 정의하면 소비 지점마다 언랩
코드가 생긴다. 상속하면 기존 코드가 그대로 받고 정확도 메타까지 자동으로 얻는다.
저장소에서 쓰는 법
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
public function searchByKeyword(string $keyword, int $perPage = 10, int $page = 1): BoundedPage
{
$query = $this->model->newQuery()
->where('published', true)
->orderBy('created_at', 'desc')
// 전순서 보장 — 정렬 컬럼이 비고유면 페이지 경계에서 행이 겹치거나 샌다
->orderBy('id', 'desc');
return BoundedPaginator::paginate(
$query,
perPage: $perPage,
page: $page,
resultCap: PaginationLimits::resultCap('search'),
// 목록이 실제로 쓰는 컬럼만 명시 (프루닝)
columns: ['id', 'slug', 'title', 'created_at'],
);
}
건수만 필요한 경우(탭 배지 등)는 조회 없이 집계만 한다.
[$total, $relation] = BoundedPaginator::countWithCap($query, PaginationLimits::resultCap('search'));
GROUP BY 가 걸린 쿼리도 파생 테이블 안에서 그룹이 만들어지므로 그룹 수가 그대로 센다.
표준 count() 가 그룹별 건수를 돌려주는 문제가 없다.
받는 입력은 표준 paginate() 와 같다
Eloquent 빌더뿐 아니라 쿼리 빌더(DB::table(...))와 관계($user->notifications())도
그대로 넘길 수 있다.
// 관계를 그대로 넘겨도 된다 — 소속 조건은 그 밑 빌더에 이미 들어가 있어 보존된다
BoundedPaginator::paginate($user->notifications(), perPage: 15, resultCap: $cap);
이 폭은 좁히면 안 된다. 이 계약은 기존 ->paginate() 호출을 대체하려고 만든 것이라,
받는 형태가 표준보다 좁으면 바꾸는 것만으로 멀쩡하던 목록이 죽는다. 관계에서 ->paginate()
가 되는 이유는 관계가 빌더로 호출을 전달해 주기 때문인데, 정적 메서드는 그 전달을 받지
못하므로 계약이 직접 이해해야 한다.
PaginatesWithDeferredJoin 은 빌더와 관계를 받는다. 이쪽은 모델의 키 컬럼과 eager load 를
다루므로 Eloquent 가 전제이며, 쿼리 빌더는 받지 않는다.
하지 말 것
| ❌ 금지 | ✅ 올바른 사용 |
|---|---|
같은 술어로 count() 한 번, get() 한 번 |
BoundedPaginator::paginate() 한 번 |
paginate(PHP_INT_MAX) 후 PHP array_slice |
실제 page/per_page 를 저장소까지 하달 |
forPage($page, $perPage + 1) |
offset 은 per_page 기준으로 따로 계산 |
총 건수를 모르는데 last_page 를 1 로 채움 |
null 로 내보내 화면이 감추게 한다 |
| 정렬 마지막이 비고유 컬럼 | 기본키를 덧붙여 전순서 보장 |
컬렉션에서 'pagination' => [...] 를 손으로 조립 |
...$this->paginationMeta() — 형태를 스스로 판정한다 |
상한 없는 전량 조회
->get() / ->pluck() 자체가 문제인 것은 아니다. 문제는 결과 크기가 데이터 증가에
비례해 커지는데 아무 상한도 없는 경우다. 그런 조회는 개발 데이터에서 늘 빠르고, 운영
데이터가 쌓인 뒤에야 메모리와 응답 시간을 함께 무너뜨린다. 예외도 경고도 없이 느려지기만
하므로 테스트로는 드러나지 않는다.
한 문장 안에 결과 크기를 묶는 근거가 하나는 있어야 한다.
| 상황 | 쓸 것 |
|---|---|
| 화면에 목록으로 보여 준다 | BoundedPaginator::paginate() / paginateWithDeferredJoin() |
| 전량을 순회해 처리한다 | chunkById() / lazyById() (키셋 — OFFSET 밀림이 없다) |
| 몇 건만 필요하다 | limit() / take() |
| 이미 좁혀진 키 집합이다 | whereIn($key, $ids) / find() |
행 수가 사용량이 아니라 운영자의 등록 수에 묶이는 설정성 테이블(역할·언어팩·정책 등)은 예외다. 그 경우 근거를 코드에 남긴다.
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::where('is_active', true)->orderBy('id')->get();
건수만 필요할 때
목록을 조회하지 않고 건수만 필요한 자리(탭 배지, 요약 수치)는 BoundedPaginator::count()
를 쓴다. int 하나만 돌려주면 상한에 걸려 잘린 값과 정확히 센 값이 구분되지 않는다 —
잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나가는 것은 오류로 드러나지 않고 그냥 틀린
숫자로만 보인다.
$count = BoundedPaginator::count($query, PaginationLimits::resultCap('search'));
return $count->toArray();
// ['total' => 10000, 'total_relation' => 'at_least', 'total_is_exact' => false, 'result_cap' => 10000]
여러 카테고리의 건수를 합칠 때는 하나라도 부정확하면 합계도 부정확하다. 정확한 카테고리 몇 개를 더해 봐야 전체가 정확해지지 않는다. 반대로 특정 탭 하나만 보는 중이라면 그 카테고리의 정확도만 본다 — 다른 카테고리가 잘렸다는 이유로 정확한 값을 "이상" 이라고 말하지 않는다.
응답 계약
BaseApiCollection::paginationMeta() 가 페이지 결과의 형태를 스스로 판정해 그 형태가 실제로
아는 값만 내보낸다. 모르는 값을 0 이나 1 로 채우면 화면이 그것을 사실로 읽는다.
| 형태 | 예 | total |
last_page |
추가 필드 |
|---|---|---|---|---|
| 전체건수형 | paginate() |
정확 | 정확 | — |
| 상한형 | BoundedPage |
정확 또는 하한 | 하한이면 null |
total_relation total_is_exact result_cap |
| 단순형 | simplePaginate() |
없음 | 없음 | — |
| 커서형 | cursorPaginate() |
없음 | 없음 | next_cursor prev_cursor |
표준 paginate() 를 쓰는 기존 컬렉션의 응답은 필드 단위로 이전과 완전히 같다.
컬렉션이 이 블록을 손으로 조립하면 두 가지가 함께 깨진다. 커서 결과에는 total() 과
lastPage() 가 없어 그 요청만 500 이 되고, 상한형에서는 정확도 필드가 빠져 잘린 건수가
정확한 값처럼 화면에 나간다. 둘 다 표준 paginate() 만 쓰던 시절에는 드러나지 않던 형태라,
목록 하나를 상한형이나 커서형으로 바꾸는 순간 처음 나타난다.
{
"pagination": {
"current_page": 3,
"per_page": 20,
"from": 41,
"to": 60,
"has_more_pages": true,
"last_page": null,
"total": 10000,
"total_relation": "at_least",
"total_is_exact": false,
"result_cap": 10000
}
}
커서(키셋) 페이지네이션
OFFSET 방식은 건너뛸 행을 실제로 읽어야 해서 페이지가 깊어질수록 느려진다. 커서 방식은 직전 페이지 마지막 행의 정렬 키를 WHERE 절 경계로 삼으므로 깊이와 무관하게 일정하다.
use App\Support\Query\KeysetPaginator;
$page = KeysetPaginator::paginate(
$query,
perPage: 20,
sortKeys: [['created_at', 'desc']],
uniqueKey: 'id',
cursor: $request->query(KeysetPaginator::CURSOR_PARAM),
);
관련도순에는 적용할 수 없다
커서는 정렬 키를 WHERE 절 경계로 쓴다. 따라서 정렬 키가 실제 컬럼이어야 한다.
FULLTEXT 관련도 점수(_ft_score)는 행마다 계산되는 값이고 컬럼이 아니므로 경계로 쓸 수 없다.
sort=relevance 는 이 제약 때문에 OFFSET 을 유지한다. 판정은
KeysetPaginator::supports($sortKeys, $columnWhitelist) 가 담당하며, 허용 컬럼 목록 밖의
정렬 키가 하나라도 있으면 false 를 돌려준다.
깨진 커서 문자열은 예외 대신 첫 페이지로 처리한다. 사용자가 주소를 손으로 고쳤다는 이유로 목록 화면이 오류를 띄우게 두지 않는다.
한계값 설정
| 설정 | 기본값 | 의미 |
|---|---|---|
pagination.result_cap |
10000 | 총 건수를 정확히 세는 상한 (0 = 무제한) |
pagination.max_page |
1000 | 직접 요청할 수 있는 페이지 번호 상한 (0 = 무제한) |
해석 순서는 관리자 환경설정 > 고급 → config/core.php 기본값 → 확장 필터 훅 이다.
해석은 PaginationLimits 한 곳에서만 하며, 확장은 값을 리터럴로 다시 적지 않고 훅으로 조정한다.
HookManager::addFilter('core.pagination.filter_result_cap', function (int $cap, ?string $context) {
return $context === 'search' ? 50000 : $cap;
});
max_page 는 남용 차단용이다. 정상 탐색은 has_more_pages 와 커서로 계속 열려 있고,
이 값은 임의의 큰 페이지 번호를 직접 던져 초대형 OFFSET 을 만드는 것만 막는다.
화면 쪽 규약
Pagination 컴포넌트는 세 가지 입력으로 동작한다.
| Prop | 의미 |
|---|---|
totalPages |
마지막 페이지. null 이면 마지막 페이지 점프만 사라진다 — 번호 목록은 1..현재(+다음이 있으면 현재+1)까지 그대로 그린다. 번호 목록까지 비우면 앞 페이지로 직접 뛰어갈 방법이 없어진다 |
hasMorePages |
다음 페이지 존재 여부. 총 건수를 몰라도 정확하다 |
showFirst / showLast |
첫/마지막 버튼을 따로 제어 (미지정 시 showFirstLast 를 따름) |
총 건수 표기는 정확도에 따라 문구가 갈린다.
정확: 총 1,234건
하한: 총 10,000건 이상 (+ 검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있다는 안내)
세지 않은 값을 정확한 것처럼 말하지 않는다.
체크리스트
- 목록 조회가 같은 술어를
count()+get()으로 두 번 실행하지 않는가 paginate()에 조회 컬럼을 명시했는가 (['*']방치 금지)- 정렬 마지막에 기본키를 덧붙여 전순서를 보장했는가
- 총 건수를 모를 때
last_page를null로 내보내는가 - 화면이
total_is_exact를 보고 문구를 바꾸는가 - 상한값을 리터럴로 적지 않고
PaginationLimits를 거치는가 - 커서를 쓴다면 정렬 키가 전부 실제 컬럼인가