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:
HeuJung committed 2026-08-06 11:13:51 +09:00
1 parent 97ab99b7cd
commit 2ae1972a3b
101 files changed
+6160 -322

No files matched your search

+27
View File
@@ -80,6 +80,33 @@ Authorization: Bearer {YOUR_TOKEN}
}
```
#### 총 건수 정확도 (대용량 목록)
매칭이 아주 많을 수 있는 목록(검색 등)은 총 건수를 상한까지만 셉니다. 그런 목록은 위 필드에
더해 정확도를 함께 내보내며, 세지 않은 값을 정확한 것처럼 말하지 않습니다.
| 필드 | 타입 | 의미 |
| --- | --- | --- |
| `total_relation` | string | `exact`(정확) 또는 `at_least`(그 이상) |
| `total_is_exact` | boolean | 총 건수가 정확한지 여부 |
| `result_cap` | integer\|null | 집계에 적용된 상한 (무제한이면 `null`) |
상한을 넘긴 경우 동작은 이렇습니다.
- `total` 은 상한값이며 **그 이상**이라는 뜻입니다 (화면은 "10,000건 이상" 으로 표기)
- `last_page` 는 **`null`** 입니다 — 총 건수를 알아야 계산되는 유일한 값이라 계산할 수 없습니다
- `has_more_pages` 는 그대로 정확합니다. 다음 페이지 이동은 끝까지 열려 있습니다
즉 상한에 걸려도 막히는 것은 마지막 페이지 점프 하나뿐입니다.
#### 단순형·커서형 응답
총 건수를 아예 세지 않는 목록(`simplePaginate`)은 `total` 과 `last_page` 를 **내보내지 않습니다**.
커서 방식 목록은 대신 `next_cursor` / `prev_cursor` 를 실어 보냅니다. 없는 필드를 0 이나 1 로
채우지 않으므로, 화면은 필드 존재 여부로 목록의 종류를 구분할 수 있습니다.
> 상한·커서 규약 상세: [pagination.md](../pagination.md)
일부 목록은 `data.abilities` 에 컬렉션 레벨 권한(`can_create`, `can_delete` 등)을 함께 반환합니다.
화면의 버튼 노출 여부를 이 값으로 판정하세요.
+22
View File
@@ -39,6 +39,7 @@
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
| sort_by | query | string | 아니오 | — | 정렬 기준 필드명 |
| sort_order | query | string | 아니오 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) |
| cursor | query | string | 아니오 | max 500 | 이어보기 커서. 주면 페이지 번호 대신 키셋 방식으로 응답합니다 (기록이 많이 쌓인 사이트에서 뒤쪽 페이지가 느려지지 않음). 형식이 깨진 값은 오류 없이 첫 페이지로 해석됩니다 |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.activity_log.index_validation_rules`).
@@ -169,6 +170,27 @@ HTTP/1.1 200
}
```
**커서 방식 응답 (cursor 파라미터를 준 경우)**
페이지 번호 대신 앞뒤 커서를 싣습니다. 총 건수를 세지 않으므로 `total` 과 `last_page` 는 없습니다.
```json
{
"success": true,
"data": {
"data": [],
"pagination": {
"per_page": 25,
"next_cursor": "eyJpZCI6MTIzLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9",
"prev_cursor": null,
"has_more_pages": true
}
}
}
```
총 건수가 상한을 넘겨 정확히 세지 못한 경우(페이지 번호 방식)에는 `pagination` 에 `total_relation`·`total_is_exact`·`result_cap` 이 함께 실리고 `last_page` 가 `null` 이 됩니다. 상세는 [pagination.md](../pagination.md).
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
+22 -3
View File
@@ -30,8 +30,9 @@
| q | query | string | 아니오 | min 2, max 200 | 검색어 (부분 일치) |
| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) |
| sort | query | string | 아니오 | `relevance`, `latest`, `oldest`, `views`, `popular`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) |
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
| page | query | integer | 아니오 | min 1, max = 목록 한계값 설정 | 조회할 페이지 번호 (1부터 시작). 상한은 관리자 > 환경설정 > 고급의 «페이지 번호 상한» 이며 남용 차단용입니다 — 정상 탐색은 `has_more_pages` 로 계속 열려 있습니다 |
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
| cursor | query | string | 아니오 | max 500 | 커서(키셋) 페이지 이동용 커서. 응답의 `next_cursor` / `prev_cursor` 를 그대로 돌려보냅니다. 특정 탭을 볼 때만 유효하며(전체 탭은 무시), 실제 컬럼 기준 정렬(`latest`·`oldest`·`views`·`popular`·`price_asc`·`price_desc`)에서만 적용됩니다. 관련도순은 계산값 정렬이라 커서를 쓸 수 없어 `page` 기반 이동을 유지합니다. 형식이 깨진 값은 오류 없이 첫 페이지로 처리됩니다 |
| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.index_validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) |
| category_id | query | integer | 아니오 | — | category 식별자 |
@@ -50,7 +51,22 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| q | string | `` | 실제 검색에 사용된 검색어 (요청 `q` 를 trim 하여 에코, 검색어가 비어 있으면 빈 문자열) |
| total | integer | `0` | 전체 개수 (집계) |
| total | integer | `0` | 전체 개수 (집계). 상한을 넘기면 상한값이며 «그 이상» 을 뜻합니다 |
| total_relation | string | `exact` | 총 건수 정확도 (`exact` 정확 / `at_least` 그 이상) |
| total_is_exact | boolean | `true` | 총 건수가 정확한지 여부. `false` 면 화면이 "N건 이상" 으로 표기합니다 |
| result_cap | integer\|null | `10000` | 총 건수 집계에 적용된 상한 (무제한이면 `null`) |
| all_count | integer | `0` | 전체 탭 기준 합계 (탭 배지 표기용) |
| all_count_is_exact | boolean | `true` | 전체 합계가 정확한지 여부. 카테고리 중 하나라도 상한에 걸리면 `false` 입니다 |
| last_page | integer\|null | `1` | 특정 탭 조회 시의 마지막 페이지. **총 건수가 부정확하면 `null`** |
| has_more_pages | boolean | `false` | 다음 페이지 존재 여부 (총 건수를 몰라도 정확) |
| next_cursor | string\|null | `null` | 다음 페이지 커서. 커서 방식으로 응답했을 때만 채워지며, `page` 방식 응답에서는 `null` 입니다 |
| prev_cursor | string\|null | `null` | 이전 페이지 커서. 위와 같습니다 |
| counts_are_exact | object | `{}` | 카테고리별 총 건수 정확도 (`{"posts": true, "products": false}`). 탭 배지가 카테고리마다 그려지므로 정확도도 카테고리마다 제공됩니다 — 정확하지 않은 배지는 화면에서 "이상" 으로 표기됩니다 |
카테고리(탭) 중 하나라도 상한에 걸리면 합계도 정확하지 않습니다 — 정확한 카테고리 몇 개를
더해 봐야 전체가 정확해지지 않기 때문입니다. 그 경우 `total_is_exact` 는 `false` 가 됩니다.
> 상한·페이지 이동 규약 상세: [pagination.md](../pagination.md)
**응답 예시**
@@ -64,7 +80,10 @@ HTTP/1.1 200
"message": "검색어를 입력해주세요.",
"data": {
"q": "",
"total": 0
"total": 0,
"total_relation": "exact",
"total_is_exact": true,
"result_cap": 10000
}
}
```
+16
View File
@@ -4,6 +4,22 @@
---
## 목록 한계값 (`advanced` 탭)
대용량 목록에서 총 건수를 세는 범위와 직접 요청할 수 있는 페이지 번호의 상한입니다.
저장 경로는 다른 고급 설정과 같은 `advanced` 탭이며, 저장소에는 `pagination` 카테고리로 남습니다.
| 필드 | 타입 | 범위 | 의미 |
| --- | --- | --- | --- |
| `advanced.pagination_result_cap` | integer | 0 ~ 1,000,000 | 총 건수를 정확히 세는 상한. 0 이면 항상 전부 셉니다 |
| `advanced.pagination_max_page` | integer | 0 ~ 100,000 | 주소로 직접 요청할 수 있는 최대 페이지 번호. 0 이면 제한하지 않습니다 |
경계값은 설정 응답의 `_meta.limits` 로 함께 내려오며(`advanced_pagination_result_cap_min` 등),
화면 입력 칸의 min/max 와 저장 검증이 같은 값을 공유합니다.
상한을 넘긴 목록의 응답 형태는 [pagination.md](../pagination.md) 를 참고하세요.
## TL;DR (5초 요약)
```text