Files
Gnuboard7/docs/backend/pagination.md
T
HeuJung 3e41dc6075 fix(admin_basic,basic): 상한 총 건수 목록의 페이지 번호 목록 복원
총 건수를 상한까지만 세는 목록(last_page: null)에서 Pagination 이 번호 목록을
통째로 비우고 현재 페이지 숫자 하나만 남겼다. 합의되고 CHANGELOG 에 약속된
동작은 "마지막 페이지 점프만 감춤" 이었으므로, 1..현재 페이지와 (다음이 있으면)
현재+1 까지는 그대로 그리고 그 너머만 계속됨 표시로 둔다. 활동 로그처럼
10,000건을 넘는 화면에서 앞 페이지로 직접 이동할 방법이 없던 것을 되돌린다.

두 템플릿 컴포넌트 회귀 테스트(작성 시 6/7 fail 확인)와 활동 로그 E2E 를 추가하고,
pagination.md 의 화면 규약 서술을 실제 동작으로 정정했다.
2026-09-04 18:53:31 +09:00

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 를 거치는가
  • 커서를 쓴다면 정렬 키가 전부 실제 컬럼인가