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
2026-08-06 11:13:51 +09:00
parent 97ab99b7cd
commit 2ae1972a3b
101 changed files with 6160 additions and 322 deletions
+3 -1
View File
@@ -22,7 +22,9 @@ BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# 설치 직후 기본값은 운영 기준(error). 관리자 환경설정 > 고급에서 조정하며,
# 그 값이 이 항목보다 우선합니다. debug 로 두면 정상 동작 기록까지 매 요청 쌓입니다.
LOG_LEVEL=error
DB_CONNECTION=mysql
+42 -1
View File
@@ -6,7 +6,7 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (33개)
### 백엔드 [backend/](docs/backend/) (34개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -33,6 +33,7 @@
| [language-pack-service.md](docs/backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactivate/uninsta... |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [pagination.md](docs/backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... |
@@ -397,6 +398,28 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [service-repository.md "목록 조회 컬럼 프루닝과 지연 조인" / "정렬 컬럼 화이트리스트" / "화면 정렬 옵션은 게이트의 부분집합이어야 한다" / "관계 테이블 컬럼 기준 정렬" / "허용되는 Raw 쿼리"](docs/backend/service-repository.md)
### 대용량 목록의 총 건수와 페이지 이동
총 건수 상한과 페이지 이동 범위는 **별개 결정**이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고, "다음" 이동은 `per_page + 1` 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 같은 술어를 `count()` 한 번, `get()` 한 번 실행 | `BoundedPaginator::paginate()` 한 번 (총 건수 + 페이지를 한 번에) |
| `paginate(PHP_INT_MAX)` 후 PHP `array_slice` | 실제 `page`/`per_page` 를 저장소까지 하달 |
| `forPage($page, $perPage + 1)` | offset 은 `per_page` 기준으로 따로 계산 (안 그러면 페이지가 깊어질수록 경계가 밀린다) |
| Scout `->keys()->all()` + 무제한 `whereIn` | 키워드 술어를 페이지 쿼리에 직접 밀어넣기 (`DatabaseFulltextEngine::whereFulltext`) |
| FULLTEXT 원문 키워드를 raw 로 바인딩 | 코어 sanitizer 경유 — `+` `-` `*` `"` 입력이 파싱 오류로 500 이 된다 |
| `whereDate` / `whereYear`+`whereMonth` | `FiltersByDateRange` 의 범위 조건 (컬럼에 함수를 씌우면 인덱스를 못 쓴다) |
| 총 건수를 모르는데 `last_page` 를 1 로 채움 | `null` 로 내보내 화면이 마지막 페이지 점프만 감추게 한다 |
| 상한값을 저장소·화면에 리터럴로 재기입 | `PaginationLimits` 단일 해석 + 확장은 `core.pagination.filter_*` 필터 훅으로만 조정 |
| 결과 크기가 데이터 증가에 비례하는데 상한 없는 `->get()` / `->pluck()` | 목록은 페이지네이션, 순회는 `chunkById`/`lazyById`, 몇 건이면 `limit` — 운영자 등록 수에 묶인 설정성 테이블만 예외이며 그 근거를 코드에 남긴다 |
| 배지·요약 건수를 `int` 하나로 돌려주기 | `BoundedPaginator::count()` 의 `BoundedCount` — 잘린 값과 정확한 값이 구분되지 않으면 잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나간다 |
| 여러 카테고리 건수를 합치며 정확도는 버리기 | 하나라도 부정확하면 합계도 부정확. 단, 특정 탭만 볼 때는 그 카테고리의 정확도만 본다 |
| 정렬 마지막이 비고유 컬럼 | 기본키를 덧붙여 전순서 보장 (동률 구간에서 행이 겹치거나 샌다) |
| 관련도순(`_ft_score`)에 커서 적용 | 계산값은 WHERE 절 경계로 쓸 수 없다 — offset 유지 (`KeysetPaginator::supports` 가 판정) |
> 상세: [pagination.md](docs/backend/pagination.md)
### 검색 인덱스 재생성(리인덱싱)
| ❌ 금지 | ✅ 올바른 사용 |
@@ -412,6 +435,24 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [search-system.md](docs/backend/search-system.md)
### 검색 질의는 활성 엔진이 만든다
검색 엔진은 `core.search.engine_drivers` 훅으로 교체 가능하다. 그런데 그 교체가 실제로 먹는 것은 **활성 엔진을 거치는 경로뿐**이다. 저장소가 구체 엔진 클래스를 지목하면 등록된 엔진은 호출될 기회 자체를 잃고, 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작한다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `DatabaseFulltextEngine::whereFulltext(...)` 등 구체 엔진 정적 호출 | `KeywordSearch::apply()` / `::applyAny()` (해석기가 활성 엔진에 위임) |
| `DB::getDriverName() === 'pgsql'` 처럼 드라이버명을 코드에 비교 | 선언형 `config('core.search.*')` + `core.search.like_operators` 필터 훅 |
| 매칭 ID 전량을 PHP 로 적재 후 `whereIn` (`search()->keys()->all()`) | 술어를 페이지 쿼리에 직접 부착 — ID 왕복도 목록 폭발도 없다 |
| 엔진에게 페이지 번호를 넘겨 한 페이지만 받기 | 페이지네이션은 DB 담당. 엔진은 **키 집합 상한**만 책임진다 (`KeywordSearchContext`) |
| 키 집합 상한을 총 건수 상한과 다른 값으로 두기 | 둘 다 `PaginationLimits::resultCap()` — 갈라지면 엔진이 돌려준 건수와 화면 총 건수의 근거가 달라진다 |
| 부분일치 폴백을 "전문검색 없을 때의 임시방편" 으로 취급 | 전문검색 미제공 DBMS 에서는 **그것이 정상 경로** — 와일드카드 escape + 대소문자 규칙을 갖춘다 |
| 확장이 `Model::search()` 를 쓰는 것을 금지로 오해 | Scout 경로는 그대로 유효하다. 새 계약은 대체가 아니라 **추가 통로** |
정적 검사는 이 저장소 안만 볼 수 있다 — 외부 엔진이 상한을 지키는지는 강제할 수 없으므로, 코어는 **값을 손에 쥐어 주는 것**까지 하고 그 값이 도달하는지를 계약 테스트가 고정한다.
> 상세: [search-system.md "키워드 술어는 활성 엔진이 만든다"](docs/backend/search-system.md)
### Listener 데이터 접근
| 금지 | 올바른 사용 |
+23
View File
@@ -66,6 +66,19 @@
- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. 주문 목록의 「발송일」처럼 정렬 기준이 다른 표에 있는 값일 때도 총 건수와 페이지 경계가 어긋나지 않게 조회하는 공통 처리를 함께 넣었습니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
#### 대용량 목록
- 검색 결과나 목록이 아주 많을 때 화면이 열리지 않거나 서버가 멈추던 문제를 개선했습니다. 이제 총 건수는 일정 규모까지만 정확히 세고 그보다 많으면 "10,000건 이상" 처럼 표시하며, 다음 페이지로 넘기는 것은 끝까지 그대로 됩니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다 — 그 위치를 계산하려면 전체를 세야 하기 때문입니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 총 건수를 세는 범위와 주소로 직접 요청할 수 있는 최대 페이지 번호를 관리자 > 환경설정 > 고급에서 조정할 수 있습니다. 두 값 모두 0 으로 두면 제한하지 않습니다.
- 검색 결과가 많아 총 건수를 정확히 세지 못한 경우, 검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있다는 안내를 함께 표시합니다.
- 활동 로그·알림 발송 기록·본인인증 기록·스케줄 실행 이력·회원 목록에도 같은 방식을 적용했습니다. 기록이 수십만 건 쌓여도 목록 첫 화면이 총 건수를 세는 비용에 끌려가지 않습니다.
- 알림함도 같은 방식으로 조회합니다. 알림이 오래 쌓인 계정에서도 목록이 빠르게 열립니다.
- 알림 발송 기록을 최신순으로 볼 때 뒤쪽 페이지를 이동하는 방식이 활동 로그와 동일해졌습니다. 몇 페이지를 넘겨도 속도가 일정하게 유지됩니다.
- 검색 결과 화면에서 다른 분류의 건수 배지가 필요 없을 때는 그 숫자를 세는 작업을 건너뛸 수 있습니다.
- 검색 결과의 탭 배지(게시글 N · 상품 N · 페이지 N)도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 탭을 열어 본 목록과 배지의 숫자가 서로 다른 기준을 말하던 문제가 사라집니다.
- 활동 로그 목록을 페이지 번호 대신 이어보기 방식으로도 조회할 수 있습니다. 기록이 많이 쌓인 사이트에서 뒤쪽으로 갈수록 느려지지 않습니다. 화면 동작은 그대로이며, 필요할 때만 쓰는 선택지입니다.
- 통합 검색도 최신순·조회순·가격순처럼 목록 자체의 순서로 볼 때는 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 검색 결과가 아주 많아도 몇 페이지를 넘기든 속도가 일정합니다. 관련도순은 계산된 점수로 정렬하므로 종전의 페이지 번호 방식을 유지합니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
#### 성능 점검
- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다.
@@ -78,6 +91,13 @@
- 알림 정의 목록이 지금 보고 있는 채널의 템플릿만 받아오도록 바꿨습니다. 예전에는 정의마다 모든 채널의 제목과 본문이 함께 실려, 채널을 여러 개 쓰는 사이트일수록 목록이 무거웠습니다. 채널을 지정하지 않고 목록을 조회하면 템플릿 본문은 함께 오지 않으며, 전체 채널의 내용이 필요하면 정의 단건 조회를 이용하면 됩니다. 화면에 보이는 내용과 '되돌리기' 버튼 표시 조건은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 본인인증 메시지 정의 목록이 대표 템플릿 한 건만 받아오도록 바꿨습니다. 전체 템플릿은 정의를 열었을 때 종전대로 받아옵니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 역할 목록에 할당된 권한 개수를 함께 표시할 수 있도록 집계 값을 제공합니다. 권한 목록 자체를 싣지 않고도 규모를 확인할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 페이지를 한 번 열 때마다 서버가 되풀이하던 준비 작업을 정리했습니다. 내부 동작 기록을 매 요청 400줄씩 남기던 부분, 환경설정 파일을 한 요청에서 스무 번 넘게 다시 읽던 부분, 이미 저장해 둔 목록을 매번 데이터베이스에서 다시 가져오던 부분을 없앴습니다. 화면에 보이는 내용은 그대로이며 응답이 빨라집니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 권한 확인이 화면 요소마다 데이터베이스를 다시 조회하던 것을 요청당 한 번으로 줄였습니다. 메뉴·버튼이 많은 화면일수록 체감 차이가 큽니다. 권한을 바꾸면 종전처럼 다음 요청부터 즉시 반영됩니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글·상품·쿠폰 검색이 조건에 맞는 항목 전체를 먼저 메모리에 올린 뒤 다시 조회하던 방식을 없앴습니다. 검색 결과가 아주 많아도 메모리 사용량이 늘지 않습니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 플러그인으로 다른 검색엔진을 연결했을 때 게시판·상품·페이지 검색이 그 엔진을 쓰지 않고 기본 방식으로 동작하던 문제를 해소했습니다. 이제 연결한 검색엔진이 모든 검색 화면에 적용됩니다. 전문검색을 제공하지 않는 데이터베이스에서도 검색어의 `%` `_` 같은 기호를 입력한 글자 그대로 찾도록 개선했습니다.
- 기간으로 걸러 보는 목록(주문·회원·쿠폰·스케줄 등)의 날짜 조건을 색인을 활용하는 방식으로 바꿨습니다. 기록이 많은 사이트에서 기간 검색이 빨라집니다.
- 설치 직후 기록 수준(`LOG_LEVEL`) 기본값을 운영 기준(`error`)으로 바꿨습니다. 이전 기본값은 정상 동작까지 모두 기록해 로그 파일이 빠르게 커졌습니다. 관리자 > 환경설정 > 고급에서 조정할 수 있으며, 그 값이 이 항목보다 우선합니다.
- 권장 서버 사양(CPU·메모리)을 시스템 요구사항 문서에 추가했습니다.
- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
@@ -122,6 +142,9 @@
#### 화면 표시·목록 상태
- 건수가 아주 많아 총 건수를 끝까지 세지 못하는 목록에서 항목 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 번호를 지어내지 않고 「-」로 표시하며, 총 건수를 정확히 센 목록의 번호는 종전과 동일합니다.
- 활동 로그 목록의 총 건수가 끝까지 세지 못한 값인데도 정확한 숫자처럼 표시되던 문제를 수정했습니다. 이제 그 경우 「이상」 표시가 함께 나옵니다.
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
- 위 문제와 같은 뿌리에서 비롯된 화면 표시 오류를 함께 정리했습니다. 같은 방식으로 작성한 값이 어디에 놓이느냐(목록 칸·본문·표시 조건·버튼 동작)에 따라 다르게 해석되던 것을 한 가지 기준으로 통일했으며, 아래 항목이 그 결과입니다. 대부분 오류 메시지 없이 값이 비거나 잘못 보이던 증상이라 눈치채기 어려웠습니다.
- 행마다 달라지는 조건(예: "활성 상태인 항목만 표시")을 목록에 걸면 해당하는 행만 걸러지지 않고 **목록 전체가 사라지던** 문제를 수정했습니다. 이제 행마다 조건을 따져 표시하며, 검색엔진 봇이 보는 화면에도 같은 규칙이 적용됩니다.
@@ -0,0 +1,39 @@
<?php
namespace App\Contracts\Pagination;
use App\Enums\TotalRelation;
/**
* 총 건수 정확도를 스스로 밝히는 페이지 결과 계약
*
* 표준 `LengthAwarePaginator` 는 `total()` 이 항상 정확하다고 가정한다. 대용량 목록에서
* 상한을 걸고 센 결과는 그 가정을 만족하지 못하므로, 정확도를 함께 실어 나른다.
*
* 이 계약을 구현한 페이지는 `BaseApiCollection::paginationMeta()` 가 자동으로 알아보고
* `total_relation` / `total_is_exact` / `result_cap` 메타를 응답에 덧붙인다. 컬렉션 쪽
* 코드 변경은 필요 없다.
*/
interface BoundedTotalAware
{
/**
* 총 건수와 실제 매칭 건수의 관계를 반환합니다.
*
* @return TotalRelation 정확(Exact) 또는 하한(AtLeast)
*/
public function totalRelation(): TotalRelation;
/**
* 총 건수 집계에 적용된 상한을 반환합니다.
*
* @return int|null 상한 (무제한이면 null)
*/
public function resultCap(): ?int;
/**
* 총 건수가 상한에 걸려 잘렸는지 여부를 반환합니다.
*
* @return bool 잘렸으면 true (= totalRelation 이 AtLeast)
*/
public function isTruncated(): bool;
}
@@ -2,6 +2,7 @@
namespace App\Contracts\Repositories;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -18,20 +19,22 @@ interface ActivityLogRepositoryInterface
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator;
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로 응답합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator;
public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator;
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
@@ -39,7 +42,7 @@ interface ActivityLogRepositoryInterface
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int;
@@ -5,6 +5,7 @@ namespace App\Contracts\Repositories;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
interface NotificationLogRepositoryInterface
@@ -44,5 +45,5 @@ interface NotificationLogRepositoryInterface
*
* @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용)
*/
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator;
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator|CursorPaginator;
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Enums;
/**
* 목록 총 건수(total)와 실제 매칭 건수의 관계
*
* 대용량 목록에서 `COUNT(*)` 전량 집계는 비용이 커서 상한을 걸고 센다.
* 상한 이하면 센 값이 곧 실제 건수이고, 상한을 넘으면 "그 이상"이라는 사실만 알 수 있다.
* 화면은 이 값을 보고 "1,234건" 과 "10,000건 이상" 을 구분해 표기한다.
*/
enum TotalRelation: string
{
/** 정확 — total 이 실제 매칭 건수와 같다 */
case Exact = 'exact';
/** 하한 — 실제 매칭 건수가 total 이상이다 (상한 초과로 정확히 세지 않음) */
case AtLeast = 'at_least';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 총 건수가 정확한지 여부를 반환합니다.
*
* @return bool 정확하면 true
*/
public function isExact(): bool
{
return $this === self::Exact;
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string lang 키 해석 결과
*/
public function label(): string
{
return __('pagination.total_relation.'.$this->value);
}
}
+4 -9
View File
@@ -124,15 +124,10 @@ class HookListenerRegistrar
self::addQueuedAction($hookName, $listenerClass, $method, $priority);
}
Log::info('훅 리스너 등록 완료', [
'hook' => $hookName,
'listener' => $listenerClass,
'method' => $method,
'priority' => $priority,
'type' => $type,
'sync' => $forceSync,
'source' => $source,
]);
// 등록 성공은 로그로 남기지 않는다. 리스너 112개 × 구독 400건이 요청마다 부팅되므로
// 한 줄씩만 남겨도 매 요청 400줄이 쌓인다. 기본 설치가 production + LOG_LEVEL 조합상
// 이 줄들을 그대로 기록하므로, 정상 동작을 알리는 데 드는 비용이 동작 자체보다 커진다.
// 등록 실패는 아래 catch 에서 계속 기록한다.
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Base;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
@@ -21,7 +22,7 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자 소유권을 확인합니다.
*
* @param int $userId 확인할 사용자 ID
* @param int $userId 확인할 사용자 ID
* @return bool
*/
protected function isOwner(int $userId): bool
@@ -32,7 +33,7 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자가 특정 리소스에 접근할 수 있는지 확인합니다.
*
* @param mixed $resource 접근하려는 리소스
* @param mixed $resource 접근하려는 리소스
* @return bool
*/
protected function canAccessResource($resource): bool
@@ -48,8 +49,8 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자 활동을 기록합니다.
*
* @param string $action 수행한 작업
* @param array $data 관련 데이터
* @param string $action 수행한 작업
* @param array $data 관련 데이터
* @return void
*/
protected function logUserActivity(string $action, array $data = []): void
@@ -58,20 +59,20 @@ abstract class AuthBaseController extends BaseApiController
Log::info("User Activity: {$action}", [
'user_id' => $this->getCurrentUser()?->uuid,
'data' => $data,
'timestamp' => now()
'timestamp' => now(),
]);
}
/**
* 리소스 소유권을 확인하고, 소유자가 아니면 Forbidden 응답을 반환합니다.
*
* @param mixed $resource 확인할 리소스
* @param string $messageKey 오류 메시지 키
* @return \Illuminate\Http\JsonResponse|null 소유자이면 null, 아니면 Forbidden 응답
* @param mixed $resource 확인할 리소스
* @param string $messageKey 오류 메시지 키
* @return JsonResponse|null 소유자이면 null, 아니면 Forbidden 응답
*/
protected function checkOwnership($resource, string $messageKey = 'common.forbidden')
{
if (!$this->canAccessResource($resource)) {
if (! $this->canAccessResource($resource)) {
return $this->forbidden($messageKey);
}
@@ -81,14 +82,21 @@ abstract class AuthBaseController extends BaseApiController
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Auth API Usage: {$endpoint}", [
//
// PublicBaseController 와 같은 이유로 디버그 모드에서만 기록한다.
// 통계 시스템이 서기 전까지 요청마다 로그 파일에 줄을 쌓지 않는다.
if (! config('app.debug')) {
return;
}
Log::debug("Auth API Usage: {$endpoint}", [
'user_id' => $this->getCurrentUser()?->uuid,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
@@ -3,9 +3,7 @@
namespace App\Http\Controllers\Api\Base;
use App\Contracts\Extension\CacheInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 API용 베이스 컨트롤러
@@ -27,9 +25,9 @@ abstract class PublicBaseController extends BaseApiController
* 이는 확장 설치 직후·활성화 전 등의 일시적 상태에서 얻은 "not found" 같은
* 에러 응답이 영구 캐시되어 복구 후에도 잘못된 응답을 반환하는 문제를 방지한다.
*
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
* @return mixed
*/
protected function cached(string $key, callable $callback, int $ttl = 3600)
@@ -54,18 +52,27 @@ abstract class PublicBaseController extends BaseApiController
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Public API Usage: {$endpoint}", [
//
// 호출 지점은 레이아웃·확장 번들·정적 자산처럼 페이지 로드마다 여러 번 열리는
// 공개 엔드포인트다. 통계 시스템이 서기 전까지 이 자리가 요청마다 로그 파일에
// 줄을 쌓지 않도록, 디버그 모드에서만 기록한다. 기본 설치는 APP_DEBUG=false 이므로
// 아무것도 쓰지 않는다.
if (! config('app.debug')) {
return;
}
Log::debug("Public API Usage: {$endpoint}", [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'data' => $data,
'timestamp' => now()
'timestamp' => now(),
]);
}
@@ -80,8 +87,7 @@ abstract class PublicBaseController extends BaseApiController
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'referer' => request()->header('referer'),
'timestamp' => now()
'timestamp' => now(),
];
}
}
@@ -2,9 +2,11 @@
namespace App\Http\Controllers\Api\Public;
use App\Enums\TotalRelation;
use App\Extension\HookManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\SearchRequest;
use App\Support\Query\PaginationLimits;
use Illuminate\Http\JsonResponse;
/**
@@ -18,7 +20,7 @@ class PublicSearchController extends PublicBaseController
/**
* 통합 검색 수행
*
* @param SearchRequest $request HTTP 요청
* @param SearchRequest $request HTTP 요청
* @return JsonResponse 검색 결과
*/
public function search(SearchRequest $request): JsonResponse
@@ -36,6 +38,14 @@ class PublicSearchController extends PublicBaseController
return $this->success(__('search.empty_keyword'), $this->buildEmptyResponse());
}
// 특정 탭을 보는 중이라면, 다른 탭의 배지 건수를 아예 세지 않을 수 있다.
// 기본값은 세는 쪽이다 — 탭 배지가 화면 요소라 끄면 숫자가 사라진다. 다만 배지가
// 필요 없는 화면(더 보기 페이지 등)은 with_counts=false 로 그 비용을 없앤다.
// 전체 탭은 배지가 화면의 주 정보라 이 값과 무관하게 항상 센다.
$includeInactiveCounts = $type === 'all'
? true
: $request->boolean('with_counts', true);
// 검색 컨텍스트 구성 (모듈별 파라미터는 request 전체를 전달하여 각 모듈이 직접 처리)
$context = [
'q' => $q,
@@ -44,6 +54,10 @@ class PublicSearchController extends PublicBaseController
'page' => $page,
'per_page' => $perPage,
'all_tab_limit' => 5,
// 커서는 특정 탭을 볼 때만 의미가 있다. 전체 탭은 카테고리마다 몇 건씩만
// 보여 주므로 깊은 페이지 자체가 없고, 카테고리별 커서를 하나로 합칠 수도 없다.
'cursor' => $type === 'all' ? null : ($validated['cursor'] ?? null),
'include_inactive_counts' => $includeInactiveCounts,
'user' => $request->user(),
'request' => $request,
];
@@ -56,8 +70,14 @@ class PublicSearchController extends PublicBaseController
// 프론트엔드용 응답 구조로 변환 (훅을 통해 모듈이 처리)
$response = $this->buildResponse($q, $results, $context);
// 총 건수가 상한에 걸렸으면 "N건" 이 아니라 "N건 이상" 으로 알린다.
// 세지 않은 값을 정확한 것처럼 말하지 않는다.
$messageKey = ($response['total_is_exact'] ?? true)
? 'search.results_found'
: 'search.results_found_at_least';
return $this->success(
__('search.results_found', ['count' => number_format($response['total'])]),
__($messageKey, ['count' => number_format($response['total'])]),
$response
);
}
@@ -72,6 +92,9 @@ class PublicSearchController extends PublicBaseController
return [
'q' => '',
'total' => 0,
'total_relation' => TotalRelation::Exact->value,
'total_is_exact' => true,
'result_cap' => null,
];
}
@@ -81,9 +104,9 @@ class PublicSearchController extends PublicBaseController
* 코어는 기본 응답 구조만 생성하고,
* 각 모듈이 core.search.build_response 훅을 통해 자신의 카테고리 응답을 구성합니다.
*
* @param string $q 검색어
* @param array $results Hook에서 반환된 검색 결과
* @param array $context 검색 컨텍스트
* @param string $q 검색어
* @param array $results Hook에서 반환된 검색 결과
* @param array $context 검색 컨텍스트
* @return array 프론트엔드용 응답 구조
*/
private function buildResponse(string $q, array $results, array $context): array
@@ -97,8 +120,15 @@ class PublicSearchController extends PublicBaseController
// 모듈은 $results에서 자신의 데이터를 가져와 $response에 추가
$response = HookManager::applyFilters('core.search.build_response', $response, $results, $context);
// 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용)
// 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용).
// "전체" 탭 배지도 잘린 합계인지 알아야 하므로 정확도를 함께 붙인다.
$response['all_count'] = $response['total'];
$response['all_count_is_exact'] = $this->resolveTotalAccuracy($results)['total_is_exact'];
// 탭 배지는 카테고리마다 하나씩 그려지므로 정확도도 카테고리마다 필요하다.
// 모듈이 각자 자기 키를 내보내면 어떤 배지는 정확도를 받고 어떤 배지는 못 받아,
// 상한에 걸린 숫자가 그 배지에서만 정확한 값처럼 보인다. 코어가 일괄로 붙인다.
$response['counts_are_exact'] = $this->resolveCategoryAccuracy($results);
// 특정 탭 조회 시 total을 해당 탭의 count로 설정
$type = $context['type'] ?? 'all';
@@ -109,13 +139,14 @@ class PublicSearchController extends PublicBaseController
}
}
return $response;
// 정확도 메타 — 전체 탭은 카테고리 합집합, 특정 탭은 그 카테고리 하나만 본다.
return array_merge($response, $this->resolveTotalAccuracy($results, $type));
}
/**
* 전체 검색 결과 수 계산
*
* @param array $results Hook에서 반환된 검색 결과
* @param array $results Hook에서 반환된 검색 결과
* @return int 전체 결과 수
*/
private function calculateTotal(array $results): int
@@ -127,4 +158,60 @@ class PublicSearchController extends PublicBaseController
return $total;
}
/**
* 카테고리별 총 건수 정확도를 모읍니다.
*
* 탭 배지는 카테고리 수만큼 그려지므로 정확도도 그 수만큼 있어야 합니다. 하나라도
* 빠지면 그 배지에서만 상한에 걸린 숫자가 정확한 값처럼 보이고, 오류로는 드러나지
* 않은 채 그냥 틀린 숫자로 남습니다.
*
* @param array $results Hook에서 반환된 검색 결과
* @return array<string, bool> 카테고리 => 정확 여부
*/
private function resolveCategoryAccuracy(array $results): array
{
$accuracy = [];
foreach ($results as $category => $categoryData) {
$accuracy[$category] = ($categoryData['total_is_exact'] ?? true) === true;
}
return $accuracy;
}
/**
* 응답 전체의 총 건수 정확도를 정합니다.
*
* 전체 탭에서는 카테고리 중 하나라도 상한에 걸리면 합계도 "이상" 이다 — 정확한
* 카테고리 몇 개를 더해 봐야 전체가 정확해지지 않는다.
*
* 특정 탭을 보는 중이면 `total` 이 그 카테고리 건수로 바뀌므로 정확도도 그 카테고리
* 하나만 본다. 다른 카테고리가 잘렸다는 이유로 정확한 값을 "이상" 이라 말하지 않는다.
*
* @param array $results Hook에서 반환된 검색 결과
* @param string $type 조회 탭 (all 이면 전체 합계)
* @return array{total_relation: string, total_is_exact: bool, result_cap: int|null} 정확도 메타
*/
private function resolveTotalAccuracy(array $results, string $type = 'all'): array
{
$scoped = ($type !== 'all' && array_key_exists($type, $results))
? [$results[$type]]
: $results;
$isExact = true;
foreach ($scoped as $categoryData) {
if (($categoryData['total_is_exact'] ?? true) === false) {
$isExact = false;
break;
}
}
return [
'total_relation' => $isExact ? TotalRelation::Exact->value : TotalRelation::AtLeast->value,
'total_is_exact' => $isExact,
'result_cap' => PaginationLimits::resultCap('search'),
];
}
}
+6 -19
View File
@@ -6,6 +6,7 @@ use App\Enums\PermissionType;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Models\Role;
use App\Support\GuestRoleResolver;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
@@ -14,13 +15,6 @@ use Symfony\Component\HttpFoundation\Response;
class PermissionMiddleware
{
/**
* guest role 캐시
*
* @var Role|null
*/
protected static $guestRoleCache = null;
/**
* 특정 권한을 가진 사용자 또는 비회원(guest)의 접근을 허용합니다.
*
@@ -125,10 +119,9 @@ class PermissionMiddleware
return false;
}
return $guestRole->permissions()
->where('identifier', $permission)
->where('type', $type)
->exists();
// 적재해 둔 권한 컬렉션에서 판정한다. permissions() 로 빌더를 다시 열면
// 캐시해 둔 의미가 사라져 판정마다 쿼리가 나간다.
return GuestRoleResolver::hasPermission($permission, $type);
}
/**
@@ -138,13 +131,7 @@ class PermissionMiddleware
*/
protected function getGuestRole(): ?Role
{
if (self::$guestRoleCache === null) {
self::$guestRoleCache = Role::where('identifier', 'guest')
->with('permissions')
->first();
}
return self::$guestRoleCache;
return GuestRoleResolver::resolve();
}
/**
@@ -158,7 +145,7 @@ class PermissionMiddleware
*/
public static function clearGuestRoleCache(): void
{
self::$guestRoleCache = null;
GuestRoleResolver::flush();
}
/**
@@ -47,6 +47,10 @@ class ActivityLogIndexRequest extends FormRequest
// 조용히 기본 정렬로 되돌리지 않도록 한다 (service-repository.md "정렬 컬럼 화이트리스트").
'sort_by' => ['nullable', 'string', Rule::in(['created_at', 'action', 'log_type'])],
'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
// 커서를 주면 목록이 키셋 방식으로 응답한다. 로그는 계속 쌓이기만 하므로 깊은
// 페이지를 OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 한다.
// 형식이 깨진 값은 KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다.
'cursor' => ['nullable', 'string', 'max:500'],
];
return HookManager::applyFilters('core.activity_log.index_validation_rules', $rules, $this);
@@ -40,6 +40,10 @@ class NotificationLogIndexRequest extends FormRequest
// 게이트가 더 좁으면 화면 정렬 셀렉트가 제공하는 옵션(수신자명순/제목순)이 422 로 막힌다.
'sort_by' => ['nullable', 'string', 'in:id,channel,notification_type,status,sent_at,created_at,recipient_name,subject'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
// 커서를 주면 목록이 키셋 방식으로 응답한다. 로그는 계속 쌓이기만 하므로 깊은
// 페이지를 OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 한다.
// 형식이 깨진 값은 KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다.
'cursor' => ['nullable', 'string', 'max:500'],
];
return HookManager::applyFilters(
+38 -2
View File
@@ -3,6 +3,8 @@
namespace App\Http\Requests\Public;
use App\Extension\HookManager;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
@@ -23,10 +25,30 @@ class SearchRequest extends FormRequest
return true;
}
/**
* 검증 전에 쿼리 파라미터를 정규화합니다.
*
* 쿼리스트링은 언제나 문자열로 도착하므로 `with_counts=false` 가 `boolean` 규칙에
* 걸려 422 가 된다. 해석 가능한 값만 캐스팅하고, 그 외(오타 등)는 손대지 않아
* 규칙이 그대로 걸러내게 둔다 — null 로 바꾸면 오타가 "미지정" 으로 통과한다.
*/
protected function prepareForValidation(): void
{
if (! $this->has('with_counts')) {
return;
}
$normalized = filter_var($this->input('with_counts'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($normalized !== null) {
$this->merge(['with_counts' => $normalized]);
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -35,8 +57,21 @@ class SearchRequest extends FormRequest
'q' => ['nullable', 'string', 'min:2', 'max:200'],
'type' => ['nullable', 'string'],
'sort' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
// 페이지 상한은 남용 차단용이다. 정상 탐색은 has_more_pages 로 계속 열려 있고,
// 임의의 큰 페이지 번호를 직접 던져 초대형 OFFSET 을 만드는 것만 막는다.
'page' => array_filter([
'nullable',
'integer',
'min:1',
($maxPage = PaginationLimits::maxPage('search')) !== null ? 'max:'.$maxPage : null,
]),
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
// 깊은 페이지를 OFFSET 없이 넘기기 위한 커서. 형식이 깨진 값은
// KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다.
'cursor' => ['nullable', 'string', 'max:500'],
// 특정 탭만 볼 때 다른 탭의 배지 건수를 세지 않게 하는 스위치.
// 배지를 그리지 않는 화면에서 그 집계 비용을 없앤다 (기본값은 세는 쪽).
'with_counts' => ['nullable', 'boolean'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
@@ -59,6 +94,7 @@ class SearchRequest extends FormRequest
'q.max' => __('search.validation.q_max'),
'page.integer' => __('search.validation.page_integer'),
'page.min' => __('search.validation.page_min'),
'page.max' => __('search.validation.page_max'),
'per_page.integer' => __('search.validation.per_page_integer'),
'per_page.min' => __('search.validation.per_page_min'),
'per_page.max' => __('search.validation.per_page_max'),
@@ -308,6 +308,10 @@ class SaveSettingsRequest extends FormRequest
'advanced.geoip_license_key' => ['nullable', 'string', 'max:200', 'regex:/^[A-Za-z0-9_]+$/'],
'advanced.geoip_auto_update_enabled' => ['nullable', 'boolean'],
// 목록 한계값 (advanced 탭) — 0 은 무제한
'advanced.pagination_result_cap' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_result_cap_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_result_cap_max', 1000000)],
'advanced.pagination_max_page' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_max_page_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_max_page_max', 100000)],
// 드라이버 설정 (drivers 탭)
'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS)]),
'drivers.s3_bucket' => ['nullable', 'string', 'max:255'],
@@ -713,6 +717,14 @@ class SaveSettingsRequest extends FormRequest
'advanced.sql_query_log.required' => __('validation.settings.sql_query_log_required'),
'advanced.sql_query_log.boolean' => __('validation.settings.sql_query_log_boolean'),
// 목록 한계값
'advanced.pagination_result_cap.integer' => __('validation.settings.pagination_result_cap_integer'),
'advanced.pagination_result_cap.min' => __('validation.settings.pagination_result_cap_min'),
'advanced.pagination_result_cap.max' => __('validation.settings.pagination_result_cap_max'),
'advanced.pagination_max_page.integer' => __('validation.settings.pagination_max_page_integer'),
'advanced.pagination_max_page.min' => __('validation.settings.pagination_max_page_min'),
'advanced.pagination_max_page.max' => __('validation.settings.pagination_max_page_max'),
// 코어 업데이트 설정
'advanced.core_update_github_url.url' => __('validation.settings.core_update_github_url_invalid'),
'advanced.core_update_github_url.max' => __('validation.settings.core_update_github_url_max'),
+4 -10
View File
@@ -3,7 +3,6 @@
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 활동 로그 컬렉션 리소스
@@ -36,15 +35,10 @@ class ActivityLogCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($activityLog) {
return (new ActivityLogResource($activityLog))->toArray(request());
}),
'pagination' => $this->resource instanceof LengthAwarePaginator ? [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
] : null,
// 메타를 손으로 조립하면 정확도 필드(total_relation·total_is_exact·result_cap)가
// 빠진다. 그러면 상한에 걸려 잘린 건수가 화면에서 정확한 값처럼 읽힌다
// (실측: 실제 88,792 건이 "10000건" 으로 표기). 표준 메타 한 곳만 쓴다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
@@ -36,15 +36,8 @@ class IdentityMessageDefinitionCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($definition) use ($request) {
return (new IdentityMessageDefinitionResource($definition))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
'has_more_pages' => $this->hasMorePages(),
],
// 표준 메타를 쓴다 — 페이지 결과의 형태를 스스로 판정해 그 형태가 아는 값만 낸다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
+61 -10
View File
@@ -2,10 +2,14 @@
namespace App\Http\Resources;
use App\Contracts\Pagination\BoundedTotalAware;
use App\Helpers\PermissionHelper;
use App\Http\Resources\Traits\HasRowNumber;
use App\Support\Query\BoundedPage;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\CursorPaginator;
/**
* API 컬렉션 리소스 기본 클래스
@@ -61,24 +65,71 @@ abstract class BaseApiCollection extends ResourceCollection
* 정확히 판정할 수 있도록 노출합니다. 전체 조회(get) 등 paginator 가 아닌
* 경우에는 빈 배열을 반환하므로 toArray 에서 array_merge 로 안전하게 합칠 수 있습니다.
*
* 페이지 결과는 세 형태 중 하나이며, 형태를 스스로 판정해 그 형태가 실제로 아는
* 값만 내보냅니다. 모르는 값을 0 이나 1 로 채우면 화면이 그것을 사실로 읽습니다.
*
* | 형태 | 예 | total | last_page | 추가 |
* |---|---|---|---|---|
* | 전체건수형 | `paginate()` | 정확 | 정확 | — |
* | 상한형 | {@see BoundedPage} | 정확 또는 하한 | 하한이면 null | `total_relation` `total_is_exact` `result_cap` |
* | 단순형 | `simplePaginate()` / `cursorPaginate()` | 없음 | 없음 | 커서면 `next_cursor` `prev_cursor` |
*
* 기존 필드는 하나도 제거하지 않으므로, 표준 `paginate()` 를 쓰는 기존 컬렉션의
* 응답은 이전과 완전히 동일합니다.
*
* @return array<string, mixed> ['pagination' => [...]] 또는 빈 배열
*/
protected function paginationMeta(): array
{
if (! method_exists($this->resource, 'currentPage')) {
$resource = $this->resource;
if ($resource instanceof CursorPaginator) {
return ['pagination' => $this->cursorPaginationMeta($resource)];
}
if (! method_exists($resource, 'currentPage')) {
return [];
}
$meta = [
'current_page' => $resource->currentPage(),
'per_page' => $resource->perPage(),
'from' => $resource->firstItem(),
'to' => $resource->lastItem(),
'has_more_pages' => $resource->hasMorePages(),
];
// 단순형(simplePaginate)은 총 건수를 아예 세지 않으므로 total/last_page 를 내보내지 않는다.
if ($resource instanceof LengthAwarePaginatorContract) {
$meta['last_page'] = $resource->lastPage();
$meta['total'] = $resource->total();
}
if ($resource instanceof BoundedTotalAware) {
$meta['total_relation'] = $resource->totalRelation()->value;
$meta['total_is_exact'] = $resource->totalRelation()->isExact();
$meta['result_cap'] = $resource->resultCap();
}
return ['pagination' => $meta];
}
/**
* 커서 페이지의 pagination 메타를 만듭니다.
*
* 커서 방식은 총 건수와 페이지 번호를 계산하지 않습니다. 대신 앞뒤 이동 커서를
* 그대로 실어 보내며, 화면은 이 값으로 이전/다음 버튼을 만듭니다.
*
* @param CursorPaginator $resource 커서 페이지 결과
* @return array<string, mixed> pagination 메타
*/
private function cursorPaginationMeta(CursorPaginator $resource): array
{
return [
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
'per_page' => $resource->perPage(),
'has_more_pages' => $resource->hasMorePages(),
'next_cursor' => $resource->nextCursor()?->encode(),
'prev_cursor' => $resource->previousCursor()?->encode(),
];
}
}
@@ -19,7 +19,7 @@ class NotificationDefinitionCollection extends BaseApiCollection
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
@@ -31,15 +31,8 @@ class NotificationDefinitionCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($definition) use ($request) {
return (new NotificationDefinitionResource($definition))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
'has_more_pages' => $this->hasMorePages(),
],
// 표준 메타를 쓴다 — 페이지 결과의 형태를 스스로 판정해 그 형태가 아는 값만 낸다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
@@ -19,7 +19,7 @@ class NotificationLogCollection extends BaseApiCollection
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
@@ -31,15 +31,10 @@ class NotificationLogCollection extends BaseApiCollection
'data' => $this->mapWithRowNumber(function ($log) use ($request) {
return (new NotificationLogResource($log))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
// 표준 메타를 쓴다. 이 목록은 커서 요청이면 CursorPaginator 로, 그 외에는 상한형
// 페이지로 온다. 손으로 조립하면 커서 결과에 없는 total()/lastPage() 를 불러
// 그 요청만 500 이 되고, 상한형에서는 정확도 메타가 빠진다.
...$this->paginationMeta(),
...($abilities ? ['abilities' => $abilities] : []),
];
}
+12 -2
View File
@@ -2,6 +2,7 @@
namespace App\Http\Resources\Traits;
use App\Contracts\Pagination\BoundedTotalAware;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@@ -57,7 +58,7 @@ trait HasRowNumber
* @param int $total 전체 항목 수
* @param int $perPage 페이지당 항목 수
* @param int $currentPage 현재 페이지 번호
* @return int 계산된 순번
* @return int|null 계산된 순번 (총 건수가 잘린 내림차순이면 null)
*/
protected function calculateRowNumber(
int $index,
@@ -65,13 +66,22 @@ trait HasRowNumber
int $total,
int $perPage,
int $currentPage
): int {
): ?int {
if ($sortOrder === 'asc') {
// 오름차순: 1, 2, 3, ... (페이지별 연속)
// 예: 2페이지 → ((2-1) * 10) + 0 + 1 = 11, 12, 13, ...
// offset 만으로 정해지므로 총 건수가 잘려도 그대로 정확하다.
return (($currentPage - 1) * $perPage) + $index + 1;
}
// 내림차순 순번은 "전체 몇 건 중 몇 번째" 라 총 건수를 알아야 나온다. 총 건수가
// 상한에 걸려 잘렸으면 그 값으로 역산한 순번은 첫 페이지부터 이미 틀리고, 상한을
// 넘어선 페이지에서는 0 과 음수까지 내려간다. 틀린 숫자를 내보내는 것보다
// 내보내지 않는 편이 낫으므로 `last_page` 와 같은 원칙으로 null 을 돌려준다.
if ($this->resource instanceof BoundedTotalAware && ! $this->resource->totalRelation()->isExact()) {
return null;
}
// 내림차순: total, total-1, ... (역순)
// 예: 총 30개, 2페이지 → 30 - ((2-1) * 10) - 0 = 20, 19, 18, ...
return $total - (($currentPage - 1) * $perPage) - $index;
@@ -5,14 +5,16 @@ namespace App\Http\Resources;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use Illuminate\Http\Request;
/**
* 알림함 컬렉션
*/
class UserNotificationCollection extends BaseApiCollection
{
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @return array
* @param Request $request HTTP 요청
* @return array<string, mixed> 변환된 배열
*/
public function toArray(Request $request): array
{
@@ -29,15 +31,10 @@ class UserNotificationCollection extends BaseApiCollection
->withTypeLabelMap($typeLabelMap)
->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
// 표준 메타를 쓴다. 손으로 조립하면 페이지 결과의 형태(전체건수형/상한형/단순형)를
// 구분하지 못해, 상한에 걸려 잘린 총 건수가 정확한 값처럼 나가고 마지막 페이지도
// 계산할 수 없는데 숫자가 채워진다.
...$this->paginationMeta(),
];
}
}
+114 -50
View File
@@ -51,6 +51,22 @@ class User extends Authenticatable implements HasLocalePreference
*/
protected array $effectiveScopeCache = [];
/**
* 보유 권한 부여 내역 캐시 (인스턴스 레벨)
*
* 한 요청에서 권한 판정은 수십~수백 번 일어난다 — 레이아웃 노드마다, 목록 행마다,
* 리소스 필드마다 부른다. 판정마다 DB 에 물으면 노드/행 수에 비례해 쿼리가 늘어난다.
* 첫 판정에서 `roles.permissions` 를 한 번 적재해 두고 이후로는 배열만 본다.
*
* 캐시 수명은 **모델 인스턴스**, 즉 요청 스코프다. 크로스 요청 캐시를 두지 않으므로
* 권한을 바꾸면 다음 요청부터 즉시 반영된다.
*
* 구조: 권한 식별자 ⇒ [{type, scope_type}, ...] (같은 권한을 여러 역할이 주면 여러 건)
*
* @var array<string, array<int, array{type: string|null, scope_type: string|null}>>|null
*/
protected ?array $permissionGrantsCache = null;
/**
* 테이블명
*
@@ -244,14 +260,79 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function hasPermission(string $permission, ?PermissionType $type = null): bool
{
return $this->roles()
->whereHas('permissions', function ($query) use ($permission, $type) {
$query->where('identifier', $permission);
if ($type !== null) {
$query->where('type', $type);
}
})
->exists();
$grants = $this->permissionGrants()[$permission] ?? null;
if ($grants === null) {
return false;
}
if ($type === null) {
return true;
}
foreach ($grants as $grant) {
if ($grant['type'] === $type->value) {
return true;
}
}
return false;
}
/**
* 보유 권한 부여 내역을 반환합니다 (인스턴스 캐시).
*
* 역할 → 권한을 한 번만 적재하고, 이후 판정은 전부 이 배열에서 이루어집니다.
*
* @return array<string, array<int, array{type: string|null, scope_type: string|null}>> 권한 식별자 ⇒ 부여 내역
*/
protected function permissionGrants(): array
{
if ($this->permissionGrantsCache !== null) {
return $this->permissionGrantsCache;
}
$grants = [];
foreach ($this->roles()->with('permissions')->get() as $role) {
foreach ($role->permissions as $permission) {
$grants[$permission->identifier][] = [
'type' => $this->enumValue($permission->type),
'scope_type' => $this->enumValue($permission->pivot->scope_type ?? null),
];
}
}
return $this->permissionGrantsCache = $grants;
}
/**
* Enum 또는 원시 값을 문자열로 정규화합니다.
*
* 캐스팅 설정 유무에 따라 Enum 인스턴스와 문자열이 섞여 들어오므로 한 형태로 맞춥니다.
*
* @param mixed $value Enum 인스턴스 · 문자열 · null
* @return string|null 정규화된 문자열 (부재 시 null)
*/
private function enumValue(mixed $value): ?string
{
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
return $value === null ? null : (string) $value;
}
/**
* 권한 판정 캐시를 비웁니다.
*
* 같은 인스턴스에서 역할을 바꾼 직후 다시 판정해야 하는 경우에 호출합니다.
* (역할 동기화 서비스가 호출하며, 일반 조회 경로에서는 필요하지 않습니다)
*/
public function flushPermissionCaches(): void
{
$this->permissionGrantsCache = null;
$this->effectiveScopeCache = [];
}
/**
@@ -263,27 +344,17 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function hasPermissions(array $permissions, bool $requireAll = true, ?PermissionType $type = null): bool
{
$userPermissions = $this->roles()
->whereHas('permissions', function ($query) use ($permissions, $type) {
$query->whereIn('identifier', $permissions);
if ($type !== null) {
$query->where('type', $type);
}
})
->with(['permissions' => function ($query) use ($permissions, $type) {
$query->whereIn('identifier', $permissions);
if ($type !== null) {
$query->where('type', $type);
}
}])
->get()
->pluck('permissions')
->flatten()
->pluck('identifier')
->unique()
->count();
// 같은 권한 집합을 hasPermission 과 공유한다 — 권한 개수만큼 쿼리가 늘지 않는다.
// 비교 기준(고유 매칭 수 vs 인자 개수)은 종전과 동일하게 유지한다.
$matched = 0;
return $requireAll ? $userPermissions === count($permissions) : $userPermissions > 0;
foreach (array_unique($permissions) as $permission) {
if ($this->hasPermission($permission, $type)) {
$matched++;
}
}
return $requireAll ? $matched === count($permissions) : $matched > 0;
}
/**
@@ -303,33 +374,21 @@ class User extends Authenticatable implements HasLocalePreference
return $this->effectiveScopeCache[$identifier];
}
$scopeTypes = $this->roles()
->whereHas('permissions', function ($query) use ($identifier) {
$query->where('identifier', $identifier);
})
->with(['permissions' => function ($query) use ($identifier) {
$query->where('identifier', $identifier);
}])
->get()
->pluck('permissions')
->flatten()
->pluck('pivot.scope_type');
// 같은 권한 집합에서 scope_type 만 뽑는다 — 권한마다 쿼리를 다시 내지 않는다.
$values = array_column($this->permissionGrants()[$identifier] ?? [], 'scope_type');
// 권한 미보유 시 null 반환 (기본값: 전체 접근)
if ($scopeTypes->isEmpty()) {
if ($values === []) {
return $this->effectiveScopeCache[$identifier] = null;
}
// union 정책: 하나라도 null → 전체 접근
if ($scopeTypes->contains(null)) {
if (in_array(null, $values, true)) {
return $this->effectiveScopeCache[$identifier] = null;
}
// ScopeType Enum 값을 문자열로 변환하여 비교
$values = $scopeTypes->map(fn ($scope) => $scope instanceof ScopeType ? $scope->value : $scope);
// 하나라도 'role' → role 적용
if ($values->contains('role')) {
if (in_array(ScopeType::Role->value, $values, true)) {
return $this->effectiveScopeCache[$identifier] = 'role';
}
@@ -369,11 +428,16 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function isAdmin(): bool
{
return $this->roles()
->whereHas('permissions', function ($query) {
$query->where('type', PermissionType::Admin);
})
->exists();
// 같은 권한 집합을 재사용한다 — 관리자 판정은 미들웨어·리소스·레이아웃에서 반복 호출된다.
foreach ($this->permissionGrants() as $grants) {
foreach ($grants as $grant) {
if ($grant['type'] === PermissionType::Admin->value) {
return true;
}
}
}
return false;
}
/**
+8 -20
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Models\Role;
use App\Models\User;
use App\Support\GuestRoleResolver;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
@@ -11,11 +12,6 @@ use Illuminate\Support\Facades\Log;
class AuthServiceProvider extends ServiceProvider
{
/**
* guest role 캐시
*/
protected static ?Role $guestRoleCache = null;
/**
* Register any authentication / authorization services.
*/
@@ -236,9 +232,9 @@ class AuthServiceProvider extends ServiceProvider
return null; // guest role 없으면 다음 Gate 정의로 위임
}
return $guestRole->permissions()
->where('identifier', $ability)
->exists() ? true : null;
// 적재해 둔 권한 컬렉션에서 판정한다. 빌더를 다시 열면 캐시가 무의미해져
// Gate 호출마다 쿼리가 나간다.
return GuestRoleResolver::hasPermission($ability) ? true : null;
}
/**
@@ -246,13 +242,7 @@ class AuthServiceProvider extends ServiceProvider
*/
protected static function getGuestRole(): ?Role
{
if (self::$guestRoleCache === null) {
self::$guestRoleCache = Role::where('identifier', 'guest')
->with('permissions')
->first();
}
return self::$guestRoleCache;
return GuestRoleResolver::resolve();
}
/**
@@ -260,7 +250,7 @@ class AuthServiceProvider extends ServiceProvider
*/
public static function clearGuestRoleCache(): void
{
self::$guestRoleCache = null;
GuestRoleResolver::flush();
}
/**
@@ -275,16 +265,14 @@ class AuthServiceProvider extends ServiceProvider
$hasPermission = false;
if ($guestRole) {
$hasPermission = $guestRole->permissions()
->where('identifier', $ability)
->exists();
$hasPermission = GuestRoleResolver::hasPermission($ability);
}
return [
'result' => self::checkGuestPermission($ability),
'guestRole' => $guestRole,
'hasPermission' => $hasPermission,
'cachedRoleId' => self::$guestRoleCache?->id,
'cachedRoleId' => $guestRole?->id,
];
}
}
+1 -1
View File
@@ -1019,7 +1019,7 @@ class CoreServiceProvider extends ServiceProvider
$listener = app($listenerClass);
$listener->registerDynamicHooks();
Log::info('동적 훅 리스너 등록 완료', ['listener' => $listenerClass]);
// 등록 성공은 로그로 남기지 않는다 — 요청마다 부팅되는 경로다. 실패만 아래에 남긴다.
} catch (\Throwable $e) {
Log::warning('동적 훅 리스너 등록 실패', [
'listener' => $listenerClass,
+7 -6
View File
@@ -2,10 +2,9 @@
namespace App\Providers;
use App\Enums\ExtensionStatus;
use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist;
use App\Models\Module;
use App\Extension\Traits\CachesModuleStatus;
use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File;
@@ -14,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
class ModuleRouteServiceProvider extends ServiceProvider
{
use CachesModuleStatus;
/**
* The path to the "home" route for your application.
*
@@ -74,10 +75,10 @@ class ModuleRouteServiceProvider extends ServiceProvider
}
}
// 활성화된 모듈 identifier 목록 가져오기
$activeModuleIdentifiers = Module::where('status', ExtensionStatus::Active->value)
->pluck('identifier')
->toArray();
// 활성화된 모듈 identifier 목록 가져오기.
// 같은 목록을 ModuleManager·ModuleServiceProvider 가 이미 캐시(TTL 기본 하루)해 두므로
// 여기서 다시 조회하지 않고 그 캐시를 공유한다. 상태 변경 시 무효화도 같이 따라온다.
$activeModuleIdentifiers = self::getActiveModuleIdentifiers();
$modules = File::directories($modulesPath);
$allowlistActive = ExtensionTestAllowlist::isActive();
@@ -30,6 +30,7 @@ class SettingsServiceProvider extends ServiceProvider
'geoip',
'seo',
'identity',
'pagination',
];
/**
+36 -3
View File
@@ -5,10 +5,14 @@ namespace App\Repositories;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Helpers\TimezoneHelper;
use App\Http\Resources\BaseApiCollection;
use App\Models\ActivityLog;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\KeysetPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -65,16 +69,22 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
columns: ['*'],
sort: [['column' => 'created_at', 'direction' => $sortOrder]],
perPage: (int) ($filters['per_page'] ?? 15),
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
}
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로, 없으면 페이지 번호 방식으로 응답합니다.
* 두 방식의 응답 봉투 차이는 {@see BaseApiCollection} 이 흡수합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator
public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator
{
// 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다)
$query = ActivityLog::query();
@@ -122,6 +132,26 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
}
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at');
$perPage = (int) ($filters['per_page'] ?? 15);
// 커서를 받은 요청은 키셋으로 응답한다. 로그는 계속 쌓이기만 해서 깊은 페이지를
// OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 하지만, 커서는 직전 페이지의 정렬
// 키를 WHERE 경계로 삼아 깊이와 무관하게 일정하다.
// 커서 모드에서는 OFFSET 자체가 없으므로 지연 조인이 해결하려던 문제도 함께 사라진다.
$sortKeys = array_map(
static fn (array $spec): array => [$spec['column'], $spec['direction']],
$sort
);
if (! empty($filters['cursor']) && KeysetPaginator::supports($sortKeys, self::SORTABLE_COLUMNS)) {
return KeysetPaginator::paginate(
query: $query->with('user:id,uuid,name,email'),
perPage: $perPage,
sortKeys: $sortKeys,
uniqueKey: 'id',
cursor: (string) $filters['cursor'],
);
}
// 목록 컬럼을 좁히지 않는 이유: 활동 로그 리소스는 변경 내역(mediumText `changes`)과
// 부가 정보(`properties`)까지 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다.
@@ -130,8 +160,11 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
query: $query,
columns: ['*'],
sort: $sort,
perPage: (int) ($filters['per_page'] ?? 15),
perPage: $perPage,
relations: ['user:id,uuid,name,email'],
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
}
@@ -0,0 +1,72 @@
<?php
namespace App\Repositories\Concerns;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
/**
* 날짜 필터를 인덱스가 살아 있는 범위 조건으로 적용하는 Trait
*
* `whereDate('created_at', ...)` 는 컬럼에 `DATE()` 를 씌운다. 함수가 씌워진 컬럼은
* 인덱스를 탈 수 없으므로, 그 조건 하나 때문에 목록 전체가 풀스캔이 된다. 같은 결과를
* 내는 `>= 하루 시작` / `<= 하루 끝` 범위 조건으로 바꾸면 인덱스가 그대로 쓰인다.
*
* 경계 처리를 각 저장소가 따로 적으면 한쪽이 종료일 하루를 통째로 빠뜨리기 쉽다
* (`<= '2026-08-02'` 는 그날 00:00:00 까지만 포함한다). 경계는 이 Trait 한 곳에서만 정한다.
*/
trait FiltersByDateRange
{
/**
* 기간(시작일~종료일) 필터를 범위 조건으로 적용합니다.
*
* 종료일은 그날 23:59:59.999999 까지 포함하므로 `whereDate(..., '<=', ...)` 와
* 같은 결과를 냅니다.
*
* @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리
* @param string $column 날짜 컬럼 (테이블 한정자 포함 가능)
* @param string|null $startDate 시작일 (빈 값이면 미적용)
* @param string|null $endDate 종료일 (빈 값이면 미적용)
*/
protected function applyDateRangeFilter($query, string $column, ?string $startDate, ?string $endDate): void
{
if (! empty($startDate)) {
$query->where($column, '>=', CarbonImmutable::parse($startDate)->startOfDay());
}
if (! empty($endDate)) {
$query->where($column, '<=', CarbonImmutable::parse($endDate)->endOfDay());
}
}
/**
* 특정 하루에 해당하는 행만 남기는 필터를 범위 조건으로 적용합니다.
*
* @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리
* @param string $column 날짜 컬럼 (테이블 한정자 포함 가능)
* @param \DateTimeInterface|string $day 대상 날짜
*/
protected function applyDayFilter($query, string $column, \DateTimeInterface|string $day): void
{
$target = CarbonImmutable::parse($day instanceof \DateTimeInterface ? $day->format('Y-m-d H:i:s') : $day);
$query->whereBetween($column, [$target->startOfDay(), $target->endOfDay()]);
}
/**
* 특정 연·월에 해당하는 행만 남기는 필터를 범위 조건으로 적용합니다.
*
* `whereYear` + `whereMonth` 조합은 컬럼에 함수를 두 번 씌워 인덱스를 완전히 막습니다.
*
* @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리
* @param string $column 날짜 컬럼 (테이블 한정자 포함 가능)
* @param int $year 연도
* @param int $month 월 (1~12)
*/
protected function applyMonthFilter($query, string $column, int $year, int $month): void
{
$start = CarbonImmutable::create($year, $month, 1)->startOfDay();
$query->whereBetween($column, [$start, $start->endOfMonth()]);
}
}
@@ -2,10 +2,14 @@
namespace App\Repositories\Concerns;
use App\Enums\TotalRelation;
use App\Support\Query\BoundedPage;
use App\Support\Query\BoundedPaginator;
use Closure;
use Illuminate\Contracts\Database\Query\Expression;
use Illuminate\Contracts\Pagination\Paginator as PaginatorContract;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
@@ -66,10 +70,11 @@ trait PaginatesWithDeferredJoin
* @param bool $preserveIdOrder true = inner 가 정한 ID 순서를 outer 에서 그대로 복원
* @param string $pageName 페이지 쿼리 파라미터명
* @param Closure|null $outerUsing outer 에만 적용할 조인/집계 (inner 에서는 실행되지 않는다)
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, 아니면 LengthAwarePaginator)
* @param int|null $resultCap 총 건수 집계 상한 (null = 항상 정확한 COUNT)
* @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, $resultCap 지정 시 BoundedPage, 아니면 LengthAwarePaginator)
*/
protected function paginateWithDeferredJoin(
Builder $query,
Builder|Relation $query,
array $columns,
array $sort,
int $perPage,
@@ -82,7 +87,14 @@ trait PaginatesWithDeferredJoin
bool $preserveIdOrder = false,
string $pageName = 'page',
?Closure $outerUsing = null,
?int $resultCap = null,
): PaginatorContract {
// 관계(`$model->items()`)도 받는다 — 표준 `paginate()` 가 되는 자리는 이 계약으로
// 바꿔도 되어야 한다. 관계의 소속 조건은 그 밑 빌더에 이미 들어가 있어 보존된다.
// (쿼리 빌더는 받지 않는다. 이 계약은 모델의 키 컬럼·eager load 를 다루므로
// Eloquent 가 전제다 — 넓힐 수 있는 범위와 없는 범위를 구분한다.)
$query = $query instanceof Relation ? $query->getQuery() : $query;
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$page = max(1, $page);
@@ -94,7 +106,16 @@ trait PaginatesWithDeferredJoin
// whereHas 는 결과 집합을 결정하는 조건이라 setEagerLoads 로 지워지지 않는다 (의도된 구분).
$inner = (clone $query)->setEagerLoads([]);
if (! $simple && $total === null) {
// 상한이 지정되면 총 건수를 상한까지만 센다. 다음 페이지 판정은 총 건수와 무관하게
// per_page + 1 실측으로 하므로, 마지막 페이지 번호 하나만 계산 불가가 된다.
$bounded = ! $simple && $resultCap !== null && $resultCap > 0;
$relation = null;
if ($bounded && $total === null) {
// Eloquent 빌더를 그대로 넘긴다 — countWithCap 이 내부에서 toBase() 로
// 글로벌 스코프까지 적용한 기반 쿼리를 만든다.
[$total, $relation] = BoundedPaginator::countWithCap(clone $inner, $resultCap);
} elseif (! $simple && $total === null) {
// Laravel 의 paginate() 와 같은 집계 경로를 쓴다. count() 는 groupBy/having 이 있는
// 쿼리에서 `select count(*) ... group by ...` 를 그대로 실행해 **첫 그룹의 행 수**를
// 총 건수로 돌려준다. getCountForPagination() 은 그룹 쿼리를 서브쿼리로 감싸므로
@@ -105,10 +126,11 @@ trait PaginatesWithDeferredJoin
$this->applySortSpec($inner, $sort);
// simple 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다 (Paginator 가 잘라낸다).
// simple/bounded 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다.
// forPage() 를 쓰면 offset 이 (page-1) * (perPage+1) 로 계산돼 페이지가 넘어갈수록
// 건너뛰는 행이 어긋나므로, offset 은 perPage 기준으로 직접 지정한다.
$fetchCount = $simple ? $perPage + 1 : $perPage;
$probesNextPage = $simple || $bounded;
$fetchCount = $probesNextPage ? $perPage + 1 : $perPage;
$ids = $inner
->select($inner->getModel()->qualifyColumn($keyName))
@@ -116,6 +138,12 @@ trait PaginatesWithDeferredJoin
->limit($fetchCount)
->pluck($keyName);
$hasMorePages = $probesNextPage && $ids->count() > $perPage;
if ($bounded && $hasMorePages) {
$ids = $ids->slice(0, $perPage)->values();
}
$items = $ids->isEmpty()
? new Collection
: $this->fetchDeferredJoinRows($query, $columns, $sort, $ids->all(), $relations, $withCount, $keyName, $preserveIdOrder, $outerUsing);
@@ -129,6 +157,22 @@ trait PaginatesWithDeferredJoin
return new Paginator($items, $perPage, $page, $options);
}
if ($bounded) {
// 동시 삽입으로 집계 시점과 조회 시점의 모수가 달라질 수 있다
$seenSoFar = ($page - 1) * $perPage + $items->count();
return new BoundedPage(
items: $items,
total: max((int) $total, $seenSoFar),
perPage: $perPage,
currentPage: $page,
totalRelation: $relation ?? TotalRelation::Exact,
resultCap: $resultCap,
hasMorePages: $hasMorePages,
options: $options,
);
}
return new LengthAwarePaginator($items, $total ?? $items->count(), $perPage, $page, $options);
}
@@ -263,6 +263,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
*/
public function allEnabled(): Collection
{
// audit:allow query-unbounded-get reason: 본인인증 정책은 운영자가 등록한 수만큼만 존재한다 (사용량과 무관)
return IdentityPolicy::query()->where('enabled', true)->get();
}
@@ -347,6 +348,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface
return [];
}
// audit:allow query-unbounded-get reason: 본인인증 정책은 운영자가 등록한 수만큼만 존재한다 (사용량과 무관)
return IdentityPolicy::query()
->where('scope', 'hook')
->distinct()
@@ -8,6 +8,7 @@ use App\Helpers\TimezoneHelper;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
@@ -219,6 +220,9 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi
columns: ['*'],
sort: [['column' => $sortBy, 'direction' => $sortOrder]],
perPage: $perPage,
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.identity_logs'),
);
}
+86 -5
View File
@@ -43,6 +43,17 @@ class JsonConfigRepository implements ConfigRepositoryInterface
*/
private ?array $cache = null;
/**
* 카테고리별 메모리 캐시
*
* 부팅 한 번에 같은 카테고리를 여러 곳에서 읽는다 — SettingsServiceProvider 의
* apply*Config 9종과 loadCoreSettingsToConfig 가 각각 조회하므로, 캐시가 없으면
* 요청마다 카테고리 수만큼 곱한 횟수로 파일 존재 확인·읽기·JSON 파싱이 반복된다.
*
* @var array<string, array<string, mixed>>
*/
private array $categoryCache = [];
/**
* 모든 카테고리의 설정을 조회합니다.
*
@@ -67,9 +78,27 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 특정 카테고리의 설정을 조회합니다.
*
* @return array<string, mixed>
* @param string $category 카테고리명
* @return array<string, mixed> 기본값과 병합된 설정
*/
public function getCategory(string $category): array
{
if (array_key_exists($category, $this->categoryCache)) {
return $this->categoryCache[$category];
}
return $this->categoryCache[$category] = $this->readCategory($category);
}
/**
* 카테고리 설정을 파일에서 읽어 기본값과 병합합니다.
*
* 캐시를 거치지 않는 실제 읽기 경로입니다.
*
* @param string $category 카테고리명
* @return array<string, mixed>
*/
private function readCategory(string $category): array
{
if (! $this->categoryExists($category)) {
return $this->getDefaultsForCategory($category);
@@ -77,11 +106,17 @@ class JsonConfigRepository implements ConfigRepositoryInterface
$path = $this->getCategoryPath($category);
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
if (! Storage::disk(self::STORAGE_DISK)->exists($path)) {
return $this->getDefaultsForCategory($category);
}
try {
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$content = Storage::disk(self::STORAGE_DISK)->get($path);
$data = json_decode($content, true);
@@ -113,6 +148,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface
* 도트 노테이션으로 특정 설정값을 조회합니다.
*
* @param string $key 예: 'mail.host', 'general.site_name'
* @param mixed $default 값이 없을 때 돌려줄 기본값
* @return mixed 설정값 (없으면 $default)
*/
public function get(string $key, mixed $default = null): mixed
{
@@ -131,6 +168,10 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 도트 노테이션으로 특정 설정값을 저장합니다.
*
* @param string $key 설정 키 (카테고리.항목)
* @param mixed $value 저장할 값
* @return bool 저장 성공 여부
*/
public function set(string $key, mixed $value): bool
{
@@ -150,7 +191,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 여러 설정을 일괄 저장합니다.
*
* @param array<string, mixed> $settings
* @param array<string, mixed> $settings 도트 노테이션 키 ⇒ 값
* @return bool 저장 성공 여부
*/
public function setMany(array $settings): bool
{
@@ -171,7 +213,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 특정 카테고리의 설정을 저장합니다.
*
* @param array<string, mixed> $settings
* @param string $category 카테고리명
* @param array<string, mixed> $settings 저장할 설정
* @return bool 저장 성공 여부
*/
public function saveCategory(string $category, array $settings): bool
{
@@ -195,6 +239,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
try {
// 파일 잠금으로 동시 쓰기 방지
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullPath = Storage::disk(self::STORAGE_DISK)->path($path);
$handle = fopen($fullPath, 'c');
@@ -211,8 +258,10 @@ class JsonConfigRepository implements ConfigRepositoryInterface
fclose($handle);
// 캐시 무효화
// 캐시 무효화 — 저장한 카테고리와 전체 맵 양쪽을 비운다.
// 한쪽만 비우면 저장 직후 조회가 이전 값을 돌려준다.
$this->cache = null;
unset($this->categoryCache[$category]);
return true;
} catch (\Exception $e) {
@@ -226,6 +275,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 설정 키 존재 여부를 확인합니다.
*
* @param string $key 설정 키 (카테고리.항목)
* @return bool 존재 여부
*/
public function has(string $key): bool
{
@@ -234,6 +286,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 특정 설정을 삭제합니다.
*
* @param string $key 설정 키 (카테고리.항목)
* @return bool 삭제 성공 여부
*/
public function delete(string $key): bool
{
@@ -269,6 +324,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 카테고리 존재 여부를 확인합니다.
*
* @param string $category 카테고리명
* @return bool 존재 여부
*/
public function categoryExists(string $category): bool
{
@@ -278,7 +336,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 설정 파일을 초기화합니다.
*
* @param array<string, array<string, mixed>> $settings
* @param array<string, array<string, mixed>> $settings 기본값 위에 덮어쓸 설정
* @return bool 초기화 성공 여부
*/
public function initialize(array $settings = []): bool
{
@@ -310,6 +369,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
$backupPath = self::BACKUP_DIR.'/'.$backupName;
$zip = new \ZipArchive;
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullBackupPath = Storage::disk(self::STORAGE_DISK)->path($backupPath);
if ($zip->open($fullBackupPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
@@ -318,6 +380,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
foreach ($this->getCategories() as $category) {
$categoryPath = $this->getCategoryPath($category);
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullCategoryPath = Storage::disk(self::STORAGE_DISK)->path($categoryPath);
if (file_exists($fullCategoryPath)) {
@@ -332,9 +397,15 @@ class JsonConfigRepository implements ConfigRepositoryInterface
/**
* 백업에서 설정을 복원합니다.
*
* @param string $backupPath 백업 파일 경로 (disk 기준)
* @return bool 복원 성공 여부
*/
public function restore(string $backupPath): bool
{
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$fullBackupPath = Storage::disk(self::STORAGE_DISK)->path($backupPath);
if (! file_exists($fullBackupPath)) {
@@ -356,12 +427,16 @@ class JsonConfigRepository implements ConfigRepositoryInterface
if ($this->categoryExists($category)) {
$content = $zip->getFromIndex($i);
$categoryPath = $this->getCategoryPath($category);
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
Storage::disk(self::STORAGE_DISK)->put($categoryPath, $content);
}
}
$zip->close();
$this->cache = null;
$this->categoryCache = [];
return true;
}
@@ -457,6 +532,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
*/
private function ensureDirectoryExists(): void
{
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$disk = Storage::disk(self::STORAGE_DISK);
if (! $disk->exists('.')) {
@@ -469,6 +547,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface
*/
private function ensureBackupDirectoryExists(): void
{
// audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서
// `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다.
// 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다.
$disk = Storage::disk(self::STORAGE_DISK);
if (! $disk->exists(self::BACKUP_DIR)) {
@@ -50,6 +50,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getActivePacks(): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('status', LanguagePackStatus::Active->value)
->get();
@@ -96,6 +97,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
?string $targetIdentifier,
string $locale
): Collection {
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', $scope)
->where('target_identifier', $targetIdentifier)
@@ -112,6 +114,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getActiveCoreLocales(): array
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', LanguagePackScope::Core->value)
->where('status', LanguagePackStatus::Active->value)
@@ -246,6 +249,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getPacksForTarget(string $scope, string $targetIdentifier): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', $scope)
->where('target_identifier', $targetIdentifier)
@@ -260,6 +264,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface
*/
public function getPacksForLocale(string $locale): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()->where('locale', $locale)->get();
}
+26 -2
View File
@@ -8,8 +8,11 @@ use App\Models\NotificationLog;
use App\Models\User;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\KeysetPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
class NotificationLogRepository implements NotificationLogRepositoryInterface
@@ -101,9 +104,9 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지당 건수
* @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용)
* @return LengthAwarePaginator 페이지네이션 결과
* @return LengthAwarePaginator|CursorPaginator 페이지네이션 결과 (커서 요청 시 키셋)
*/
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator|CursorPaginator
{
// 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다)
$query = NotificationLog::query();
@@ -152,6 +155,24 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
$sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'sent_at');
// 커서를 받은 요청은 키셋으로 응답한다. 로그는 계속 쌓이기만 해서 깊은 페이지를
// OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 하지만, 커서는 직전 페이지의 정렬
// 키를 WHERE 경계로 삼아 깊이와 무관하게 일정하다.
$sortKeys = array_map(
static fn (array $spec): array => [$spec['column'], $spec['direction']],
$sort
);
if (! empty($filters['cursor']) && KeysetPaginator::supports($sortKeys, self::SORTABLE_COLUMNS)) {
return KeysetPaginator::paginate(
query: $query->with(['senderUser', 'recipientUser']),
perPage: $perPage,
sortKeys: $sortKeys,
uniqueKey: 'id',
cursor: (string) $filters['cursor'],
);
}
// 목록 컬럼을 좁히지 않는 이유: 이 목록의 리소스는 렌더링된 본문(longText `body`)까지
// 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다. 지연 조인만으로도 본문을 읽는
// 행 수가 OFFSET 과 무관하게 이번 페이지 분량으로 고정된다.
@@ -161,6 +182,9 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface
sort: $sort,
perPage: $perPage,
relations: ['senderUser', 'recipientUser'],
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.notification_logs'),
);
}
+10 -1
View File
@@ -4,6 +4,8 @@ namespace App\Repositories;
use App\Contracts\Repositories\NotificationRepositoryInterface;
use App\Models\User;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -32,7 +34,14 @@ class NotificationRepository implements NotificationRepositoryInterface
// 걷어낼 넓은 컬럼이 없다
// 정렬 마지막의 기본키는 전순서 보장용이다 — created_at 동률에서 페이지 경계가
// 흔들려 인접 페이지가 같은 알림을 중복 노출하고 다른 알림을 누락하는 것을 막는다.
return $query->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($perPage);
//
// 알림함은 한 사용자에 묶이지만 시간이 지날수록 계속 쌓인다(설정성 테이블이 아니다).
// 총 건수는 상한까지만 세고, 뒤쪽 페이지 이동은 실측으로 끝까지 열어 둔다.
return BoundedPaginator::paginate(
$query->orderBy('created_at', 'desc')->orderBy('id', 'desc'),
perPage: $perPage,
resultCap: PaginationLimits::resultCap('user.notifications'),
);
}
/**
+3
View File
@@ -17,6 +17,7 @@ class RoleRepository implements RoleRepositoryInterface
*/
public function getAll(): Collection
{
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::with(['permissions'])
->orderBy('id')
->get();
@@ -29,6 +30,7 @@ class RoleRepository implements RoleRepositoryInterface
*/
public function getActiveRoles(): Collection
{
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::where('is_active', true)
->orderBy('id')
->get();
@@ -142,6 +144,7 @@ class RoleRepository implements RoleRepositoryInterface
*/
public function getByExtension(ExtensionOwnerType $extensionType, string $extensionIdentifier): Collection
{
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::where('extension_type', $extensionType)
->where('extension_identifier', $extensionIdentifier)
->get();
@@ -6,6 +6,7 @@ use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
use App\Models\ScheduleHistory;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\PaginationLimits;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
@@ -116,6 +117,9 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface
sort: $sort,
perPage: $perPage,
relations: ['triggeredBy'],
// 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은
// per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐).
resultCap: PaginationLimits::resultCap('admin.schedule_histories'),
);
}
+9 -7
View File
@@ -8,6 +8,7 @@ use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Helpers\PermissionHelper;
use App\Models\Schedule;
use App\Repositories\Concerns\FiltersByDateRange;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\ResolvesSortSpec;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -16,6 +17,7 @@ use Illuminate\Database\Eloquent\Collection;
class ScheduleRepository implements ScheduleRepositoryInterface
{
use FiltersByDateRange;
use HasMultipleSearchFilters;
use ResolvesSortSpec;
@@ -198,13 +200,13 @@ class ScheduleRepository implements ScheduleRepositoryInterface
*/
private function applyDateFilters(Builder $query, array $filters): void
{
if (! empty($filters['created_from'])) {
$query->whereDate('created_at', '>=', $filters['created_from']);
}
if (! empty($filters['created_to'])) {
$query->whereDate('created_at', '<=', $filters['created_to']);
}
// whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다.
$this->applyDateRangeFilter(
$query,
'created_at',
$filters['created_from'] ?? null,
$filters['created_to'] ?? null
);
}
/**
+12 -7
View File
@@ -5,9 +5,11 @@ namespace App\Repositories;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Helpers\PermissionHelper;
use App\Models\User;
use App\Repositories\Concerns\FiltersByDateRange;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use App\Repositories\Concerns\ResolvesSortSpec;
use App\Support\Query\PaginationLimits;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
@@ -16,6 +18,7 @@ use Laravel\Sanctum\PersonalAccessToken;
class UserRepository implements UserRepositoryInterface
{
use FiltersByDateRange;
use HasMultipleSearchFilters;
use PaginatesWithDeferredJoin;
use ResolvesSortSpec;
@@ -125,6 +128,8 @@ class UserRepository implements UserRepositoryInterface
sort: $sort,
perPage: $perPage,
relations: ['roles'],
// 회원 수가 커져도 목록 첫 화면 비용이 총 건수 COUNT 에 끌려가지 않도록 상한을 건다.
resultCap: PaginationLimits::resultCap('admin.users'),
);
}
@@ -153,13 +158,13 @@ class UserRepository implements UserRepositoryInterface
*/
private function applyDateFilters(Builder $query, array $filters): void
{
if (! empty($filters['start_date'])) {
$query->whereDate('created_at', '>=', $filters['start_date']);
}
if (! empty($filters['end_date'])) {
$query->whereDate('created_at', '<=', $filters['end_date']);
}
// whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다.
$this->applyDateRangeFilter(
$query,
'created_at',
$filters['start_date'] ?? null,
$filters['end_date'] ?? null
);
// 기본 날짜 필터 (전체가 아닌 경우)
if (empty($filters['start_date']) && empty($filters['end_date']) &&
@@ -0,0 +1,78 @@
<?php
namespace App\Search\Contracts;
use App\Search\DTO\KeywordSearchContext;
use App\Search\KeywordSearch;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
/**
* 진행 중인 DB 쿼리에 키워드 술어를 붙일 수 있는 검색 엔진 계약
*
* Scout 의 `Model::search()` 는 "엔진이 결과를 돌려준다" 모델입니다. 그래서 결과를 받은
* 뒤 그 ID 로 DB 를 다시 조회해야 하고, 매칭이 많으면 ID 전량을 메모리에 올려 무제한
* `IN (...)` 을 만들게 됩니다. 페이지네이션·조인·필터를 DB 에 남긴 채 키워드 조건만
* 얹으려면 **엔진에게 술어를 달라고 요청할 통로**가 따로 필요합니다.
*
* 이 계약이 그 통로입니다. 구현하는 엔진은 자기 방식(FULLTEXT MATCH, 외부 엔진이 미리
* 좁혀 준 키 집합 등)으로 조건을 붙이고, 코어는 무엇이 붙는지 모르는 채 호출만 합니다.
* 해석과 폴백은 {@see KeywordSearch} 가 단독으로 수행합니다.
*
* 구현하지 않은 엔진은 `LIKE` 폴백으로 내려갑니다 — 검색은 동작하지만 관련도 정렬이
* 사라지고 전체 스캔이 되므로, 폴백 사실은 조용히 넘어가지 않고 기록됩니다.
*
* ```php
* class MeilisearchEngine extends Engine implements KeywordPredicateProvider
* {
* public function applyKeywordPredicate(
* Builder $query,
* array $columns,
* string $keyword,
* string $boolean,
* KeywordSearchContext $context
* ): void {
* // 상한을 지킨다 — 지키지 않으면 매칭이 큰 검색어에서 이 배열이 곧 메모리 폭발이다
* $ids = $this->lookupIds($keyword, limit: $context->keyCap);
* $method = $boolean === 'or' ? 'orWhereIn' : 'whereIn';
* $query->$method($query->getModel()->getQualifiedKeyName(), $ids);
* }
* }
* ```
*/
interface KeywordPredicateProvider
{
/**
* 쿼리에 키워드 술어를 붙입니다.
*
* 컬럼 목록은 **하나의 조건으로 함께 평가**해 달라는 요청입니다. 엔진이 그 조합을
* 다룰 수 없으면(예: MySQL 이 그 컬럼 조합의 복합 FULLTEXT 인덱스를 요구하는 경우)
* 호출자가 {@see KeywordSearch::applyAny()} 로 컬럼별 OR 을 요청합니다.
*
* 매칭이 하나도 없을 수 있는 검색어(예: 연산자만 입력)에는 **항상 거짓인 조건**을
* 붙여야 합니다. 아무 조건도 붙이지 않으면 필터 없는 전체 목록이 검색 결과로 나갑니다.
*
* **페이지네이션은 호출자(DB)가 담당합니다.** 엔진에게 페이지 번호를 넘기지 않는 이유는,
* 엔진이 자기 순서로 한 페이지 분량만 돌려주면 그 뒤에 적용되는 DB 필터(분류·전시상태·
* 조인)에 일부가 탈락해 페이지가 비고, DB 정렬과 엔진의 관련도 순서가 달라 페이지 경계도
* 어긋나기 때문입니다. 엔진이 책임지는 것은 **"얼마까지 돌려줄 것인가"** 하나입니다.
*
* 키 집합을 만들어 조건으로 붙이는 구현은 `$context->keyCap` 을 반드시 지켜야 합니다.
* 지키지 않으면 매칭이 큰 검색어에서 그 집합 자체가 메모리 폭발이 됩니다. 상한에 걸려
* 잘린 경우 그 이상은 도달 불가이며, 총 건수는 "이상" 으로 보고됩니다
* ({@see PaginationLimits} 와 같은 의미).
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문 (정제는 엔진이 수행)
* @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or')
* @param KeywordSearchContext $context 술어 생성 조건 (키 집합 상한 등)
*/
public function applyKeywordPredicate(
Builder $query,
array $columns,
string $keyword,
string $boolean,
KeywordSearchContext $context
): void;
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Search\DTO;
/**
* 키워드 술어를 만들 때 엔진에게 전달되는 조건
*
* 엔진마다 술어를 만드는 방식이 다르고, 그중 일부는 **비용이 규모에 비례**합니다.
* 외부 검색 서버를 쓰는 엔진은 자기 서버에서 키 집합을 받아 조건으로 붙이는데, 매칭이
* 크면 그 키 집합 자체가 메모리 폭발이 됩니다 — 이 프로젝트가 한 번 겪은 결함입니다.
*
* 그래서 코어는 엔진에게 "얼마까지 가져와도 되는가" 를 함께 넘깁니다. 규정만으로는
* 강제할 수 없으므로(외부 엔진 코드는 이 저장소 밖입니다) **값을 손에 쥐어 주는 것**까지가
* 코어가 할 수 있는 최선입니다.
*
* 값 추가가 필요해질 때 계약 시그니처를 다시 깨지 않도록 객체로 감쌉니다.
*/
final class KeywordSearchContext
{
/**
* @param int|null $keyCap 엔진이 돌려줄 수 있는 최대 키 개수 (null = 무제한)
*/
public function __construct(
public readonly ?int $keyCap = null,
) {}
/**
* 상한이 정해져 있는지 반환합니다.
*
* @return bool 상한이 있으면 true
*/
public function hasKeyCap(): bool
{
return $this->keyCap !== null && $this->keyCap > 0;
}
}
+256 -44
View File
@@ -2,7 +2,13 @@
namespace App\Search\Engines;
use App\Enums\TotalRelation;
use App\Search\Contracts\FulltextSearchable;
use App\Search\Contracts\KeywordPredicateProvider;
use App\Search\DTO\KeywordSearchContext;
use App\Search\KeywordSearch;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
@@ -18,7 +24,7 @@ use Laravel\Scout\Engines\Engine;
*
* MySQL 자체가 인덱스 소스이므로 update/delete/flush는 no-op입니다.
*/
class DatabaseFulltextEngine extends Engine
class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider
{
/**
* MariaDB 감지 결과 캐시 (프로세스 수명 동안 유지)
@@ -53,20 +59,52 @@ class DatabaseFulltextEngine extends Engine
* FULLTEXT 검색을 수행합니다.
*
* @param Builder $builder Scout 빌더
* @return array{query: \Illuminate\Database\Eloquent\Builder, total: int}
* @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null}
*/
public function search(Builder $builder): array
{
return $this->performSearch($builder);
}
/**
* 검색 결과의 키 목록만 조회합니다.
*
* 총 건수를 쓰지 않는 경로이므로 COUNT 를 아예 실행하지 않고, SELECT 도 키 컬럼과
* 정렬용 스코어로만 좁힙니다. 좁히지 않으면 매칭된 전 행의 모든 컬럼(본문 포함)을
* 읽고 나서 ID 만 뽑아내게 됩니다.
*
* @param Builder $builder Scout 빌더
* @return \Illuminate\Support\Collection 키 컬렉션
*/
public function keys(Builder $builder): \Illuminate\Support\Collection
{
return $this->mapIds($this->performSearch($builder, withTotal: false, keysOnly: true));
}
/**
* 검색 결과를 모델 컬렉션으로 조회합니다.
*
* 총 건수를 쓰지 않는 경로이므로 COUNT 를 실행하지 않습니다.
*
* @param Builder $builder Scout 빌더
* @return Collection 모델 컬렉션
*/
public function get(Builder $builder): Collection
{
return $this->map(
$builder,
$builder->applyAfterRawSearchCallback($this->performSearch($builder, withTotal: false)),
$builder->model
);
}
/**
* 페이지네이션 적용 FULLTEXT 검색을 수행합니다.
*
* @param Builder $builder Scout 빌더
* @param int $perPage 페이지당 결과 수
* @param int $page 페이지 번호
* @return array{query: \Illuminate\Database\Eloquent\Builder, total: int}
* @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null}
*/
public function paginate(Builder $builder, $perPage, $page): array
{
@@ -85,7 +123,13 @@ class DatabaseFulltextEngine extends Engine
return collect();
}
return $query->pluck($query->getModel()->getKeyName());
// `pluck()` 은 이미 걸려 있는 SELECT 를 보존한다(onceWithColumns). 검색 쿼리는
// 관련도 점수를 위해 `table.*` 를 선택해 두므로, 그대로 두면 ID 만 필요한 자리에서
// 전 컬럼을 읽는다. 사본에서 키 컬럼만 남겨 좁힌다.
$model = $query->getModel();
$key = $model->getQualifiedKeyName();
return $query->clone()->select($key)->pluck($model->getKeyName());
}
/**
@@ -129,7 +173,31 @@ class DatabaseFulltextEngine extends Engine
*/
public function getTotalCount($results): int
{
return $results['total'] ?? 0;
return (int) ($results['total'] ?? 0);
}
/**
* 검색 결과의 총 건수 정확도를 반환합니다.
*
* 상한을 넘겨 정확히 세지 않은 경우 `AtLeast` 입니다.
*
* @param array $results 검색 결과
* @return TotalRelation 총 건수 정확도
*/
public function getTotalRelation($results): TotalRelation
{
return $results['total_relation'] ?? TotalRelation::Exact;
}
/**
* 총 건수 집계에 적용된 상한을 반환합니다.
*
* @param array $results 검색 결과
* @return int|null 상한 (무제한이면 null)
*/
public function getResultCap($results): ?int
{
return $results['result_cap'] ?? null;
}
/**
@@ -239,6 +307,14 @@ class DatabaseFulltextEngine extends Engine
* MySQL 파싱 오류(ER_PARSE_ERROR)를 유발하므로, 사용자 입력을 일반 검색어로만
* 취급하도록 연산자를 제거하고 남은 토큰을 따옴표 구문으로 묶습니다.
*
* 토큰은 공백으로 잇습니다 — BOOLEAN MODE 에서 공백 결합은 OR 입니다. 각 토큰에 `+` 를
* 붙이면 AND 가 되어 매칭 집합이 줄고 대용량에서 검색 시간이 짧아질 여지가 있지만,
* **의도적으로 OR 을 유지합니다**. 입력한 단어 중 하나만 든 문서가 결과에서 통째로
* 사라지는 손실이 성능 이득보다 크기 때문입니다. 한글은 ngram 파서가 2글자 단위로
* 토큰을 쪼개므로 AND 전환의 결과 축소 폭이 특히 큽니다. 성능 축이 문제가 되면 결합
* 방식이 아니라 전용 검색 엔진(core.search.engine_drivers)으로 해결합니다.
* 배경·실측: docs/backend/search-system.md, docs/backend/pagination.md
*
* @param string $keyword 원본 검색어
* @return string 안전하게 정제된 BOOLEAN MODE 검색식 (정제 결과가 없으면 빈 문자열)
*/
@@ -267,32 +343,114 @@ class DatabaseFulltextEngine extends Engine
*
* DBMS별로 MATCH...AGAINST 또는 LIKE fallback을 자동 적용합니다.
*
* 검색어는 반드시 이 헬퍼를 거쳐야 합니다. BOOLEAN MODE 는 `+ - * " ( )` 등을
* 연산자로 해석하므로, 원문 키워드를 그대로 바인딩하면 사용자가 `+` 하나만 입력해도
* 파싱 오류로 500 이 됩니다. 정제는 sanitizeBooleanModeKeyword 한 곳에서만 합니다.
*
* **컬럼을 배열로 넘기면 하나의 `MATCH(a, b)` 가 됩니다.** MySQL 은 이 형태에 정확히
* 그 컬럼 조합의 **복합 FULLTEXT 인덱스**를 요구하며, 없으면
* `Can't find FULLTEXT index matching the column list` 오류가 납니다.
* 컬럼별 단일 인덱스만 있는 테이블은 {@see self::whereFulltextAny()} 를 쓰세요.
*
* @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더
* @param string $column 검색 대상 컬럼명
* @param string $keyword 검색어
* @param string|array<int, string> $columns 검색 대상 컬럼명 (배열이면 복합 인덱스 필요)
* @param string $keyword 검색어 (원문 — 정제는 이 메서드가 수행)
* @param string $boolean 조건 결합 방식 ('and' 또는 'or')
*/
public static function whereFulltext(
\Illuminate\Database\Eloquent\Builder $query,
string $column,
string|array $columns,
string $keyword,
string $boolean = 'and'
): void {
if (static::supportsFulltext()) {
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
if ($ftKeyword === '') {
// 정제 결과 없음 → 항상 false 조건으로 빈 결과 (void 반환 계약 유지)
$falseMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->$falseMethod('1 = 0');
// 활성 엔진 해석을 거친다 — 다른 엔진이 켜져 있으면 그 엔진이 조건을 만든다.
// 이 정적 헬퍼를 직접 부르던 코드도 이 경유로 엔진 교체 혜택을 받는다.
KeywordSearch::apply($query, $columns, $keyword, $boolean);
}
return;
}
$method = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->$method("MATCH(`{$column}`) AGAINST(? IN BOOLEAN MODE)", [$ftKeyword]);
} else {
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->$method($column, 'LIKE', "%{$keyword}%");
/**
* {@inheritDoc}
*
* 이 엔진의 술어는 `MATCH ... AGAINST IN BOOLEAN MODE` 입니다. DBMS 가 FULLTEXT 를
* 지원하지 않으면 같은 자리에서 부분일치로 내려갑니다.
*
* `$context->keyCap` 은 쓰지 않습니다 — 이 엔진의 술어는 SQL 조건 그 자체라 중간
* 키 집합을 만들지 않으므로 상한을 적용할 대상이 없습니다. 상한은 키 집합을 만들어
* 조건으로 붙이는 엔진(외부 검색 서버 등)을 위한 것입니다.
*/
public function applyKeywordPredicate(
\Illuminate\Database\Eloquent\Builder $query,
array $columns,
string $keyword,
string $boolean,
KeywordSearchContext $context
): void {
static::applyFulltextPredicate($query, $columns, $keyword, $boolean);
}
/**
* FULLTEXT 조건을 실제로 조립합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 조건 결합 방식 ('and' 또는 'or')
*/
protected static function applyFulltextPredicate(
\Illuminate\Database\Eloquent\Builder $query,
array $columns,
string $keyword,
string $boolean = 'and'
): void {
$columns = array_values($columns);
if (! static::supportsFulltext()) {
// 전문검색을 제공하지 않는 DBMS 로 설치된 사이트에서는 부분일치가 정상 경로다.
// 연산자 선택과 와일드카드 escape 는 코어 해석기가 단독으로 수행한다 —
// 여기서 다시 조립하면 DBMS 가 늘 때마다 고쳐야 할 곳이 둘이 된다.
KeywordSearch::applyLikeMatch($query, $columns, $keyword, $boolean);
return;
}
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
if ($ftKeyword === '') {
// 정제 결과 없음(연산자만 입력) → 빈 결과. 빌더가 바인딩까지 처리하도록
// 빈 whereIn 을 쓴다 (raw '1 = 0' 은 쓰지 않는다).
$method = $boolean === 'or' ? 'orWhereIn' : 'whereIn';
$query->$method($query->getModel()->getQualifiedKeyName(), []);
return;
}
$match = 'MATCH(`'.implode('`, `', $columns).'`)';
$method = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
$query->$method($match.' AGAINST(? IN BOOLEAN MODE)', [$ftKeyword]);
}
/**
* 여러 컬럼 중 하나라도 매칭하면 되는 FULLTEXT 조건을 추가합니다.
*
* 컬럼마다 별도의 `MATCH(col)` 를 만들어 OR 로 묶습니다. 컬럼별 단일 FULLTEXT 인덱스만
* 있는 테이블(대부분의 경우)에서 쓰는 형태이며, 복합 인덱스가 없어도 동작합니다.
*
* 복합 인덱스가 있어 `MATCH(a, b)` 한 번으로 끝내야 하는 테이블은
* {@see self::whereFulltext()} 에 배열을 넘기세요.
*
* @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명 목록
* @param string $keyword 검색어 (원문 — 정제는 내부에서 수행)
* @param string $boolean 바깥 쿼리와의 결합 방식 ('and' 또는 'or')
*/
public static function whereFulltextAny(
\Illuminate\Database\Eloquent\Builder $query,
array $columns,
string $keyword,
string $boolean = 'and'
): void {
// 활성 엔진 해석을 거친다 ({@see self::whereFulltext()} 와 동일한 이유).
KeywordSearch::applyAny($query, $columns, $keyword, $boolean);
}
/**
@@ -329,39 +487,37 @@ class DatabaseFulltextEngine extends Engine
* @param Builder $builder Scout 빌더
* @param int|null $perPage 페이지당 결과 수
* @param int|null $page 페이지 번호
* @return array{query: \Illuminate\Database\Eloquent\Builder, total: int}
* @param bool $withTotal 총 건수를 계산할지 여부 (쓰지 않는 경로에서는 false)
* @param bool $keysOnly 키 컬럼과 정렬용 스코어만 조회할지 여부
* @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null}
*/
protected function performSearch(Builder $builder, ?int $perPage = null, ?int $page = null): array
{
protected function performSearch(
Builder $builder,
?int $perPage = null,
?int $page = null,
bool $withTotal = true,
bool $keysOnly = false
): array {
$model = $builder->model;
$keyword = $builder->query;
// 빈 검색어인 경우 빈 결과 반환
if (empty(trim($keyword))) {
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
$query = $model->newQuery();
// FulltextSearchable 인터페이스 구현 확인
if (! ($model instanceof FulltextSearchable)) {
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
$columns = $model->searchableColumns();
$weights = $model->searchableWeights();
if (empty($columns)) {
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
$useFulltext = static::supportsFulltext();
@@ -375,10 +531,7 @@ class DatabaseFulltextEngine extends Engine
$ftKeyword = static::sanitizeBooleanModeKeyword($keyword);
if ($ftKeyword === '') {
// 연산자만 입력 → 500 대신 빈 결과 (빈 검색어와 동일 반환 형태)
return [
'query' => null,
'total' => 0,
];
return self::emptyResult();
}
// MATCH...AGAINST WHERE 조건 생성 (MySQL, MariaDB)
@@ -399,7 +552,7 @@ class DatabaseFulltextEngine extends Engine
$scoreBindings[] = $ftKeyword;
}
$scoreRaw = '('.implode(' + ', $scoreExpressions).') as _ft_score';
$query->selectRaw($qualifiedTable.'.*, '.$scoreRaw, $scoreBindings);
$this->applySelect($query, $model, $qualifiedTable, $scoreRaw, $scoreBindings, $keysOnly);
} else {
// LIKE fallback (PostgreSQL, SQLite 등)
$query->where(function ($q) use ($columns, $keyword) {
@@ -409,7 +562,7 @@ class DatabaseFulltextEngine extends Engine
});
// 스코어 고정 0 (관련성 순위 불가)
$query->selectRaw($qualifiedTable.'.*, 0 as _ft_score');
$this->applySelect($query, $model, $qualifiedTable, '0 as _ft_score', [], $keysOnly);
}
// Scout Builder 콜백 적용 (추가 where 조건 등)
@@ -459,8 +612,16 @@ class DatabaseFulltextEngine extends Engine
$query->orderByDesc('_ft_score');
}
// 전체 건수 계산 (페이지네이션 전)
$total = $query->toBase()->getCountForPagination();
// 전체 건수 계산 (페이지네이션 전).
// 총 건수를 쓰지 않는 경로에서는 아예 세지 않는다 — 대용량 매칭에서 COUNT 한 번이
// 조회 본체보다 비쌀 수 있다.
$resultCap = PaginationLimits::resultCap('search');
$total = null;
$relation = null;
if ($withTotal) {
[$total, $relation] = BoundedPaginator::countWithCap($query, $resultCap);
}
// 페이지네이션 적용
if ($perPage !== null) {
@@ -468,11 +629,62 @@ class DatabaseFulltextEngine extends Engine
$query->limit($perPage)->offset($offset);
} elseif ($builder->limit !== null) {
$query->limit($builder->limit);
} elseif ($resultCap !== null) {
// perPage 도 limit 도 없으면 LIMIT 이 아예 붙지 않아 매칭 전량을 PHP 로 끌어온다.
// 상한을 걸어 한 요청이 읽는 행 수에 천장을 둔다.
$query->limit($resultCap);
}
return [
'query' => $query,
'total' => $total,
'total_relation' => $relation,
'result_cap' => $resultCap,
];
}
/**
* 조회 컬럼을 적용합니다.
*
* 키만 필요한 경로에서는 키 컬럼과 정렬용 스코어로 좁힙니다. 스코어는 `ORDER BY` 가
* 참조하므로 좁힐 때도 함께 남겨야 합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 대상 쿼리
* @param Model $model 기준 모델
* @param string $qualifiedTable 프리픽스 포함 테이블명
* @param string $scoreRaw 스코어 SELECT 표현식 (`... as _ft_score`)
* @param array<int, mixed> $scoreBindings 스코어 표현식 바인딩
* @param bool $keysOnly 키 컬럼만 조회할지 여부
*/
protected function applySelect(
$query,
Model $model,
string $qualifiedTable,
string $scoreRaw,
array $scoreBindings,
bool $keysOnly
): void {
if ($keysOnly) {
$query->select($model->getQualifiedKeyName())->selectRaw($scoreRaw, $scoreBindings);
return;
}
$query->selectRaw($qualifiedTable.'.*, '.$scoreRaw, $scoreBindings);
}
/**
* 검색이 성립하지 않을 때의 빈 결과 형태를 반환합니다.
*
* @return array{query: null, total: int, total_relation: TotalRelation, result_cap: null}
*/
protected static function emptyResult(): array
{
return [
'query' => null,
'total' => 0,
'total_relation' => TotalRelation::Exact,
'result_cap' => null,
];
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
namespace App\Search;
use App\Extension\HookManager;
use App\Search\Contracts\KeywordPredicateProvider;
use App\Search\DTO\KeywordSearchContext;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Scout\EngineManager;
use Throwable;
/**
* 진행 중인 DB 쿼리에 키워드 술어를 붙이는 단일 진입점
*
* 저장소는 "이 컬럼들로 이 키워드를 걸어라" 만 말하고, **어떤 엔진이 그 조건을 만드는지는
* 알지 않는다.** 활성 Scout 엔진이 {@see KeywordPredicateProvider} 를 구현하면 그 엔진에게
* 위임하고, 구현하지 않았으면 `LIKE` 로 내려간다.
*
* 이 지점이 없으면 저장소가 구체 엔진 클래스(예: `DatabaseFulltextEngine::whereFulltext()`)를
* 직접 부르게 되고, 그 순간 플러그인이 등록한 검색 엔진은 **호출될 기회 자체가 없어진다** —
* 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작한다.
*
* 확장이 자기 엔진을 등록하는 방법은 [search-system.md](../../docs/backend/search-system.md) 참조.
*/
final class KeywordSearch
{
/**
* DBMS 별 부분일치 연산자 표를 조정하는 필터 훅
*
* 확장이 새 DBMS 의 연산자를 선언할 때 씁니다.
*
* ```php
* HookManager::addFilter('core.search.like_operators', function (array $operators) {
* $operators['somedb'] = 'imatch';
*
* return $operators;
* });
* ```
*/
public const LIKE_OPERATORS_FILTER = 'core.search.like_operators';
/**
* 폴백 경고를 요청당 한 번만 남기기 위한 기록 (드라이버명 집합)
*
* @var array<string, true>
*/
private static array $warnedDrivers = [];
/**
* 쿼리에 키워드 술어를 붙입니다.
*
* 컬럼을 배열로 넘기면 **하나의 조건으로 함께 평가**해 달라는 뜻입니다. 엔진이 그 조합을
* 다룰 수 없는 경우(MySQL FULLTEXT 는 그 컬럼 조합의 복합 인덱스를 요구한다)에는
* {@see self::applyAny()} 로 컬럼별 OR 을 요청하세요.
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param string|array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or')
* @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름 (예: 'search')
*/
public static function apply(
Builder $query,
string|array $columns,
string $keyword,
string $boolean = 'and',
?string $limitContext = null
): void {
$columns = array_values((array) $columns);
if ($columns === []) {
return;
}
$provider = self::provider();
if ($provider !== null) {
$provider->applyKeywordPredicate(
$query,
$columns,
$keyword,
$boolean,
self::context($limitContext)
);
return;
}
self::applyLikeMatch($query, $columns, $keyword, $boolean);
}
/**
* 엔진에게 넘길 술어 생성 조건을 만듭니다.
*
* 키 집합 상한은 목록 총 건수 상한과 같은 값을 쓴다 — 두 상한이 갈라지면 "엔진이
* 돌려준 건수" 와 "화면이 보고하는 총 건수" 가 서로 다른 근거를 갖게 된다.
*
* @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름
* @return KeywordSearchContext 술어 생성 조건
*/
private static function context(?string $limitContext): KeywordSearchContext
{
return new KeywordSearchContext(
keyCap: PaginationLimits::resultCap($limitContext),
);
}
/**
* 컬럼별로 따로 평가해 OR 로 묶은 키워드 술어를 붙입니다.
*
* 컬럼마다 개별 인덱스만 있는 테이블에서 씁니다. 바깥은 하나의 그룹으로 감싸므로
* 호출자의 기존 조건과 섞이지 않습니다.
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or')
* @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름 (예: 'search')
*/
public static function applyAny(
Builder $query,
array $columns,
string $keyword,
string $boolean = 'and',
?string $limitContext = null
): void {
$columns = array_values($columns);
if ($columns === []) {
return;
}
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->$method(function ($group) use ($columns, $keyword, $limitContext) {
foreach ($columns as $index => $column) {
self::apply($group, $column, $keyword, $index === 0 ? 'and' : 'or', $limitContext);
}
});
}
/**
* 활성 엔진이 키워드 술어를 제공하는지 확인하고 그 엔진을 반환합니다.
*
* 제공하지 않으면 null 을 돌려주고, 그 사실을 드라이버당 한 번 기록합니다.
* 기록하지 않으면 "검색이 느리고 관련도가 이상하다" 는 증상만 남고 원인을 찾을 단서가
* 없어진다 — 폴백은 정상 동작처럼 보이기 때문이다.
*
* @return KeywordPredicateProvider|null 술어를 제공하는 엔진 (없으면 null)
*/
private static function provider(): ?KeywordPredicateProvider
{
try {
$engine = app(EngineManager::class)->engine();
} catch (Throwable) {
// 부팅 초기 등 엔진 해석이 불가한 상황 — 경고 없이 폴백한다.
return null;
}
if ($engine instanceof KeywordPredicateProvider) {
return $engine;
}
self::warnFallbackOnce();
return null;
}
/**
* 폴백 사용 사실을 드라이버당 한 번 기록합니다.
*/
private static function warnFallbackOnce(): void
{
$driver = (string) config('scout.driver', 'mysql-fulltext');
if (isset(self::$warnedDrivers[$driver])) {
return;
}
self::$warnedDrivers[$driver] = true;
Log::warning('활성 검색 엔진이 키워드 술어를 제공하지 않아 LIKE 로 검색합니다. 관련도 정렬이 적용되지 않고 전체 스캔이 발생합니다.', [
'driver' => $driver,
'contract' => KeywordPredicateProvider::class,
]);
}
/**
* 부분일치(LIKE) 조건을 붙입니다.
*
* 이 경로는 "엔진이 없을 때의 임시방편" 이 아니다. 전문검색을 제공하지 않는 DBMS 로
* 설치된 사이트에서는 **이것이 정상 검색 경로**이므로 이식성을 갖춰야 한다.
*
* - 대소문자: PostgreSQL 의 `LIKE` 는 대소문자를 구분한다. MySQL 은 기본 collation 이
* 구분하지 않으므로, 드라이버별로 연산자를 갈라 **같은 검색어가 DBMS 에 따라 다른
* 결과를 내지 않도록** 한다.
* - 와일드카드: 검색어의 `%` `_` `\` 를 escape 해 이용자가 입력한 글자 그대로 찾는다.
* escape 하지 않으면 `50%` 검색이 `50` 으로 시작하는 모든 행을 반환한다.
*
* @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더
* @param array<int, string> $columns 검색 대상 컬럼명
* @param string $keyword 검색어 원문
* @param string $boolean 기존 조건과의 결합 방식
*/
public static function applyLikeMatch(
Builder $query,
array $columns,
string $keyword,
string $boolean = 'and'
): void {
$columns = array_values($columns);
if ($columns === []) {
return;
}
$pattern = '%'.self::escapeLikeWildcards($keyword).'%';
$operator = self::caseInsensitiveLikeOperator();
$method = $boolean === 'or' ? 'orWhere' : 'where';
$query->$method(function ($nested) use ($columns, $pattern, $operator) {
foreach ($columns as $index => $column) {
$nested->{$index === 0 ? 'where' : 'orWhere'}($column, $operator, $pattern);
}
});
}
/**
* 대소문자를 구분하지 않는 부분일치 연산자를 드라이버별로 반환합니다.
*
* 드라이버명을 코드에 적지 않는다 — 앞으로 어떤 DBMS 가 공식 지원될지 정해져 있지
* 않으므로, 코드에 박으면 DBMS 가 늘 때마다 코어를 고쳐야 한다. 표는 선언형 config
* (`core.search.like_operators`)에 있고, 확장은 필터 훅으로 조정한다.
*
* @return string 사용할 비교 연산자
*/
private static function caseInsensitiveLikeOperator(): string
{
$default = (string) config('core.search.like_operator_default', 'like');
$operators = config('core.search.like_operators', []);
$operators = HookManager::applyFilters(self::LIKE_OPERATORS_FILTER, $operators);
if (! is_array($operators)) {
return $default;
}
try {
$driver = DB::getDriverName();
} catch (Throwable) {
return $default;
}
$operator = $operators[$driver] ?? $default;
return is_string($operator) && $operator !== '' ? $operator : $default;
}
/**
* `LIKE` 패턴에서 특수 의미를 갖는 문자를 escape 합니다.
*
* @param string $keyword 검색어 원문
* @return string escape 된 검색어
*/
public static function escapeLikeWildcards(string $keyword): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword);
}
/**
* 폴백 경고 기록을 초기화합니다 (테스트 전용).
*/
public static function forgetFallbackWarnings(): void
{
self::$warnedDrivers = [];
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Search;
use App\Enums\TotalRelation;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPage;
use App\Support\Query\KeysetPaginator;
use Illuminate\Pagination\CursorPaginator;
/**
* 검색 카테고리 응답 페이로드를 만드는 단일 지점
*
* 검색 결과는 컬렉션 리소스를 거치지 않고 리스너가 배열을 만들어 넘긴다. 그 조립을
* 도메인마다 손으로 하면 응답 형태가 갈라지고, 나중에 필드가 하나 늘 때 어떤 화면은
* 받고 어떤 화면은 못 받는다. 그 형태 확장이 조용히 깨지는 것을 막기 위해 키 구성을
* 여기 한 곳에 둔다.
*
* offset 응답과 커서 응답은 채워지는 값만 다르고 **키 집합은 같다**. 화면이 두 형태를
* 분기 없이 그릴 수 있어야 하기 때문이다.
*/
final class SearchCategoryPayload
{
/**
* offset(페이지 번호) 방식 결과로 페이로드를 만듭니다.
*
* @param BoundedPage $page 상한 총 건수를 가진 페이지 결과
* @param array<int, mixed> $items 화면에 실을 항목 (가공 완료 상태)
* @param array<string, mixed> $extra 도메인 고유 필드 (available_boards 등)
* @return array<string, mixed> 카테고리 페이로드
*/
public static function fromBounded(BoundedPage $page, array $items, array $extra = []): array
{
return array_merge([
'total' => $page->total(),
'total_relation' => $page->totalRelation()->value,
'total_is_exact' => $page->totalRelation()->isExact(),
'result_cap' => $page->resultCap(),
'last_page' => $page->lastPage(),
'has_more_pages' => $page->hasMorePages(),
// offset 응답에는 커서가 없다. 키 자체는 남겨 화면이 분기 없이 읽게 한다.
'next_cursor' => null,
'prev_cursor' => null,
'items' => $items,
], $extra);
}
/**
* 커서(키셋) 방식 결과로 페이로드를 만듭니다.
*
* 커서 응답에는 총 건수가 없으므로 건수는 별도 집계로 받습니다. 마지막 페이지 번호는
* 커서 방식에 존재하지 않는 개념이라 언제나 null 이며, 화면은 그때 마지막 페이지
* 점프만 감추고 "다음" 이동은 그대로 유지합니다.
*
* @param CursorPaginator $page 커서 페이지 결과
* @param BoundedCount|null $count 총 건수 집계 (배지를 그리지 않으면 null)
* @param array<int, mixed> $items 화면에 실을 항목 (가공 완료 상태)
* @param array<string, mixed> $extra 도메인 고유 필드
* @return array<string, mixed> 카테고리 페이로드
*/
public static function fromCursor(
CursorPaginator $page,
?BoundedCount $count,
array $items,
array $extra = []
): array {
$relation = $count?->totalRelation() ?? TotalRelation::AtLeast;
return array_merge([
'total' => $count?->total() ?? count($items),
'total_relation' => $relation->value,
'total_is_exact' => $count !== null && $relation->isExact(),
'result_cap' => $count?->resultCap(),
// 커서 방식에는 마지막 페이지 번호가 없다 (총 건수를 알아도 계산하지 않는다).
'last_page' => null,
'has_more_pages' => $page->hasMorePages(),
'next_cursor' => KeysetPaginator::nextCursor($page),
'prev_cursor' => KeysetPaginator::previousCursor($page),
'items' => $items,
], $extra);
}
/**
* 목록 없이 건수만 필요한 자리(비활성 탭 배지)의 페이로드를 만듭니다.
*
* @param BoundedCount $count 총 건수 집계
* @param array<string, mixed> $extra 도메인 고유 필드
* @return array<string, mixed> 카테고리 페이로드
*/
public static function fromCountOnly(BoundedCount $count, array $extra = []): array
{
return array_merge($count->toArray(), [
'last_page' => null,
'has_more_pages' => false,
'next_cursor' => null,
'prev_cursor' => null,
'items' => [],
], $extra);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Search;
use App\Support\Query\KeysetPaginator;
/**
* 검색 목록이 커서(키셋)로 응답할 수 있는지 판정하는 단일 지점
*
* 이 판정을 도메인마다 각자 구현하면 같은 규칙이 검색 모듈 수만큼 복제되고, 한 곳만
* 고치면 다른 도메인은 조용히 옛 규칙으로 남는다. 코어가 규칙을 소유하고, 확장은
* "내 정렬 이름이 어떤 실제 컬럼인가" 만 선언한다.
*
* 커서는 정렬 키를 WHERE 절 경계로 삼으므로 정렬 키가 전부 실제 컬럼이어야 한다.
* 관련도순처럼 계산값(FULLTEXT 점수)으로 정렬하는 경우에는 쓸 수 없고 offset 을 유지한다.
*/
final class SearchPagePolicy
{
/**
* 이 요청을 커서로 응답할지 판정합니다.
*
* 커서를 받은 요청만 커서로 처리하면 첫 커서가 만들어질 자리가 없다. 서버는 커서
* 모드에서만 다음 커서를 내보내므로, 화면은 건넬 커서가 없어 영원히 offset 에 머문다.
* 첫 페이지에 커서가 없는 것은 정상이므로 "커서 없음" 이 아니라 "시작점" 으로 읽는다.
*
* 다만 커서 없이 깊은 페이지를 직접 지목한 요청(주소로 열어 둔 딥링크·북마크)은
* 그 페이지를 그대로 보여줘야 한다. 커서로 바꾸면 첫 페이지로 되돌아가 링크가
* 가리키던 자리를 잃으므로, 그 경우에만 offset 을 유지한다.
*
* @param string|null $cursor 요청이 보낸 커서 (첫 페이지면 없음)
* @param array<int, array{0: string, 1: string}> $sortKeys [[컬럼, 방향], ...]
* @param array<int, string> $cursorColumns 이 도메인에서 커서로 허용한 실제 컬럼
* @param int $page 요청이 지목한 페이지 번호 (없으면 1)
* @return bool 커서로 응답할 수 있으면 true
*/
public static function usesCursor(?string $cursor, array $sortKeys, array $cursorColumns, int $page = 1): bool
{
if (! KeysetPaginator::supports($sortKeys, $cursorColumns)) {
return false;
}
if ($cursor !== null && $cursor !== '') {
return true;
}
return $page <= 1;
}
/**
* 정렬 이름을 정렬 키 배열로 바꿉니다.
*
* 확장이 선언한 `정렬이름 => [컬럼, 방향]` 맵을 코어가 해석합니다. 맵에 없는 이름은
* 커서로 처리할 수 없는 정렬(관련도순 등)로 보고 빈 배열을 돌려주며,
* {@see self::usesCursor()} 가 이를 "커서 불가" 로 판정합니다.
*
* @param string $sort 요청이 보낸 정렬 이름
* @param array<string, array{0: string, 1: string}> $sortMap 정렬 이름 → [컬럼, 방향]
* @return array<int, array{0: string, 1: string}> 정렬 키 배열 (해석 불가 시 빈 배열)
*/
public static function sortKeys(string $sort, array $sortMap): array
{
if (! array_key_exists($sort, $sortMap)) {
return [];
}
return [$sortMap[$sort]];
}
}
+11 -8
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Extension\HookManager;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
@@ -18,7 +19,7 @@ class ActivityLogService
/**
* ActivityLogService 생성자
*
* @param ActivityLogRepositoryInterface $repository 활동 로그 리포지토리
* @param ActivityLogRepositoryInterface $repository 활동 로그 리포지토리
*/
public function __construct(
private ActivityLogRepositoryInterface $repository
@@ -27,8 +28,8 @@ class ActivityLogService
/**
* 특정 모델의 활동 로그 목록을 조회합니다.
*
* @param Model $model 대상 모델
* @param array $filters 필터 조건
* @param Model $model 대상 모델
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getLogsForModel(Model $model, array $filters = []): LengthAwarePaginator
@@ -39,10 +40,12 @@ class ActivityLogService
/**
* 활동 로그 목록을 조회합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로 응답합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getList(array $filters = []): LengthAwarePaginator
public function getList(array $filters = []): LengthAwarePaginator|CursorPaginator
{
return $this->repository->getPaginated($filters);
}
@@ -50,7 +53,7 @@ class ActivityLogService
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
@@ -67,7 +70,7 @@ class ActivityLogService
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int
+1
View File
@@ -356,6 +356,7 @@ class AuthService
$userRole = $this->roleRepository->findByIdentifier('user');
if ($userRole) {
$user->roles()->sync([$userRole->id]);
$user->flushPermissionCaches();
}
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
@@ -56,8 +56,14 @@ class LanguagePackRegistry
return $this->activeCoreLocalesCache;
}
$fromDb = $this->repository->getActiveCoreLocales();
$merged = array_values(array_unique(array_merge(self::BUNDLED_CORE_LOCALES, $fromDb)));
// 활성 언어팩 전체는 getActivePacks() 가 이미 한 번 적재해 캐시한다. 코어 로케일은
// 그 컬렉션의 부분집합이므로 DB 를 다시 부르지 않고 여기서 걸러 낸다.
// (부팅 경로에서 두 메서드가 모두 호출되므로, 재조회하면 요청마다 쿼리가 하나 더 는다)
$fromPacks = $this->getActivePacks(LanguagePackScope::Core->value)
->pluck('locale')
->all();
$merged = array_values(array_unique(array_merge(self::BUNDLED_CORE_LOCALES, $fromPacks)));
return $this->activeCoreLocalesCache = $merged;
}
+23 -1
View File
@@ -7,8 +7,12 @@ use App\Enums\NotificationLogStatus;
use App\Extension\HookManager;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 알림 발송 이력 서비스
*/
class NotificationLogService
{
public function __construct(
@@ -17,6 +21,9 @@ class NotificationLogService
/**
* 발송 성공 로그를 기록합니다.
*
* @param array<string, mixed> $data 로그 데이터
* @return NotificationLog 기록된 로그
*/
public function logSent(array $data): NotificationLog
{
@@ -33,6 +40,9 @@ class NotificationLogService
/**
* 발송 실패 로그를 기록합니다.
*
* @param array<string, mixed> $data 로그 데이터
* @return NotificationLog 기록된 로그
*/
public function logFailed(array $data): NotificationLog
{
@@ -49,6 +59,9 @@ class NotificationLogService
/**
* 발송 건너뜀 로그를 기록합니다.
*
* @param array<string, mixed> $data 로그 데이터
* @return NotificationLog 기록된 로그
*/
public function logSkipped(array $data): NotificationLog
{
@@ -65,6 +78,9 @@ class NotificationLogService
/**
* 로그를 삭제합니다.
*
* @param NotificationLog $log 삭제할 로그
* @return bool 삭제 성공 여부
*/
public function deleteLog(NotificationLog $log): bool
{
@@ -79,6 +95,9 @@ class NotificationLogService
/**
* 다건 삭제합니다.
*
* @param array<int, int> $ids 삭제할 로그 ID 목록
* @return int 삭제된 건수
*/
public function bulkDelete(array $ids): int
{
@@ -94,9 +113,12 @@ class NotificationLogService
/**
* 페이지네이션 목록을 조회합니다.
*
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지당 건수
* @param User|null $user 스코프 적용 대상 사용자 (null이면 스코프 미적용)
* @return LengthAwarePaginator|CursorPaginator 페이지 결과 (커서 요청 시 키셋)
*/
public function getLogs(array $filters = [], int $perPage = 20, ?User $user = null): LengthAwarePaginator
public function getLogs(array $filters = [], int $perPage = 20, ?User $user = null): LengthAwarePaginator|CursorPaginator
{
return $this->repository->getPaginated($filters, $perPage, $user);
}
+3
View File
@@ -88,6 +88,7 @@ class UserService
// 역할 동기화
if ($roleIds !== null && count($roleIds) > 0) {
$user->roles()->sync($roleIds);
$user->flushPermissionCaches();
}
// After 훅: 사용자 객체와 원본 데이터 전달
@@ -196,6 +197,7 @@ class UserService
// 역할 동기화
if ($roleIds !== null) {
$user->roles()->sync($roleIds);
$user->flushPermissionCaches();
}
// After 훅: 사용자 객체와 원본 데이터, 스냅샷 전달
@@ -313,6 +315,7 @@ class UserService
// 역할 연결 해제 (명시적 삭제 - CASCADE 의존 금지)
$user->roles()->detach();
$user->flushPermissionCaches();
// 약관 동의 이력 삭제
$user->consents()->delete();
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Support;
use App\Enums\PermissionType;
use App\Models\Permission;
use App\Models\Role;
/**
* 비회원(guest) 역할 해석기
*
* 비회원 권한 판정은 한 요청에서 수십 번 일어난다 — 미들웨어, Gate, 리소스가 각각 묻는다.
* 매번 역할과 권한을 다시 조회하지 않도록 요청당 한 번만 적재하고 그 결과를 공유한다.
*
* 캐시 수명은 **요청 스코프**다. 프로세스 정적으로 두면 상주형 실행 환경에서 권한을
* 바꿔도 그 프로세스가 살아 있는 동안 예전 권한으로 판정한다 — 권한 변경 즉시 반영이
* 깨지므로 그렇게 하지 않는다.
*
* 미들웨어와 Gate 가 각자 같은 조회를 들고 있으면 한쪽만 고쳐졌을 때 두 경로의 판정이
* 갈린다. 그래서 해석은 이 클래스 한 곳에서만 한다.
*/
class GuestRoleResolver
{
/**
* 해석 결과를 담는 요청 속성 키
*/
private const CACHE_KEY = '_guest_role_cache';
/**
* guest 역할을 권한과 함께 조회합니다 (요청당 1회).
*
* @return Role|null guest 역할 (미정의 시 null)
*/
public static function resolve(): ?Role
{
$request = request();
if ($request->attributes->has(self::CACHE_KEY)) {
return $request->attributes->get(self::CACHE_KEY);
}
$role = Role::where('identifier', 'guest')
->with('permissions')
->first();
$request->attributes->set(self::CACHE_KEY, $role);
return $role;
}
/**
* guest 역할이 특정 권한을 보유하는지 판정합니다.
*
* 적재해 둔 권한 컬렉션에서 판정하므로 판정마다 쿼리가 나가지 않습니다.
*
* @param string $identifier 권한 식별자
* @param PermissionType|null $type 권한 타입 (null 이면 타입 무관)
* @return bool 보유 여부
*/
public static function hasPermission(string $identifier, ?PermissionType $type = null): bool
{
$role = self::resolve();
if (! $role) {
return false;
}
foreach ($role->permissions as $permission) {
if ($permission->identifier !== $identifier) {
continue;
}
if ($type === null || self::permissionType($permission) === $type) {
return true;
}
}
return false;
}
/**
* 캐시를 비웁니다.
*
* 권한 재시드(코어 업데이트 / 확장 설치) 직후처럼 같은 요청 안에서 권한 구성이
* 바뀌는 경우에 호출합니다.
*/
public static function flush(): void
{
request()->attributes->remove(self::CACHE_KEY);
}
/**
* 권한의 타입을 Enum 으로 정규화합니다.
*
* @param Permission $permission 권한 모델
* @return PermissionType|null 정규화된 타입 (해석 불가 시 null)
*/
private static function permissionType($permission): ?PermissionType
{
$type = $permission->type;
if ($type instanceof PermissionType) {
return $type;
}
return $type === null ? null : PermissionType::tryFrom((string) $type);
}
}
+89
View File
@@ -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,
];
}
}
+125
View File
@@ -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;
}
}
+209
View File
@@ -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;
}
}
+149
View File
@@ -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';
}
}
+80
View File
@@ -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;
}
}
+48
View File
@@ -75,6 +75,54 @@ return [
'identity_challenge_ttl_minutes_max' => 1440,
'identity_max_attempts_min' => 1,
'identity_max_attempts_max' => 20,
// 목록 한계값 (0 = 무제한)
'advanced_pagination_result_cap_min' => 0,
'advanced_pagination_result_cap_max' => 1000000,
'advanced_pagination_max_page_min' => 0,
'advanced_pagination_max_page_max' => 100000,
],
/*
|--------------------------------------------------------------------------
| 목록 한계값 기본값
|--------------------------------------------------------------------------
| 관리자 환경설정(`pagination` 카테고리)이 비어 있을 때 쓰는 코드 기본값입니다.
| 실제 해석은 App\Support\Query\PaginationLimits 가 단독으로 수행하며, 확장은
| 이 값을 리터럴로 다시 적지 않고 필터 훅으로만 조정합니다.
|
| - result_cap: 총 건수를 정확히 세는 상한. 이 값을 넘는 매칭은 "이상" 으로만 보고합니다.
| 페이지 이동은 상한과 무관하게 끝까지 열려 있고, 계산이 불가능해지는 것은
| 마지막 페이지 번호 하나뿐입니다.
| - max_page: 직접 요청할 수 있는 페이지 번호 상한 (남용 차단용).
|
| 둘 다 0 이면 무제한입니다.
*/
'pagination' => [
'result_cap' => 10000,
'max_page' => 1000,
],
/*
|--------------------------------------------------------------------------
| 검색 — DBMS 별 부분일치 연산자
|--------------------------------------------------------------------------
| 전문검색을 제공하지 않는 DBMS 로 설치된 사이트에서는 부분일치(LIKE)가 정상 검색
| 경로입니다. 그런데 "대소문자를 구분하지 않는 부분일치" 를 어떤 연산자로 쓰는지는
| DBMS 마다 다릅니다 — 대부분 기본 비교가 구분하지 않아 `like` 로 충분하지만,
| 그렇지 않은 DBMS 는 전용 연산자를 씁니다.
|
| 코어 코드에 드라이버명을 적지 않기 위해 이 표에 선언합니다. 새 DBMS 를 공식 지원할
| 때는 여기에 한 줄을 더하면 되고, 확장은 `core.search.like_operators` 필터 훅으로
| 조정합니다. 표에 없는 드라이버는 `like_operator_default` 를 씁니다.
|
| 키는 `DB::getDriverName()` 이 돌려주는 값입니다.
*/
'search' => [
'like_operators' => [
'pgsql' => 'ilike',
],
'like_operator_default' => 'like',
],
/*
+14 -1
View File
@@ -2,7 +2,7 @@
"_meta": {
"version": "1.0.0",
"description": "그누보드7 환경설정 기본값 및 프론트엔드 스키마",
"categories": ["general", "security", "mail", "upload", "seo", "cache", "debug", "drivers", "core_update", "geoip", "notifications", "identity"]
"categories": ["general", "security", "mail", "upload", "seo", "cache", "debug", "drivers", "core_update", "geoip", "notifications", "identity", "pagination"]
},
"defaults": {
"general": {
@@ -112,6 +112,10 @@
"auto_update_enabled": true,
"last_updated_at": ""
},
"pagination": {
"result_cap": 10000,
"max_page": 1000
},
"drivers": {
"storage_driver": "local",
"s3_bucket": "",
@@ -282,6 +286,15 @@
"last_updated_at": { "type": "string", "sensitive": false, "frontend_key": "geoip_last_updated_at" }
}
},
"pagination": {
"expose": true,
"merge_into": "advanced",
"_comment": "목록 한계값 — advanced 카테고리에 병합",
"fields": {
"result_cap": { "type": "integer", "sensitive": false, "frontend_key": "pagination_result_cap" },
"max_page": { "type": "integer", "sensitive": false, "frontend_key": "pagination_max_page" }
}
},
"drivers": {
"expose": false,
"_comment": "드라이버 설정 — 프론트엔드 미노출 (admin_settings에서 API 원본값 기반 비교로 변경)",
+3 -2
View File
@@ -9,7 +9,7 @@
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
| [백엔드](backend/) | 34개 | 정상 |
| [백엔드](backend/) | 35개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 |
| 공통 | 20개 | 정상 |
@@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
## 카테고리별 전체 문서 목록
### 백엔드 (34개)
### 백엔드 (35개)
| 문서 | 제목 |
|------|------|
@@ -152,6 +152,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [language-pack-service.md](backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) |
| [middleware.md](backend/middleware.md) | 미들웨어 등록 규칙 |
| [notification-system.md](backend/notification-system.md) | 알림 시스템 (Notification System) |
| [pagination.md](backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) |
| [README.md](backend/README.md) | 백엔드 개발 가이드 |
| [response-helper.md](backend/response-helper.md) | API 응답 규칙 (ResponseHelper) |
| [routing.md](backend/routing.md) | 라우트 네이밍 및 경로 |
+1
View File
@@ -53,6 +53,7 @@
| [language-pack-service.md](language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactiva... |
| [middleware.md](middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스... |
| [pagination.md](pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relat... |
| [response-helper.md](response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL F... |
+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
+18 -4
View File
@@ -29,16 +29,30 @@
## 파일 구조 개요
`config/core.php`는 3개 최상위 키로 구성됩니다:
`config/core.php`는 최상위 키로 구성되며, 이 문서는 그중 권한·역할·메뉴를 다룹니다.
나머지 키는 각자의 규정 문서가 소유합니다.
```php
return [
'permissions' => [...], // 코어 권한 정의
'roles' => [...], // 코어 역할 정의
'menus' => [...], // 코어 메뉴 정의
// 이 문서가 다루는 범위
'permissions' => [...], // 코어 권한 정의
'roles' => [...], // 코어 역할 정의
'menus' => [...], // 코어 메뉴 정의
// 다른 문서가 소유하는 키
'settings_limits' => [...], // 관리자 환경설정 입력 한계값
'pagination' => [...], // 목록 한계값 기본값 → pagination.md
'search' => [...], // DBMS 별 부분일치 연산자 → search-system.md
'notification_definitions' => [...], // 알림 정의 → notification-system.md
'identity_policies' => [...], // 본인인증 정책 → identity-policies.md
'identity_messages' => [...], // 본인인증 메시지 → identity-messages.md
'identity_purposes' => [...], // 본인인증 목적 → identity-policies.md
];
```
키를 새로 추가할 때는 그 키를 설명하는 규정 문서를 함께 지정하고 이 목록에 한 줄을 더합니다.
목록이 실제 구조와 어긋나면 "여기 없으니 없는 값" 으로 읽혀 중복 키가 생깁니다.
> **변경 이력 (7.0.0-beta.2)**: `mail_templates` 키는 알림 시스템 통합(#146)으로 제거되었습니다. 코어 메일 템플릿은 `notification_definitions` + `notification_templates` 로 이전되었으며, `database/seeders/NotificationDefinitionSeeder` 가 시드 데이터를 직접 보유합니다.
---
+344
View File
@@ -0,0 +1,344 @@
# 대용량 목록 페이지네이션 (Pagination)
> **관련 문서**: [Service-Repository 패턴](./service-repository.md) | [API 리소스](./api-resources.md) | [검색 시스템](./search-system.md)
---
## TL;DR (5초 요약)
```text
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 로 결합한다"](search-system.md) 참조.
측정 한계도 함께 적는다. 위 수치는 한 토큰이 전 행에 걸리도록 만든 인위적 조건이고,
단일 개발 장비 기준이다. 매칭이 적은 일반적인 검색어에는 해당하지 않는다.
`innodb_ft_result_cache_limit` 에 대한 메모리 증가분은 기준선을 확보하지 못해 확정하지
못했다 — 확정된 것은 위 시간 축뿐이다.
---
## 상한과 페이지 이동의 관계
총 건수 상한과 페이지 이동 범위는 **별개 결정**이다. 묶으면 필요 없이 기능이 깎인다.
| 항목 | 상한을 넘었을 때 |
|---|---|
| 총 건수 | "N건 이상" 으로 표기 (`total_relation = at_least`) |
| 다음 페이지 이동 | **그대로 가능** — `per_page + 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` 를 상속한다. 기존 컬렉션·리소스·컨트롤러가 전부
페이지네이터 인터페이스에 맞춰져 있으므로, 별도 타입을 새로 정의하면 소비 지점마다 언랩
코드가 생긴다. 상속하면 기존 코드가 그대로 받고 정확도 메타까지 자동으로 얻는다.
---
## 저장소에서 쓰는 법
```php
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'],
);
}
```
건수만 필요한 경우(탭 배지 등)는 조회 없이 집계만 한다.
```php
[$total, $relation] = BoundedPaginator::countWithCap($query, PaginationLimits::resultCap('search'));
```
`GROUP BY` 가 걸린 쿼리도 파생 테이블 안에서 그룹이 만들어지므로 **그룹 수**가 그대로 센다.
표준 `count()` 가 그룹별 건수를 돌려주는 문제가 없다.
### 받는 입력은 표준 `paginate()` 와 같다
Eloquent 빌더뿐 아니라 쿼리 빌더(`DB::table(...)`)와 **관계**(`$user->notifications()`)도
그대로 넘길 수 있다.
```php
// 관계를 그대로 넘겨도 된다 — 소속 조건은 그 밑 빌더에 이미 들어가 있어 보존된다
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()` |
행 수가 사용량이 아니라 **운영자의 등록 수**에 묶이는 설정성 테이블(역할·언어팩·정책 등)은
예외다. 그 경우 근거를 코드에 남긴다.
```php
// audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관)
return Role::where('is_active', true)->orderBy('id')->get();
```
---
## 건수만 필요할 때
목록을 조회하지 않고 건수만 필요한 자리(탭 배지, 요약 수치)는 `BoundedPaginator::count()`
를 쓴다. `int` 하나만 돌려주면 **상한에 걸려 잘린 값과 정확히 센 값이 구분되지 않는다** —
잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나가는 것은 오류로 드러나지 않고 그냥 틀린
숫자로만 보인다.
```php
$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()` 만 쓰던 시절에는 드러나지 않던 형태라,
목록 하나를 상한형이나 커서형으로 바꾸는 순간 처음 나타난다.
```json
{
"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 절 경계로 삼으므로 깊이와 무관하게 일정하다.
```php
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` 한 곳에서만 하며, 확장은 값을 리터럴로 다시 적지 않고 훅으로 조정한다.
```php
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` 이면 페이지 번호 목록과 마지막 페이지 점프가 사라진다** |
| `hasMorePages` | 다음 페이지 존재 여부. 총 건수를 몰라도 정확하다 |
| `showFirst` / `showLast` | 첫/마지막 버튼을 따로 제어 (미지정 시 `showFirstLast` 를 따름) |
총 건수 표기는 정확도에 따라 문구가 갈린다.
```text
정확: 총 1,234건
하한: 총 10,000건 이상 (+ 검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있다는 안내)
```
세지 않은 값을 정확한 것처럼 말하지 않는다.
---
## 체크리스트
- [ ] 목록 조회가 같은 술어를 `count()` + `get()` 으로 두 번 실행하지 않는가
- [ ] `paginate()` 에 조회 컬럼을 명시했는가 (`['*']` 방치 금지)
- [ ] 정렬 마지막에 기본키를 덧붙여 전순서를 보장했는가
- [ ] 총 건수를 모를 때 `last_page` 를 `null` 로 내보내는가
- [ ] 화면이 `total_is_exact` 를 보고 문구를 바꾸는가
- [ ] 상한값을 리터럴로 적지 않고 `PaginationLimits` 를 거치는가
- [ ] 커서를 쓴다면 정렬 키가 전부 실제 컬럼인가
+161 -4
View File
@@ -24,10 +24,11 @@
2. [FulltextSearchable 인터페이스](#fulltextsearchable-인터페이스)
3. [검색 엔진 드라이버](#검색-엔진-드라이버)
4. [확장 포인트](#확장-포인트)
5. [마이그레이션](#마이그레이션)
6. [AsUnicodeJson 캐스트](#asunicodejson-캐스트)
7. [환경설정](#환경설정)
8. [관련 문서](#관련-문서)
5. [검색 목록의 페이지 이동](#검색-목록의-페이지-이동)
6. [마이그레이션](#마이그레이션)
7. [AsUnicodeJson 캐스트](#asunicodejson-캐스트)
8. [환경설정](#환경설정)
9. [관련 문서](#관련-문서)
---
@@ -188,6 +189,93 @@ DBMS에 따라 자동 분기:
- MySQL/MariaDB: `WHERE MATCH(\`content\`) AGAINST(? IN BOOLEAN MODE)`
- 그 외: `WHERE content LIKE '%keyword%'`
### 키워드 술어는 활성 엔진이 만든다
Scout 의 `Model::search()` 는 "엔진이 결과를 돌려준다" 모델입니다. 그래서 결과를 받은 뒤 그 ID
로 DB 를 다시 조회해야 하고, 매칭이 많으면 ID 전량을 메모리에 올려 무제한 `IN (...)` 을 만들게
됩니다. 페이지네이션·조인·필터를 DB 에 남긴 채 키워드 조건만 얹으려면 **엔진에게 술어를 달라고
요청할 통로**가 따로 필요합니다.
그 통로가 `App\Search\Contracts\KeywordPredicateProvider` 이고, 해석은
`App\Search\KeywordSearch` 가 단독으로 수행합니다.
```php
use App\Search\KeywordSearch;
// 컬럼들을 하나의 조건으로 함께 평가 (복합 인덱스가 있는 테이블)
KeywordSearch::apply($query, ['title', 'content'], $keyword);
// 컬럼마다 따로 평가해 OR 로 묶기 (컬럼별 단일 인덱스만 있는 테이블)
KeywordSearch::applyAny($query, ['name', 'description'], $keyword);
```
**저장소는 구체 엔진 클래스를 지목하지 않습니다.** 지목하는 순간 플러그인이 등록한 검색 엔진은
호출될 기회 자체를 잃고, 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작합니다.
활성 엔진이 이 계약을 구현하지 않으면 부분일치로 내려가며, 그 사실이 기록에 남습니다 — 폴백은
정상 동작처럼 보이므로 기록하지 않으면 "검색이 느리고 관련도가 이상하다" 는 증상만 남습니다.
#### 페이지네이션은 DB 가, 상한은 엔진이
엔진에게 페이지 번호를 넘기지 않습니다. 엔진이 자기 순서로 한 페이지 분량만 돌려주면, 그 뒤에
적용되는 DB 필터(분류·전시상태·조인)에 일부가 탈락해 페이지가 비고, DB 정렬과 엔진의 관련도
순서가 달라 페이지 경계도 어긋나기 때문입니다.
엔진이 책임지는 것은 **"얼마까지 돌려줄 것인가"** 하나이며, 그 값은 `KeywordSearchContext` 로
전달됩니다. 외부 검색 서버를 쓰는 엔진은 자기 서버에서 키 집합을 받아 조건으로 붙이는데,
상한을 지키지 않으면 매칭이 큰 검색어에서 그 집합 자체가 메모리 폭발이 됩니다.
```php
KeywordSearch::applyAny($query, ['name', 'description'], $keyword, 'and', 'search');
// ↑ 상한 해석 컨텍스트
```
상한은 목록 총 건수 상한(`PaginationLimits::resultCap()`)과 **같은 값**을 씁니다. 두 기준이
갈라지면 "엔진이 돌려준 건수" 와 "화면이 보고하는 총 건수" 가 서로 다른 근거를 갖게 됩니다.
상한에 걸려 잘린 경우 그 이상은 도달 불가이고 총 건수는 "이상" 으로 보고됩니다.
이 의무는 정적 검사로 강제할 수 없습니다 — 외부 엔진 코드는 이 저장소 밖입니다. 코어가 할 수
있는 것은 **값을 손에 쥐어 주는 것**까지이며, 그 값이 실제로 도달하는지는 계약 테스트가
고정합니다.
#### 전문검색을 제공하지 않는 DBMS
부분일치 경로는 임시방편이 아닙니다. 전문검색을 제공하지 않는 DBMS 로 설치된 사이트에서는
**이것이 정상 검색 경로**이므로 이식성을 갖춥니다.
- 검색어의 `%` `_` `\` 를 escape 해 이용자가 입력한 글자 그대로 찾습니다.
- "대소문자를 구분하지 않는 부분일치" 를 어떤 연산자로 쓰는지는 DBMS 마다 다릅니다. 그 표는
`config('core.search.like_operators')` 에 **선언형으로** 있고, 표에 없는 드라이버는
`like_operator_default` 를 씁니다. 코어 코드에는 드라이버명을 적지 않습니다 — 적으면
DBMS 가 공식 지원 목록에 추가될 때마다 코어를 고쳐야 합니다.
- 확장은 `core.search.like_operators` 필터 훅으로 새 DBMS 의 연산자를 선언합니다.
```php
HookManager::addFilter('core.search.like_operators', function (array $operators) {
$operators['somedb'] = 'imatch';
return $operators;
});
```
해당 DBMS 의 진짜 전문검색(예: PostgreSQL `tsvector`)은 그 엔진을 `core.search.engine_drivers`
로 등록하고 `KeywordPredicateProvider` 를 구현하면 코어 수정 없이 이 경로를 그대로 탑니다.
### 검색어 토큰은 OR 로 결합한다 (확정)
`sanitizeBooleanModeKeyword()` 는 BOOLEAN MODE 연산자를 제거한 뒤 남은 토큰을 각각
따옴표로 묶어 **공백으로 잇는다**. BOOLEAN MODE 에서 공백 결합은 OR 이므로, "빨간 운동화"
는 "빨간" 이 든 행과 "운동화" 가 든 행을 **모두** 매치한다.
각 토큰에 `+` 를 붙여 AND 로 바꾸면 매칭 집합 자체가 줄어 대용량에서 검색 시간이 줄어들
여지가 있다. 그럼에도 **OR 을 유지한다** — 이용자가 입력한 단어 중 하나만 든 문서가
결과에서 통째로 사라지는 것은 성능과 맞바꿀 수 없는 손실이기 때문이다. 한글은 ngram 파서가
2글자 단위로 토큰을 쪼개므로 AND 전환의 결과 축소 폭이 특히 크다.
성능 축이 문제가 되면 토큰 결합 방식이 아니라 전용 검색 엔진(`core.search.engine_drivers`
훅으로 교체)으로 해결한다. 상한 COUNT 가 매칭 시간을 줄이지 못하는 이유와 실측치는
[pagination.md](pagination.md) 참조.
---
## 확장 포인트
@@ -481,8 +569,77 @@ SCOUT_QUEUE=false
---
## 검색 목록의 페이지 이동
검색 결과는 컬렉션 리소스를 거치지 않고 리스너가 배열을 만들어 넘긴다. 그 조립과 판정을
도메인마다 손으로 하면 검색 모듈 수만큼 같은 코드가 복제되고, 나중에 필드가 하나 늘 때
어떤 화면은 받고 어떤 화면은 못 받는다. **코어가 계약을 소유하고 확장은 선언만 한다.**
| 코어가 소유하는 것 | 위치 |
|---|---|
| 커서 적용 가능 여부 판정 | `App\Search\SearchPagePolicy` |
| 카테고리 응답 페이로드 조립 | `App\Search\SearchCategoryPayload` |
| 커서 인코딩·디코딩 | `App\Support\Query\KeysetPaginator` |
확장이 선언하는 것은 **정렬 이름이 어떤 실제 컬럼인가** 둘뿐이다.
```php
/** 정렬 이름 → [실제 컬럼, 방향]. 여기에 없는 이름은 커서를 쓰지 않는다. */
public const SEARCH_SORT_MAP = [
'latest' => ['created_at', 'desc'],
'oldest' => ['created_at', 'asc'],
];
/** 커서 경계로 쓸 수 있는 실제 컬럼 */
public const SEARCH_CURSOR_COLUMNS = ['created_at'];
```
서비스는 규칙을 다시 쓰지 않고 코어에 판정을 위임한다. 적용할 수 없는 정렬이면 `null` 을
돌려주고, 호출자는 기존 offset 경로를 그대로 쓴다.
```php
$sortKeys = SearchPagePolicy::sortKeys($sort, self::SEARCH_SORT_MAP);
if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS)) {
return null;
}
return $this->repository->searchByKeywordWithCursor($keyword, $sortKeys, $perPage, $cursor);
```
### 관련도순에는 커서를 쓸 수 없다
커서는 정렬 키를 WHERE 절 경계로 삼으므로 정렬 키가 **실제 컬럼**이어야 한다. 관련도순은
전문 검색 점수라는 계산값으로 정렬하므로 경계로 쓸 수 없고, 그 정렬에서는 offset 을
유지한다. 이 판정은 선언에 그 이름이 없는 것으로 자연히 이루어진다.
### 응답 키 집합은 세 형태가 모두 같다
offset·커서·건수전용 세 응답은 채워지는 값만 다르고 키 구성은 동일하다. 화면이 분기 없이
같은 키를 읽을 수 있어야 하기 때문이다.
| 키 | offset | 커서 | 건수전용 |
|---|---|---|---|
| `total` / `total_relation` / `total_is_exact` / `result_cap` | 채움 | 채움(별도 집계) | 채움 |
| `last_page` | 상한 초과 시 `null` | 언제나 `null` | `null` |
| `has_more_pages` | 실측 | 실측 | `false` |
| `next_cursor` / `prev_cursor` | `null` | 채움 | `null` |
| `items` | 채움 | 채움 | 빈 배열 |
커서 응답에 `last_page` 가 없는 것은 결함이 아니라 **커서 방식에 마지막 페이지 번호라는
개념이 없기 때문**이다. 화면은 그때 마지막 페이지 점프만 감추고 "다음" 이동은 유지한다.
### 탭 배지에도 정확도가 따라간다
배지는 숫자 하나만 그리므로, 상한에 걸려 잘린 값이 정확한 것처럼 나가도 오류로 드러나지
않고 그냥 틀린 숫자로만 보인다. 코어가 카테고리별 정확도를 `counts_are_exact` 로 일괄
제공하며, 화면은 정확하지 않은 배지에 "이상" 표시를 붙인다.
---
## 관련 문서
- [대용량 목록 페이지네이션](pagination.md) - 상한 COUNT·커서 적용 기준
- [Service-Repository 패턴](service-repository.md) - Repository에서 whereFulltext() 사용
- [훅 시스템](../extension/hooks.md) - core.search.engine_drivers 필터 훅
- [데이터베이스 가이드](../database-guide.md) - 마이그레이션 규칙
+19 -5
View File
@@ -942,11 +942,25 @@ class OrderRepository implements OrderRepositoryInterface
`paginate` / `simplePaginate` / 캐시 total 주입은 인자로 구분한다 — inner 쿼리는 동일하고 COUNT 수행 여부와 반환 클래스만 달라진다.
| 호출자 상황 | `$simple` | `$total` | 반환 |
| ------ | ------ | ------ | ------ |
| 일반 목록 | `false` | `null` | `LengthAwarePaginator` (COUNT 1회) |
| COUNT 를 피하고 다음 페이지 유무만 필요 | `true` | — | `Paginator` |
| 총 건수를 이미 캐시해 둠 | `false` | 캐시값 | `LengthAwarePaginator` (COUNT 없음) |
| 호출자 상황 | `$simple` | `$total` | `$resultCap` | 반환 |
| ------ | ------ | ------ | ------ | ------ |
| 일반 목록 | `false` | `null` | `null` | `LengthAwarePaginator` (COUNT 1회) |
| COUNT 를 피하고 다음 페이지 유무만 필요 | `true` | — | — | `Paginator` |
| 총 건수를 이미 캐시해 둠 | `false` | 캐시값 | `null` | `LengthAwarePaginator` (COUNT 없음) |
| 계속 쌓이기만 하는 목록 (로그·회원 등) | `false` | `null` | 상한 | `BoundedPage` (상한 COUNT + `per_page + 1` 실측) |
상한을 지정하면 총 건수는 그 값까지만 세고, 다음 페이지 판정은 `per_page + 1` 실측으로 따로 한다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이다. 상세는 [pagination.md](pagination.md).
```php
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: $sort,
perPage: $perPage,
relations: ['user:id,uuid,name,email'],
resultCap: PaginationLimits::resultCap('admin.activity_logs'),
);
```
정렬 순서 복원은 `whereIn` + 같은 정렬 스펙 재적용으로 한다. 키 컬럼이 정렬에 포함돼 전순서가 성립하므로 결과가 inner 순서와 동일하다. MySQL 전용 `FIELD()` 는 쓰지 않는다. outer 에 존재하지 않는 표현식(집계·조인 서브쿼리)으로 정렬해 재현이 불가능한 경우에만 `$preserveIdOrder = true` 로 표준 SQL `CASE WHEN` 경로를 쓴다.
+34 -2
View File
@@ -8,7 +8,7 @@
1. PHP 8.2+ 필수
2. MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4, utf8mb4_unicode_ci)
3. PHP 필수 모듈 30개 (ctype, curl, gd, intl, redis, imagick 등)
4. 디스크 용량: 최소 700MB / 권장 2GB+
4. 하드웨어: 최소 2 vCPU·2GB / 권장 4 vCPU·8GB, 디스크 최소 700MB / 권장 2GB+
5. 프로덕션: HTTPS 필수, Redis 권장, 큐 워커/스케줄러/Reverb 데몬 필요
```
@@ -174,7 +174,39 @@ sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates
---
## 3. 디스크 용량
## 3. 하드웨어 사양
### 3.1 CPU · 메모리
| 수준 | CPU | 메모리 | 상정 환경 |
|------|-----|--------|----------|
| 최소 | 2 vCPU | 2GB | 설치·기능 확인용. 동시 접속이 거의 없는 개발/검토 환경 |
| 권장 | 4 vCPU | 8GB | 웹서버 + PHP-FPM + MySQL + Redis 를 한 대에 올린 소규모 운영 |
| 분리 구성 | 웹 2 vCPU / DB 2 vCPU | 각 4GB 이상 | 트래픽이 늘면 DB 를 먼저 분리 |
메모리 배분은 어떤 구성 요소를 같은 서버에 올렸는지에 따라 달라진다. 한 대에 모두 올린
경우 MySQL 의 `innodb_buffer_pool_size` 가 전체 메모리의 절반을 넘지 않도록 두고, PHP-FPM
자식 프로세스 수 × `memory_limit` 가 남은 메모리를 넘지 않는지 확인한다.
위 수치는 코어와 번들 확장을 기본 설정으로 운영할 때의 출발점이다. 실제 필요량은 데이터
규모·동시 접속·설치한 확장에 따라 달라지므로, 운영 전 대상 트래픽으로 직접 측정할 것을
권한다(`php artisan g7:bench` 로 목록·화면·쓰기·배치 4축을 잰다).
**이 수치의 근거**
- **권장 4 vCPU · 8GB** — 사용자가 4 vCPU · 8GB 가상머신에서 수행한 운영 환경 측정 보고
(`gnuboard/g7#82`)에서 코어와 번들 확장을 함께 올린 구성이 동작한 사양이다. 같은 보고에서
대량 데이터의 검색·목록 조회는 이 사양에서도 메모리 압박이 관측되었으므로, "이 사양이면
어떤 규모든 충분하다" 는 뜻이 아니라 **한 대 구성의 하한선**으로 읽어야 한다.
- **최소 2 vCPU · 2GB** — 설치와 기능 확인이 가능한 수준으로, 위 보고의 측정 대상이 아니다.
동시 접속이 있는 운영에는 적합하지 않다.
- **분리 구성** — 위 보고에서 부하가 먼저 걸린 지점이 데이터베이스였다는 관측에 따른 순서
제안이다. 분리 시점의 트래픽 임계값은 측정하지 않았다.
우리가 직접 측정한 범위는 쿼리 실행 횟수와 조회 구조이며, 특정 사양에서의 동시 접속 한계나
응답 시간은 측정하지 않았다. 그 값이 필요하면 대상 환경에서 직접 재야 한다.
### 3.2 디스크 용량
| 수준 | 용량 | 포함 범위 |
|------|------|----------|
@@ -26,6 +26,9 @@
- 레이아웃 편집기 버전 이력의 "더 보기" 버튼과 변경내용이 너무 클 때의 안내 문구 일본어 번역 추가.
- 버전 목록을 한 번에 몇 개까지 가져올지 정하는 값의 입력 안내 문구 일본어 번역 추가.
- 통신이 끊겼을 때 표시되는 네트워크 오류 안내 문구 일본어 번역 추가 — 종전에는 내부 식별 문구가 영문 그대로 노출됐습니다.
- 목록·검색의 총 건수 정확도 안내 문구(`pagination.*`) 일본어 번역 추가 — 총 건수를 상한까지만 센 경우의 "N건 이상" 표기와 검색어를 좁히라는 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 검색 결과 건수 안내와 검색어 구체화 안내(`search.results_found_at_least`, `search.refine_query_hint`) 일본어 번역 추가.
- 페이지 번호 상한 초과 시의 검증 오류 문구(`validation.page_max`) 일본어 번역 추가.
## [1.0.8] - 2026-08-01
@@ -0,0 +1,12 @@
<?php
return [
'total_relation' => [
'exact' => '正確',
'at_least' => '以上',
],
'total_exact' => '合計 :count件',
'total_at_least' => '合計 :count件以上',
'result_cap_notice' => '一致する項目が :cap件を超えているため、正確な件数を数えていません。次のページに続けて移動できます。',
'refine_query_hint' => '検索語をより具体的に入力すると、正確な件数と最後のページを表示できます。',
];
@@ -13,6 +13,7 @@ return [
'per_page_integer' => 'ページあたりの項目数は数字である必要があります。',
'per_page_min' => 'ページあたりの項目数は1以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は最大100個まで可能です。',
'page_max' => 'ページ番号は :max 以下である必要があります。検索キーワードをより具体的に入力してください。',
],
'index' => [
'status' => [
@@ -56,4 +57,7 @@ return [
],
],
],
'results_found_at_least' => ':count件以上の検索結果が見つかりました。',
'result_cap_notice' => '一致する項目が :cap件を超えており、合計件数を正確にカウントしていません。次のページに続行できます。',
'refine_query_hint' => '検索キーワードをより具体的に入力すると、正確な件数と最後のページを確認できます。',
];
@@ -977,6 +977,12 @@ return [
'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。',
'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。',
'sitemap_hreflang_enabled_boolean' => 'Sitemap 多言語代替リンク(hreflang) 設定は true または false 値である必要があります。',
'pagination_result_cap_integer' => '総件数集計上限は数値である必要があります。',
'pagination_result_cap_min' => '総件数集計上限は:min以上である必要があります。(0 = 無制限)',
'pagination_result_cap_max' => '総件数集計上限は:maxを超えることはできません。',
'pagination_max_page_integer' => 'ページ番号上限は数値である必要があります。',
'pagination_max_page_min' => 'ページ番号上限は:min以上である必要があります。(0 = 無制限)',
'pagination_max_page_max' => 'ページ番号上限は:maxを超えることはできません。',
],
'identity_policy' => [
'key_required' => 'ポリシーキーを入力してください。',
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| List pagination
|--------------------------------------------------------------------------
| Total count accuracy labels and large-result-set notices.
*/
'total_relation' => [
'exact' => 'exact',
'at_least' => 'at least',
],
'total_exact' => ':count results',
'total_at_least' => 'more than :count results',
'result_cap_notice' => 'More than :cap items matched, so the exact total was not counted. You can still move to the next page.',
'refine_query_hint' => 'Narrow your search terms to see the exact total and jump to the last page.',
];
+4
View File
@@ -7,6 +7,9 @@
return [
'empty_keyword' => 'Please enter a search keyword.',
'results_found' => ':count results found.',
'results_found_at_least' => 'More than :count results found.',
'result_cap_notice' => 'More than :cap items matched, so the exact total was not counted. You can still move to the next page.',
'refine_query_hint' => 'Narrow your search terms to see the exact total and jump to the last page.',
'no_results' => 'No results found.',
'view_more' => 'View more',
@@ -16,6 +19,7 @@ return [
'q_max' => 'Search keyword must not exceed 200 characters.',
'page_integer' => 'Page number must be a number.',
'page_min' => 'Page number must be at least 1.',
'page_max' => 'Page number must not exceed :max. Please narrow your search terms.',
'per_page_integer' => 'Items per page must be a number.',
'per_page_min' => 'Items per page must be at least 1.',
'per_page_max' => 'Items per page must not exceed 100.',
+8
View File
@@ -906,6 +906,14 @@ return [
'sql_query_log_required' => 'Please select the SQL query log setting.',
'sql_query_log_boolean' => 'SQL query log must be true or false.',
// List limits
'pagination_result_cap_integer' => 'The total count cap must be a number.',
'pagination_result_cap_min' => 'The total count cap must be at least :min. (0 = unlimited)',
'pagination_result_cap_max' => 'The total count cap may not be greater than :max.',
'pagination_max_page_integer' => 'The maximum page number must be a number.',
'pagination_max_page_min' => 'The maximum page number must be at least :min. (0 = unlimited)',
'pagination_max_page_max' => 'The maximum page number may not be greater than :max.',
// Core update settings
'core_update_github_url_invalid' => 'The GitHub repository URL format is invalid.',
'core_update_github_url_max' => 'The GitHub repository URL may not be greater than 500 characters.',
+23
View File
@@ -0,0 +1,23 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 목록 페이지네이션
|--------------------------------------------------------------------------
| 총 건수 정확도 표기와 대용량 목록 안내 문구입니다.
*/
'total_relation' => [
'exact' => '정확',
'at_least' => '이상',
],
// 건수 표기 — 상한 이하일 때는 정확한 건수, 초과할 때는 "이상"
'total_exact' => '총 :count건',
'total_at_least' => '총 :count건 이상',
// 상한 초과 안내 — 검색어를 좁히면 정확한 건수를 볼 수 있다
'result_cap_notice' => '일치하는 항목이 :cap건을 넘어 총 건수를 정확히 세지 않았습니다. 다음 페이지로 계속 이동할 수 있습니다.',
'refine_query_hint' => '검색어를 더 구체적으로 입력하면 정확한 건수와 마지막 페이지를 볼 수 있습니다.',
];
+4
View File
@@ -7,6 +7,9 @@
return [
'empty_keyword' => '검색어를 입력해주세요.',
'results_found' => ':count건의 검색 결과를 찾았습니다.',
'results_found_at_least' => ':count건 이상의 검색 결과를 찾았습니다.',
'result_cap_notice' => '일치하는 항목이 :cap건을 넘어 총 건수를 정확히 세지 않았습니다. 다음 페이지로 계속 이동할 수 있습니다.',
'refine_query_hint' => '검색어를 더 구체적으로 입력하면 정확한 건수와 마지막 페이지를 볼 수 있습니다.',
'no_results' => '검색 결과가 없습니다.',
'view_more' => '더보기',
@@ -16,6 +19,7 @@ return [
'q_max' => '검색어는 최대 200자까지 입력 가능합니다.',
'page_integer' => '페이지 번호는 숫자여야 합니다.',
'page_min' => '페이지 번호는 1 이상이어야 합니다.',
'page_max' => '페이지 번호는 :max 이하여야 합니다. 검색어를 더 구체적으로 입력해 주세요.',
'per_page_integer' => '페이지당 항목 수는 숫자여야 합니다.',
'per_page_min' => '페이지당 항목 수는 1 이상이어야 합니다.',
'per_page_max' => '페이지당 항목 수는 최대 100개까지 가능합니다.',
+8
View File
@@ -989,6 +989,14 @@ return [
'sql_query_log_required' => 'SQL 쿼리 로그 설정을 선택해주세요.',
'sql_query_log_boolean' => 'SQL 쿼리 로그는 true 또는 false 값이어야 합니다.',
// 목록 한계값
'pagination_result_cap_integer' => '총 건수 집계 상한은 숫자여야 합니다.',
'pagination_result_cap_min' => '총 건수 집계 상한은 :min 이상이어야 합니다. (0 = 무제한)',
'pagination_result_cap_max' => '총 건수 집계 상한은 :max 를 초과할 수 없습니다.',
'pagination_max_page_integer' => '페이지 번호 상한은 숫자여야 합니다.',
'pagination_max_page_min' => '페이지 번호 상한은 :min 이상이어야 합니다. (0 = 무제한)',
'pagination_max_page_max' => '페이지 번호 상한은 :max 를 초과할 수 없습니다.',
// 코어 업데이트 설정
'core_update_github_url_invalid' => 'GitHub 저장소 URL 형식이 올바르지 않습니다.',
'core_update_github_url_max' => 'GitHub 저장소 URL은 500자를 초과할 수 없습니다.',
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace Tests\Concerns;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
/**
* 실행된 쿼리 수를 세는 테스트 트레이트
*
* N+1 은 "행 수가 늘면 쿼리도 는다" 는 형태로 나타난다. 특정 시점의 쿼리 수를 숫자로
* 박아 두면 관계 하나만 추가해도 테스트가 깨져 유지되지 않고, 반대로 상한만 두면
* 행 수에 비례해 늘어나는 진짜 회귀를 놓친다.
*
* 그래서 이 트레이트는 **같은 화면을 행 수만 바꿔 두 번 재고 그 차이를 본다**.
* 행을 두 배로 늘려도 쿼리 수가 그대로면 조회 구조가 행 수와 무관하다는 뜻이다.
*/
trait CountsQueries
{
/**
* 클로저를 실행하며 발생한 쿼리를 모두 기록합니다.
*
* @param \Closure $callback 측정 대상
* @return array<int, string> 실행된 SQL 목록
*/
protected function captureQueries(\Closure $callback): array
{
$queries = [];
DB::listen(function (QueryExecuted $query) use (&$queries) {
$queries[] = $query->sql;
});
try {
$callback();
} finally {
// 리스너는 커넥션에 누적되므로, 다음 측정이 이전 기록을 물려받지 않도록 끊는다.
DB::getEventDispatcher()?->forget(QueryExecuted::class);
}
return $queries;
}
/**
* 클로저를 실행하며 발생한 쿼리 수를 셉니다.
*
* @param \Closure $callback 측정 대상
* @return int 실행된 쿼리 수
*/
protected function countQueries(\Closure $callback): int
{
return count($this->captureQueries($callback));
}
/**
* 데이터 규모를 바꿔도 쿼리 수가 늘지 않는지 단언합니다.
*
* 행을 늘리는 일(`$grow`)은 측정 밖에서 수행하고, 측정은 조회(`$measure`)만 감쌉니다.
*
* @param \Closure $measure 측정할 조회 (쿼리 수를 재는 대상)
* @param \Closure $grow 데이터를 늘리는 작업 (측정에 포함되지 않음)
* @param string $context 실패 메시지에 붙일 설명
*/
protected function assertQueryCountStableAsDataGrows(\Closure $measure, \Closure $grow, string $context = ''): void
{
$before = $this->countQueries($measure);
$grow();
$after = $this->countQueries($measure);
$label = $context !== '' ? $context.': ' : '';
$this->assertLessThanOrEqual(
$before,
$after,
$label."행 수를 늘렸더니 쿼리 수가 {$before} → {$after} 로 늘었다. "
.'조회가 행마다 쿼리를 내고 있다(N+1) — 관계를 eager load 하거나 일괄 조회로 바꾼다'
);
}
}
@@ -224,9 +224,21 @@ class ActivityLogControllerTest extends TestCase
'from',
'to',
'has_more_pages',
// 활동 로그는 상한을 건 집계라 총 건수가 잘릴 수 있다. 정확도 필드가
// 빠지면 잘린 값이 화면에서 정확한 건수로 읽힌다 (실측: 실제 88,792 건이
// "10000건" 으로 표기). 메타를 손으로 조립하면 이 필드들이 사라지므로
// 표준 메타(BaseApiCollection::paginationMeta)를 쓰는지 여기서 고정한다.
'total_relation',
'total_is_exact',
'result_cap',
],
],
]);
$this->assertTrue(
$response->json('data.pagination.total_is_exact'),
'작은 표본에서는 총 건수가 정확해야 합니다.'
);
}
public function test_index_data_items_have_correct_fields(): void
@@ -67,6 +67,65 @@ class NotificationLogControllerTest extends TestCase
->assertJsonPath('data.pagination.total', 1);
}
/**
* 커서 요청이 정상 응답하는지 확인 (#519 회귀)
*
* 이 목록은 커서를 주면 키셋 페이지로 응답한다. 커서 결과에는 총 건수와 마지막 페이지가
* 없는데, 컬렉션이 페이지네이션 블록을 손으로 조립하면 없는 값을 불러 그 요청만 500 이
* 된다. 응답 형태를 스스로 판정하는 표준 메타를 쓰는지 여기서 고정한다.
*/
public function test_index_accepts_cursor_request(): void
{
foreach (range(1, 5) as $i) {
NotificationLog::create([
'channel' => 'mail',
'notification_type' => 'test',
'recipient_identifier' => "c{$i}@test.com",
'status' => 'sent',
]);
}
// 커서 파라미터가 있어야 키셋 경로로 들어간다. 형식이 깨진 값은 첫 페이지로
// 되돌려 주므로(KeysetPaginator::decode), 진입에는 임의 문자열로 충분하다.
$first = $this->authRequest()
->getJson('/api/admin/notification-logs?per_page=2&cursor=first');
$first->assertStatus(200);
$this->assertCount(2, $first->json('data.data'));
$pagination = $first->json('data.pagination');
// 커서 결과는 총 건수를 세지 않는다 — 없는 값을 채워 내보내지 않아야 한다.
$this->assertArrayNotHasKey('total', $pagination);
$this->assertArrayNotHasKey('last_page', $pagination);
$this->assertArrayHasKey('next_cursor', $pagination, '커서 응답에 다음 커서가 없다');
// 받은 커서로 실제 다음 페이지까지 이동되는지 확인
$second = $this->authRequest()
->getJson('/api/admin/notification-logs?per_page=2&cursor='.urlencode($pagination['next_cursor']));
$second->assertStatus(200);
$this->assertNotEmpty($second->json('data.data'));
}
/**
* 상한형 목록이 정확도 메타를 함께 싣는지 확인 (#519 — 성능 개선 유지)
*/
public function test_index_carries_total_accuracy_meta(): void
{
NotificationLog::create(['channel' => 'mail', 'notification_type' => 'test', 'recipient_identifier' => 'm@test.com', 'status' => 'sent']);
$response = $this->authRequest()->getJson('/api/admin/notification-logs?per_page=15');
$response->assertStatus(200);
$pagination = $response->json('data.pagination');
$this->assertArrayHasKey('total_relation', $pagination, '상한 계약의 정확도 메타가 사라졌다');
$this->assertArrayHasKey('total_is_exact', $pagination);
$this->assertArrayHasKey('result_cap', $pagination);
}
/**
* 단건 삭제
*/
@@ -111,7 +170,7 @@ class NotificationLogControllerTest extends TestCase
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
@@ -7,6 +7,7 @@ use App\Enums\PermissionType;
use App\Enums\ScheduleType;
use App\Enums\ScopeType;
use App\Helpers\PermissionHelper;
use App\Http\Middleware\PermissionMiddleware;
use App\Models\Menu;
use App\Models\Permission;
use App\Models\Role;
@@ -48,10 +49,7 @@ class PermissionMiddlewareTest extends TestCase
$prop->setValue(null, []);
// PermissionMiddleware guest role 캐시 초기화
$middlewareReflection = new \ReflectionClass(\App\Http\Middleware\PermissionMiddleware::class);
$guestProp = $middlewareReflection->getProperty('guestRoleCache');
$guestProp->setAccessible(true);
$guestProp->setValue(null, null);
PermissionMiddleware::clearGuestRoleCache();
// 테스트용 admin 권한 생성
$this->adminPermission = Permission::create([
@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature\Notifications;
use App\Enums\PermissionType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* 관리자 알림 목록 조회 (#519 회귀)
*
* 이 목록은 `$user->notifications()` 라는 **관계**를 페이지네이션한다. 상한 페이지네이션
* 계약으로 옮기면서 그 계약이 빌더만 받도록 좁게 선언돼 있어, 화면 진입만으로 500 이
* 났다. 이 경로에 테스트가 하나도 없어 전 계층 테스트가 green 인 채로 통과했다.
*
* 그래서 여기서는 두 가지를 함께 못박는다.
* (1) 관계 입력이 정상 응답으로 이어진다 (회귀 차단)
* (2) 그러면서도 상한 계약의 정확도 메타가 살아 있다 (성능 개선이 되돌아가지 않음)
*/
class AdminNotificationListTest extends TestCase
{
use RefreshDatabase;
/**
* 알림 읽기 권한을 가진 관리자를 만듭니다.
*
* @return User 관리자
*/
private function adminWithNotificationRead(): User
{
$user = User::factory()->create();
$role = Role::create([
'identifier' => 'notif-admin-'.$user->id,
'name' => ['ko' => '알림 관리자', 'en' => 'Notification Admin'],
]);
foreach (['admin.access', 'core.notifications.read'] as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => ['ko' => $identifier, 'en' => $identifier],
'type' => PermissionType::Admin,
]
);
$role->permissions()->syncWithoutDetaching([$permission->id]);
}
$user->roles()->attach($role->id);
return User::findOrFail($user->id);
}
/**
* 알림을 만듭니다.
*
* @param User $user 수신자
* @param int $count 건수
* @param bool $read 읽음 여부
*/
private function seedNotifications(User $user, int $count, bool $read = false): void
{
foreach (range(1, $count) as $i) {
$user->notifications()->create([
'id' => (string) Str::uuid(),
'type' => 'test',
'data' => ['message' => 'n'.$i],
'read_at' => $read ? now() : null,
]);
}
}
/**
* 목록이 200 으로 응답하는지 확인 (관계 입력 회귀 차단)
*/
public function test_admin_notification_list_returns_ok(): void
{
$admin = $this->adminWithNotificationRead();
$this->seedNotifications($admin, 3);
$response = $this->actingAs($admin, 'sanctum')
->getJson('/api/admin/notifications?per_page=15&read=unread');
$response->assertOk();
$this->assertCount(3, $response->json('data.data'));
}
/**
* 읽음 필터가 적용되는지 확인
*/
public function test_unread_filter_is_applied(): void
{
$admin = $this->adminWithNotificationRead();
$this->seedNotifications($admin, 2, read: false);
$this->seedNotifications($admin, 4, read: true);
$response = $this->actingAs($admin, 'sanctum')
->getJson('/api/admin/notifications?per_page=15&read=unread');
$response->assertOk();
$this->assertCount(2, $response->json('data.data'));
}
/**
* 다른 사람의 알림이 섞이지 않는지 확인 (관계의 소속 조건 보존)
*/
public function test_other_users_notifications_are_not_listed(): void
{
$admin = $this->adminWithNotificationRead();
$other = User::factory()->create();
$this->seedNotifications($admin, 2);
$this->seedNotifications($other, 5);
$response = $this->actingAs($admin, 'sanctum')
->getJson('/api/admin/notifications?per_page=15');
$response->assertOk();
$this->assertCount(2, $response->json('data.data'), '관계의 소속 조건이 사라져 남의 알림이 섞였다');
}
/**
* 상한 계약의 정확도 메타가 응답에 실리는지 확인 (성능 개선 유지)
*
* 회귀를 고치면서 표준 집계로 되돌리면 이 필드가 사라진다. 오류가 없어졌다는 것만으로는
* 개선이 유지됐다고 말할 수 없다.
*/
public function test_response_carries_total_accuracy_meta(): void
{
$admin = $this->adminWithNotificationRead();
$this->seedNotifications($admin, 3);
$response = $this->actingAs($admin, 'sanctum')
->getJson('/api/admin/notifications?per_page=2');
$response->assertOk();
$pagination = $response->json('data.pagination');
$this->assertIsArray($pagination, '페이지네이션 메타가 없다');
$this->assertArrayHasKey('total_relation', $pagination, '상한 계약의 정확도 메타가 사라졌다');
$this->assertArrayHasKey('total_is_exact', $pagination);
$this->assertArrayHasKey('result_cap', $pagination);
$this->assertTrue($pagination['has_more_pages']);
}
}
@@ -0,0 +1,194 @@
<?php
namespace Tests\Feature\Performance;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Enums\TotalRelation;
use App\Models\ActivityLog;
use App\Models\User;
use App\Support\Query\BoundedPage;
use App\Support\Query\KeysetPaginator;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\Paginator;
use Tests\TestCase;
/**
* 지연 조인 목록의 상한 계약 + 커서 전환 테스트
*
* 검색 밖에서도 같은 계약이 성립하는지 확인한다. 관리자 로그 목록은 계속 쌓이기만
* 하는 대표적인 대용량 목록이며, 계약이 검색 전용이면 재사용됐다고 말할 수 없다.
*/
class DeferredJoinBoundedTotalTest extends TestCase
{
use RefreshDatabase;
/**
* 현재 페이지 번호를 고정합니다.
*
* 저장소는 요청에서 페이지를 해석하므로(`Paginator::resolveCurrentPage`), 테스트에서
* 페이지를 바꾸려면 해석기를 갈아 끼워야 한다. 필터 배열의 `page` 는 읽지 않는다.
*
* @param int $page 고정할 페이지 번호
*/
private function onPage(int $page): void
{
Paginator::currentPageResolver(static fn () => $page);
}
/**
* 테스트 종료 시 페이지 해석기를 되돌립니다.
*/
protected function tearDown(): void
{
Paginator::currentPageResolver(static fn () => 1);
parent::tearDown();
}
/**
* 활동 로그를 원하는 건수만큼 만듭니다.
*
* @param int $count 생성할 건수
*/
private function seedLogs(int $count): void
{
$user = User::factory()->create();
for ($i = 0; $i < $count; $i++) {
ActivityLog::create([
'log_type' => 'admin',
'action' => 'test.action',
'user_id' => $user->id,
'description_key' => 'test',
'ip_address' => '127.0.0.1',
]);
}
}
/**
* 상한을 넘긴 목록이 BoundedPage 로 응답하며 마지막 페이지를 감추는지 확인
*/
public function test_deferred_join_returns_bounded_page_when_over_cap(): void
{
$this->seedLogs(9);
config(['g7_settings.core.pagination.result_cap' => 4]);
$page = app(ActivityLogRepositoryInterface::class)->getPaginated(['per_page' => 2]);
$this->assertInstanceOf(BoundedPage::class, $page);
$this->assertSame(4, $page->total());
$this->assertSame(TotalRelation::AtLeast, $page->totalRelation());
// 총 건수를 모르면 마지막 페이지 번호는 계산할 수 없다
$this->assertNull($page->lastPage());
// 그래도 "다음" 이동은 열려 있어야 한다
$this->assertTrue($page->hasMorePages());
$this->assertCount(2, $page->items());
}
/**
* 상한 이하면 정확한 총 건수와 마지막 페이지가 그대로 나오는지 확인
*/
public function test_deferred_join_stays_exact_under_cap(): void
{
$this->seedLogs(5);
config(['g7_settings.core.pagination.result_cap' => 100]);
$page = app(ActivityLogRepositoryInterface::class)->getPaginated(['per_page' => 2]);
$this->assertSame(5, $page->total());
$this->assertSame(3, $page->lastPage());
$this->assertTrue($page->hasMorePages());
}
/**
* 마지막 페이지에서는 "다음" 이 닫히는지 확인
*
* per_page + 1 실측이 경계에서 어긋나면 마지막 페이지에서도 다음 버튼이 남는다.
*/
public function test_last_page_closes_next_navigation(): void
{
$this->seedLogs(5);
config(['g7_settings.core.pagination.result_cap' => 3]);
$this->onPage(3);
$page = app(ActivityLogRepositoryInterface::class)->getPaginated(['per_page' => 2]);
$this->assertCount(1, $page->items());
$this->assertFalse($page->hasMorePages());
}
/**
* 깊은 페이지에서도 offset 이 밀리지 않는지 확인
*
* offset 을 per_page + 1 배수로 계산하면 페이지가 깊어질수록 경계가 밀려
* 뒤쪽 페이지에서 행이 통째로 사라진다.
*/
public function test_deep_page_offset_does_not_drift(): void
{
$this->seedLogs(10);
config(['g7_settings.core.pagination.result_cap' => 4]);
$repository = app(ActivityLogRepositoryInterface::class);
$seen = [];
for ($p = 1; $p <= 5; $p++) {
$this->onPage($p);
foreach ($repository->getPaginated(['per_page' => 2])->items() as $row) {
$seen[] = $row->id;
}
}
$this->assertCount(10, $seen);
$this->assertSame(count($seen), count(array_unique($seen)));
}
/**
* 커서를 주면 키셋 방식으로 응답하는지 확인
*/
public function test_cursor_request_switches_to_keyset(): void
{
$this->seedLogs(6);
$repository = app(ActivityLogRepositoryInterface::class);
// 첫 페이지는 페이지 번호 방식 — 여기서 다음 커서를 얻을 수 없으므로
// 커서 모드 첫 진입은 빈 문자열이 아닌 "임의의 유효하지 않은 값" 으로 확인한다.
$page = $repository->getPaginated(['per_page' => 2, 'cursor' => 'invalid-cursor']);
$this->assertInstanceOf(CursorPaginator::class, $page);
// 형식이 깨진 커서는 첫 페이지로 되돌린다 (URL 을 손으로 고쳤다고 오류를 띄우지 않는다)
$this->assertCount(2, $page->items());
}
/**
* 커서로 끝까지 훑으면 모든 행이 정확히 한 번씩 나오는지 확인
*/
public function test_cursor_round_trip_covers_every_row_once(): void
{
$this->seedLogs(7);
$repository = app(ActivityLogRepositoryInterface::class);
$seen = [];
$cursor = 'invalid-cursor'; // 첫 진입 (첫 페이지로 해석된다)
for ($i = 0; $i < 10; $i++) {
/** @var CursorPaginator $page */
$page = $repository->getPaginated(['per_page' => 2, 'cursor' => $cursor]);
foreach ($page->items() as $row) {
$seen[] = $row->id;
}
$next = KeysetPaginator::nextCursor($page);
if ($next === null) {
break;
}
$cursor = $next;
}
$this->assertCount(7, $seen);
$this->assertSame(count($seen), count(array_unique($seen)));
}
}
@@ -0,0 +1,221 @@
<?php
namespace Tests\Feature\Performance;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\LayoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Concerns\CountsQueries;
use Tests\TestCase;
/**
* 목록·권한 조회의 쿼리 수 회귀 테스트
*
* 특정 시점의 쿼리 수를 숫자로 박아 두면 관계 하나만 추가해도 깨져 유지되지 않는다.
* 대신 **행 수를 늘려도 쿼리 수가 늘지 않는다** 를 단언한다 — 그것이 N+1 의 정의이고,
* 정상적인 구조 변경에는 반응하지 않는다.
*/
class ListQueryCountRegressionTest extends TestCase
{
use CountsQueries;
use RefreshDatabase;
/**
* 관리자 회원 목록: 회원 수가 늘어도 쿼리 수가 늘지 않는지 확인
*/
public function test_admin_user_list_query_count_is_stable(): void
{
User::factory()->count(5)->create();
$this->assertQueryCountStableAsDataGrows(
measure: fn () => User::query()->with('roles')->paginate(50, ['id', 'uuid', 'name', 'email', 'status', 'created_at']),
grow: fn () => User::factory()->count(10)->create(),
context: '관리자 회원 목록',
);
}
/**
* 권한 판정: 판정 횟수가 늘어도 쿼리 수가 늘지 않는지 확인
*
* 레이아웃 노드 필터링은 노드마다 권한을 묻는다. 판정마다 DB 를 보면
* 노드 수에 비례해 쿼리가 늘어난다.
*/
public function test_permission_checks_do_not_grow_with_check_count(): void
{
$user = $this->userWithPermissions(['a.read', 'b.read', 'c.read']);
$tenChecks = $this->countQueries(function () use ($user) {
for ($i = 0; $i < 10; $i++) {
$user->hasPermission('a.read');
$user->hasPermission('b.read');
$user->hasPermission('missing.read');
}
});
$freshUser = User::find($user->id);
$hundredChecks = $this->countQueries(function () use ($freshUser) {
for ($i = 0; $i < 100; $i++) {
$freshUser->hasPermission('a.read');
$freshUser->hasPermission('b.read');
$freshUser->hasPermission('missing.read');
}
});
$this->assertLessThanOrEqual(
$tenChecks,
$hundredChecks,
"판정 횟수를 10배로 늘렸더니 쿼리가 {$tenChecks} → {$hundredChecks} 로 늘었다 — 권한 집합이 재사용되지 않는다"
);
}
/**
* 관리자 판정도 같은 권한 집합을 재사용하는지 확인
*/
public function test_is_admin_reuses_permission_set(): void
{
$user = $this->userWithPermissions(['x.read']);
$queries = $this->countQueries(function () use ($user) {
$user->hasPermission('x.read');
$user->isAdmin();
$user->hasPermissions(['x.read', 'y.read'], requireAll: false);
$user->getEffectiveScopeForPermission('x.read');
});
// 집합 적재는 역할 조회 + 권한 eager load 2회로 끝난다.
// 판정 종류가 늘어도 이 2회를 넘지 않아야 한다.
$this->assertLessThanOrEqual(
2,
$queries,
"권한 판정 4종이 쿼리를 {$queries}회 실행했다 — 한 번 적재한 집합을 공유해야 한다"
);
}
/**
* 레이아웃 컴포넌트 트리 권한 필터링이 노드 수와 무관한지 확인
*/
public function test_layout_permission_filter_does_not_grow_with_node_count(): void
{
$user = $this->userWithPermissions(['layout.read']);
$service = app(LayoutService::class);
$smallTree = $this->componentTree(5);
$largeTree = $this->componentTree(50);
$smallQueries = $this->countQueries(fn () => $this->filterTree($service, $smallTree, $user));
$freshUser = User::find($user->id);
$largeQueries = $this->countQueries(fn () => $this->filterTree($service, $largeTree, $freshUser));
$this->assertLessThanOrEqual(
$smallQueries,
$largeQueries,
"노드를 10배로 늘렸더니 쿼리가 {$smallQueries} → {$largeQueries} 로 늘었다 — 노드마다 권한을 다시 묻고 있다"
);
}
/**
* 통합검색: 매칭 수가 늘어도 쿼리 수가 늘지 않는지 확인
*
* 통합검색은 각 모듈 리스너로 팬아웃하는 구조라, 어느 한 리스너가 결과 항목마다
* 조회를 내면 그 자리에서 쿼리가 매칭 수에 비례한다. 응답을 통째로 재야 드러난다.
*
* 활성 검색 모듈이 하나도 없는 환경에서도 이 단언은 유효하다 — 그 경우 코어의
* 응답 조립만 재게 되며, 리스너가 붙는 순간부터 그 리스너가 판정 대상이 된다.
*/
public function test_unified_search_query_count_is_stable(): void
{
$this->seedSearchableUsers(5, 'search-a');
$this->assertQueryCountStableAsDataGrows(
measure: function () {
$response = $this->getJson('/api/search?q=g7search');
$response->assertOk();
},
grow: fn () => $this->seedSearchableUsers(10, 'search-b'),
context: '통합검색',
);
}
/**
* 검색 대상이 될 만한 데이터를 만듭니다.
*
* 어떤 모듈이 설치돼 있든 모수가 늘어나도록 코어 테이블(users)을 늘린다.
*
* @param int $count 생성할 수
* @param string $prefix 이름 접두
*/
private function seedSearchableUsers(int $count, string $prefix): void
{
User::factory()->count($count)->create([
'name' => $prefix.' g7search',
]);
}
/**
* 권한을 가진 사용자를 만듭니다.
*
* @param array<int, string> $identifiers 권한 식별자 목록
* @return User 생성된 사용자
*/
private function userWithPermissions(array $identifiers): User
{
$role = Role::factory()->create();
foreach ($identifiers as $identifier) {
$permission = Permission::factory()->create([
'identifier' => $identifier,
'type' => 'user',
]);
$role->permissions()->syncWithoutDetaching([$permission->id]);
}
$user = User::factory()->create();
$user->roles()->syncWithoutDetaching([$role->id]);
return User::find($user->id);
}
/**
* 권한 조건이 붙은 컴포넌트 트리를 만듭니다.
*
* @param int $nodeCount 노드 수
* @return array<int, array<string, mixed>> 컴포넌트 배열
*/
private function componentTree(int $nodeCount): array
{
$components = [];
for ($i = 0; $i < $nodeCount; $i++) {
$components[] = [
'type' => 'basic',
'name' => 'Div',
'permissions' => ['layout.read'],
'children' => [
['type' => 'basic', 'name' => 'Span', 'permissions' => ['layout.read']],
],
];
}
return $components;
}
/**
* 컴포넌트 트리에 권한 필터를 적용합니다.
*
* @param LayoutService $service 레이아웃 서비스
* @param array<int, array<string, mixed>> $components 컴포넌트 배열
* @param User $user 판정 대상 사용자
* @return array<string, mixed> 필터링 결과
*/
private function filterTree(LayoutService $service, array $components, User $user): array
{
$reflection = new \ReflectionMethod($service, 'filterComponentTree');
$reflection->setAccessible(true);
return ['components' => $reflection->invoke($service, $components, $user)];
}
}
@@ -0,0 +1,152 @@
<?php
namespace Tests\Feature\Search;
use App\Enums\TotalRelation;
use App\Models\User;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 검색·목록 총 건수 정확도 계약 테스트
*
* 화면이 "1,234건" 과 "10,000건 이상" 을 구분해 말할 수 있으려면, 서버가 그 구분을
* 응답에 실어 보내야 한다. 세지 않은 값을 정확한 것처럼 말하지 않는다.
*
* @scenario pagination-accuracy-contract
*
* @effects exact_total_reports_exact,
* bounded_total_reports_at_least,
* bounded_total_hides_last_page,
* bounded_total_keeps_next,
* page_limit_rejects_abusive_page,
* search_message_switches_on_accuracy
*/
class SearchAccuracyContractTest extends TestCase
{
use RefreshDatabase;
/**
* 상한 이하면 총 건수가 정확하고 마지막 페이지가 계산되는지 확인
*
* @effects exact_total_reports_exact
*/
public function test_exact_total_reports_exact_and_last_page(): void
{
User::factory()->count(7)->create();
$page = BoundedPaginator::paginate(User::query(), perPage: 3, page: 1, resultCap: 100);
$this->assertSame(TotalRelation::Exact, $page->totalRelation());
$this->assertTrue($page->totalRelation()->isExact());
$this->assertSame(User::query()->count(), $page->total());
$this->assertNotNull($page->lastPage(), '정확한 총 건수에서는 마지막 페이지가 계산된다');
}
/**
* 상한 초과 시 하한으로 보고하고 마지막 페이지를 비우는지 확인
*
* @effects bounded_total_reports_at_least, bounded_total_hides_last_page
*/
public function test_bounded_total_reports_at_least_and_hides_last_page(): void
{
User::factory()->count(12)->create();
$page = BoundedPaginator::paginate(User::query(), perPage: 3, page: 1, resultCap: 5);
$this->assertSame(TotalRelation::AtLeast, $page->totalRelation());
$this->assertFalse($page->totalRelation()->isExact());
$this->assertSame(5, $page->total(), '상한을 넘으면 상한값을 하한으로 보고한다');
$this->assertNull($page->lastPage(), '마지막 페이지는 계산할 수 없다');
}
/**
* 상한을 넘겨도 "다음" 이동이 끝까지 열려 있는지 확인
*
* @effects bounded_total_keeps_next
*/
public function test_bounded_total_keeps_next_navigation_open(): void
{
User::factory()->count(12)->create();
$reached = [];
for ($pageNumber = 1; $pageNumber <= 5; $pageNumber++) {
$page = BoundedPaginator::paginate(User::query(), perPage: 3, page: $pageNumber, resultCap: 5);
foreach ($page->items() as $user) {
$reached[] = $user->id;
}
if (! $page->hasMorePages()) {
break;
}
}
$this->assertSame(
User::query()->orderBy('id')->pluck('id')->all(),
collect($reached)->sort()->values()->all(),
'상한과 무관하게 마지막 행까지 도달할 수 있어야 한다'
);
}
/**
* 통합검색 응답이 정확도 메타를 함께 내보내는지 확인
*
* @effects search_message_switches_on_accuracy
*/
public function test_search_response_carries_accuracy_meta(): void
{
$response = $this->getJson('/api/search?'.http_build_query(['q' => '검색어테스트']));
$response->assertStatus(200);
$data = $response->json('data');
$this->assertArrayHasKey('total_relation', $data, '정확도를 응답에 실어야 화면이 문구를 고를 수 있다');
$this->assertArrayHasKey('total_is_exact', $data);
$this->assertArrayHasKey('result_cap', $data);
$this->assertSame(TotalRelation::Exact->value, $data['total_relation']);
$this->assertTrue($data['total_is_exact']);
}
/**
* 남용 방지용 페이지 상한이 적용되는지 확인
*
* 정상 탐색은 has_more_pages 로 열려 있고, 상한은 임의의 큰 페이지 번호를 직접
* 던져 초대형 OFFSET 을 만드는 것만 막는다.
*
* @effects page_limit_rejects_abusive_page
*/
public function test_page_number_upper_bound_is_enforced(): void
{
config(['g7_settings.core.pagination.max_page' => 50]);
$maxPage = PaginationLimits::maxPage('search');
$this->assertSame(50, $maxPage);
$response = $this->getJson('/api/search?'.http_build_query(['q' => '검색어테스트', 'page' => 999999]));
$response->assertStatus(422);
$response->assertJsonValidationErrors(['page']);
}
/**
* 상한이 0 이면 무제한으로 해석되는지 확인
*/
public function test_zero_cap_means_unlimited(): void
{
config(['g7_settings.core.pagination.result_cap' => 0]);
$this->assertNull(PaginationLimits::resultCap('search'));
User::factory()->count(4)->create();
[$total, $relation] = BoundedPaginator::countWithCap(User::query(), PaginationLimits::resultCap('search'));
$this->assertSame(User::query()->count(), $total);
$this->assertSame(TotalRelation::Exact, $relation);
}
}
@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Search;
use App\Enums\TotalRelation;
use App\Extension\HookManager;
use Tests\TestCase;
/**
* 검색 탭 배지의 정확도 전달 계약 테스트 (#519)
*
* 배지는 숫자 하나만 그리므로, 상한에 걸려 잘린 값이 정확한 것처럼 나가도 오류로 드러나지
* 않고 그냥 틀린 숫자로만 보인다. 카테고리마다 정확도가 응답에 실리는지 고정한다.
*
* 모듈이 각자 자기 정확도 키를 내보내던 시절에는 어떤 배지는 받고 어떤 배지는 못 받았다.
* 코어가 일괄로 붙인다는 성질을 여기서 잠근다.
*
* @scenario case=search_badge_accuracy
*
* @effects badge_accuracy_is_emitted_per_category,
* badge_accuracy_defaults_to_exact
*/
class SearchBadgeAccuracyTest extends TestCase
{
protected function tearDown(): void
{
HookManager::clearFilter('core.search.results');
parent::tearDown();
}
/**
* 카테고리마다 정확도가 응답에 실리는지 확인
*
* @effects badge_accuracy_is_emitted_per_category
*/
public function test_accuracy_is_emitted_for_every_category(): void
{
// Given: 한 카테고리는 정확하고 다른 하나는 상한에 걸린 검색 결과
HookManager::addFilter('core.search.results', function (array $results): array {
$results['alpha'] = [
'total' => 3,
'total_relation' => TotalRelation::Exact->value,
'total_is_exact' => true,
'items' => [],
];
$results['beta'] = [
'total' => 10000,
'total_relation' => TotalRelation::AtLeast->value,
'total_is_exact' => false,
'items' => [],
];
return $results;
}, 5);
// When: 통합 검색을 호출
$response = $this->getJson('/api/search?q=테스트');
// Then: 카테고리별 정확도가 각각 실린다
$response->assertOk();
$response->assertJsonPath('data.counts_are_exact.alpha', true);
$response->assertJsonPath('data.counts_are_exact.beta', false);
}
/**
* 정확도를 싣지 않은 카테고리는 정확한 것으로 보되 키는 존재하는지 확인
*
* 키 자체가 없으면 화면이 "정확도 정보 없음" 과 "정확함" 을 구분하지 못한다.
*
* @effects badge_accuracy_defaults_to_exact
*/
public function test_category_without_accuracy_defaults_to_exact(): void
{
// Given: 정확도를 싣지 않은 카테고리
HookManager::addFilter('core.search.results', function (array $results): array {
$results['legacy'] = ['total' => 7, 'items' => []];
return $results;
}, 5);
// When: 통합 검색을 호출
$response = $this->getJson('/api/search?q=테스트');
// Then: 키는 존재하고 값은 정확으로 해석된다
$response->assertOk();
$response->assertJsonPath('data.counts_are_exact.legacy', true);
}
}
@@ -0,0 +1,270 @@
/**
* E2E: 총 건수 상한 표기 계약 + 쇼핑 첫 화면 단일 요청 (#519)
*
* 두 변경 모두 브라우저에서만 드러나는 성질을 갖는다.
*
* (1) 총 건수가 상한을 넘으면 서버가 `last_page: null` 을 보낸다. 화면이 그 값을
* 숫자로 잘못 해석하면 페이저가 통째로 사라지거나 "1 / 1" 로 굳는다 —
* 응답은 정상이고 콘솔 에러도 없어 API 테스트로는 드러나지 않는다.
* (2) 쇼핑 첫 화면은 다섯 묶음을 한 번에 받도록 바꿨다. 레이아웃 바인딩을 한 곳이라도
* 옛 경로에 남겨 두면 그 영역만 조용히 비어 렌더된다.
*
* @scenario case=bounded_total_and_single_storefront_request
*
* @effects search_pager_survives_null_last_page,
* search_count_marks_inexact_total,
* storefront_uses_single_request,
* storefront_sections_render
*/
import type { Page } from '@playwright/test';
import { test, expect } from '../../fixtures/auth';
/**
* 화면 진입 공통 대기.
*
* `networkidle` 은 폴링이 있는 화면에서 idle 이 되지 않으므로 쓰지 않는다.
*
* @param page 대상 페이지
* @param path 이동할 경로
* @returns void
*/
async function gotoAndSettle(page: Page, path: string): Promise<void> {
await page.goto(path);
await page.waitForLoadState('domcontentloaded');
await acceptCookieConsent(page);
await page.waitForTimeout(1200);
}
/**
* 쿠키 동의 배너를 처리한다.
*
* GDPR 사전 차단(preblocker)이 동의 전까지 데이터 요청을 막으므로, 배너를 남겨 둔 채로는
* "화면이 어떤 API 를 부르는가" 를 관찰할 수 없다. 배너가 없는 사이트에서는 아무 일도 하지 않는다.
*
* @param page 대상 페이지
* @returns void
*/
async function acceptCookieConsent(page: Page): Promise<void> {
const accept = page.getByRole('button', { name: /모두 동의|Accept all/i }).first();
if (await accept.isVisible({ timeout: 3_000 }).catch(() => false)) {
await accept.click();
await page.waitForTimeout(500);
}
}
// 쇼핑 목록 라우트는 '/shop' 이 아니라 '/shop/products' 다 (템플릿 routes.json 기준).
const SHOP_LIST_PATH = '/shop/products';
test.describe('총 건수 상한과 페이지 이동', () => {
// @scenario case=search_response_carries_accuracy_meta
// @effects search_count_marks_inexact_total
test('검색 응답이 총 건수 정확도를 함께 싣는다', async ({ page }) => {
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/search?') && r.status() === 200,
{ timeout: 20_000 },
);
await gotoAndSettle(page, '/search?q=' + encodeURIComponent('테스트'));
const body = await (await responsePromise).json();
const data = body?.data;
expect(data, '검색 응답에 data 가 없다').toBeTruthy();
expect(data).toHaveProperty('total_is_exact');
expect(data).toHaveProperty('total_relation');
// 정확도가 true 면 종전 표기 그대로, false 면 "이상" 표기가 붙어야 한다.
// 표기 문구는 로케일에 따라 다르므로 둘 중 하나가 화면에 있는지로 판정한다.
// 본문 전체를 대상으로 하므로 앵커(`$`)를 쓰지 않는다 — 뒤에 다른 문구가 따라온다.
const suffix = data.total_is_exact
? /\d+\s*건|\d+\s*results/i
: /건 이상|\+ results|more than/i;
await expect(page.locator('body')).toContainText(suffix, { timeout: 15_000 });
});
// @scenario case=search_pager_survives_null_last_page
// @effects search_pager_survives_null_last_page
test('마지막 페이지를 모르는 목록에서도 페이저가 사라지지 않는다', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
// 상한 초과 상태는 실데이터로 재현하기 어려우므로 응답을 가로채 주입한다.
// last_page: null + has_more_pages: true 가 이 시나리오의 핵심 입력이다.
await page.route('**/api/search?**', async (route) => {
const response = await route.fetch();
const body = await response.json();
if (body?.data) {
body.data.last_page = null;
body.data.has_more_pages = true;
body.data.total_relation = 'at_least';
body.data.total_is_exact = false;
}
await route.fulfill({ response, json: body });
});
await gotoAndSettle(page, '/search?q=' + encodeURIComponent('테스트'));
expect(errors, '검색 화면 렌더 중 자바스크립트 오류가 발생했다').toEqual([]);
// "다음" 이동 수단이 실제로 화면에 남아 있어야 한다. 이것이 사라지면 상한 초과
// 검색에서 2페이지 이후에 도달할 방법이 없어진다.
const nextControl = page
.locator('button:has(i.fa-chevron-right), a:has(i.fa-chevron-right)')
.first();
await expect(nextControl, '마지막 페이지를 모른다는 이유로 "다음" 이동이 사라졌다')
.toBeVisible({ timeout: 15_000 });
});
});
test.describe('쇼핑 첫 화면 단일 요청', () => {
// @scenario case=storefront_uses_single_request
// @effects storefront_uses_single_request
test('분류·상품·진열 묶음을 한 번의 요청으로 받는다', async ({ page }) => {
const productApiCalls: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (/\/api\/modules\/sirsoft-ecommerce\/(products|categories|storefront)/.test(url)) {
productApiCalls.push(url);
}
});
const storefrontPromise = page.waitForResponse(
(r) => r.url().includes('/api/modules/sirsoft-ecommerce/storefront') && r.status() === 200,
{ timeout: 20_000 },
);
await gotoAndSettle(page, SHOP_LIST_PATH);
const body = await (await storefrontPromise).json();
const data = body?.data;
expect(data, 'storefront 응답에 data 가 없다').toBeTruthy();
for (const key of ['categories', 'products', 'recent_products', 'popular_products', 'new_products']) {
expect(data, `storefront 응답에 ${key} 묶음이 없다`).toHaveProperty(key);
}
// 개별 엔드포인트를 함께 부르고 있으면 통합의 의미가 없다.
const legacyCalls = productApiCalls.filter(
(url) => /\/(products\/(popular|new|recent)|categories)(\?|$)/.test(url),
);
expect(legacyCalls, `쇼핑 첫 화면이 개별 엔드포인트를 여전히 호출한다: ${legacyCalls.join(', ')}`).toEqual([]);
});
// @scenario case=storefront_sections_render
// @effects storefront_sections_render
test('첫 화면의 각 영역이 비어 있지 않게 렌더된다', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
const storefrontPromise = page.waitForResponse(
(r) => r.url().includes('/api/modules/sirsoft-ecommerce/storefront') && r.status() === 200,
{ timeout: 20_000 },
);
await gotoAndSettle(page, SHOP_LIST_PATH);
expect(errors, '쇼핑 첫 화면 렌더 중 자바스크립트 오류가 발생했다').toEqual([]);
await expect(page.locator('h1')).toBeVisible({ timeout: 15_000 });
// 통합 응답이 실제로 담아 온 묶음은 화면에도 나타나야 한다. 바인딩 경로가
// 하나라도 옛 이름에 남아 있으면 그 영역만 조용히 비므로, 응답에 항목이 있는
// 묶음에 한해 그 이름이 화면에 렌더됐는지 본다 (빈 사이트에서는 건너뛴다).
const data = (await (await storefrontPromise).json())?.data;
const sections: Array<[string, unknown]> = [
['recent_products', data?.recent_products],
['popular_products', data?.popular_products],
['new_products', data?.new_products],
];
for (const [key, items] of sections) {
const list = Array.isArray(items) ? items : (items as { data?: unknown[] })?.data;
if (!Array.isArray(list) || list.length === 0) {
continue;
}
const firstName = (list[0] as { name_localized?: string; name?: string })?.name_localized
?? (list[0] as { name?: string })?.name;
if (typeof firstName !== 'string' || firstName.trim() === '') {
continue;
}
await expect(
page.getByText(firstName, { exact: false }).first(),
`${key} 묶음이 응답에는 있는데 화면에 렌더되지 않았다 — 바인딩 경로가 어긋났다`,
).toBeVisible({ timeout: 15_000 });
}
});
});
test.describe('공유 partial 을 쓰는 다른 화면 (#519 회귀)', () => {
// 쇼핑 첫 화면을 단일 요청으로 통합하면서, 같은 partial 을 쓰는 분류 화면과 상품
// 상세의 인기 상품 영역이 조용히 비었던 회귀다. `?? []` 폴백 때문에 예외도 404 도
// 나지 않아 브라우저에서 눈으로 보는 것 말고는 드러나지 않는다.
// @scenario case=shared_partial_reads_parent_declared_names
// @effects shared_partial_reads_parent_declared_names
test('분류 화면이 상품 그리드 데이터를 실제로 받는다', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
const listRequests: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (/\/api\/modules\/sirsoft-ecommerce\/(products|storefront)(\?|$|\/)/.test(url)) {
listRequests.push(url);
}
});
// 분류 슬러그가 없는 사이트에서는 목록 화면으로 대체 확인한다
await gotoAndSettle(page, '/shop/products');
expect(errors, '분류/목록 화면 렌더 중 자바스크립트 오류가 발생했다').toEqual([]);
// 이 화면은 공유 partial 로 상품 그리드를 그린다. partial 이 부모가 선언한 이름을
// 읽지 못하면 데이터 요청 자체가 나가지 않고 그리드만 조용히 빈다 — 요청이 실제로
// 있었는지가 그 회귀를 잡는 신호다.
expect(
listRequests,
'상품 목록/통합 응답 요청이 한 번도 나가지 않았다 — 공유 partial 이 부모가 선언한 데이터소스 이름을 읽지 못한다',
).not.toEqual([]);
await expect(page.locator('h1')).toBeVisible({ timeout: 15_000 });
});
// @scenario case=storefront_grid_pager_uses_has_more_pages
// @effects storefront_grid_pager_uses_has_more_pages
test('상품 그리드 페이저가 마지막 페이지를 몰라도 접히지 않는다', async ({ page }) => {
await page.route('**/api/modules/sirsoft-ecommerce/storefront**', async (route) => {
const response = await route.fetch();
const body = await response.json().catch(() => null);
if (!body?.data?.products?.pagination) {
return route.fulfill({ response });
}
// 총 건수를 상한까지만 센 응답을 흉내 낸다 — last_page 는 계산할 수 없다
body.data.products.pagination.last_page = null;
body.data.products.pagination.has_more_pages = true;
body.data.products.pagination.total_relation = 'at_least';
body.data.products.pagination.total_is_exact = false;
return route.fulfill({ response, body: JSON.stringify(body) });
});
await gotoAndSettle(page, SHOP_LIST_PATH);
// last_page 가 null 이라고 페이저가 통째로 사라지면 1페이지 밖 상품에 도달할 방법이 없다
const nextButton = page.locator('button:has(i.fa-chevron-right)').last();
await expect(nextButton).toBeVisible({ timeout: 15_000 });
await expect(nextButton).toBeEnabled();
});
});
@@ -0,0 +1,113 @@
<?php
namespace Tests\Unit\Http\Resources;
use App\Enums\TotalRelation;
use App\Http\Resources\Traits\HasRowNumber;
use App\Support\Query\BoundedPage;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Tests\TestCase;
/**
* 순번이 상한 총 건수로 역산되어 0·음수로 내려가는 회귀 테스트 (#519)
*
* 내림차순 순번은 "전체 몇 건 중 몇 번째" 라 총 건수를 알아야 나온다. 총 건수가 상한에
* 걸려 잘리면 그 값으로 역산한 순번은 첫 페이지부터 이미 틀리고, 상한을 넘어선 페이지에서는
* 0 과 음수까지 내려간다. 활동 로그(실측 88,792 건 / 상한 10,000)가 이 경로를 탄다.
*
* 틀린 숫자를 내보내는 것보다 내보내지 않는 편이 낫다. `last_page` 를 모를 때 null 을
* 내보내는 것과 같은 원칙으로, 잘린 총 건수에서는 순번을 null 로 둔다.
*
* @scenario case=row_number_bounded_total
*
* @effects row_number_null_when_total_truncated,
* row_number_exact_when_total_exact,
* row_number_ascending_unaffected_by_truncation
*/
class HasRowNumberBoundedTotalTest extends TestCase
{
/**
* 트레이트를 그대로 쓰는 최소 컬렉션을 만듭니다.
*/
private function collectionFor(mixed $paginator): ResourceCollection
{
return new class($paginator) extends ResourceCollection
{
use HasRowNumber;
/**
* 순번만 뽑아냅니다.
*
* @return array<int, mixed> 순번 목록
*/
public function numbers(string $sortOrder): array
{
return $this->mapWithRowNumber(fn ($item) => ['id' => $item], $sortOrder)
->pluck('number')
->all();
}
};
}
/**
* 상한형 페이지를 만듭니다.
*/
private function boundedPage(int $page, int $perPage, int $total, TotalRelation $relation): BoundedPage
{
return new BoundedPage(
items: new Collection(range(1, $perPage)),
total: $total,
perPage: $perPage,
currentPage: $page,
totalRelation: $relation,
resultCap: 10000,
hasMorePages: true,
);
}
public function test_총건수가_정확하면_순번은_총건수부터_내림차순이다(): void
{
$paginator = new LengthAwarePaginator(new Collection(range(1, 5)), 120, 5, 1);
$this->assertSame(
[120, 119, 118, 117, 116],
$this->collectionFor($paginator)->numbers('desc')
);
}
public function test_총건수가_잘리면_순번은_null_이다(): void
{
$page = $this->boundedPage(1, 5, 10000, TotalRelation::AtLeast);
$this->assertSame(
[null, null, null, null, null],
$this->collectionFor($page)->numbers('desc')
);
}
public function test_상한을_넘은_페이지에서_0이나_음수_순번이_없다(): void
{
foreach ([501, 502, 601] as $pageNumber) {
$page = $this->boundedPage($pageNumber, 20, 10000, TotalRelation::AtLeast);
foreach ($this->collectionFor($page)->numbers('desc') as $number) {
$this->assertFalse(
is_int($number),
"{$pageNumber} 페이지에서 상한 총 건수로 역산한 순번이 그대로 나왔습니다."
);
}
}
}
public function test_오름차순_순번은_총건수가_잘려도_유지된다(): void
{
$page = $this->boundedPage(3, 5, 10000, TotalRelation::AtLeast);
$this->assertSame(
[11, 12, 13, 14, 15],
$this->collectionFor($page)->numbers('asc')
);
}
}
@@ -0,0 +1,181 @@
<?php
namespace Tests\Unit\Layouts;
use Tests\TestCase;
/**
* 상한 목록의 `last_page: null` 을 화면이 접지 않는지 전 영역 회귀 가드 (#519).
*
* 총 건수를 상한까지만 센 목록은 마지막 페이지를 계산할 수 없어 `last_page: null` 을 내보낸다.
* 화면이 그 값을 1 로 채우면 두 가지가 일어난다.
*
* - `?? 1` / `|| 1` → "1페이지뿐" 이라고 잘못 말한다
* - `if: last_page > 1` → 페이저가 통째로 사라져 **뒤쪽 페이지로 갈 방법 자체가 없어진다**
*
* 둘 다 예외도 404 도 내지 않는다. 화면은 정상으로 보이고 기능만 없다.
*
* 템플릿별 가드는 각 템플릿 스위트에 이미 있지만 자기 `layouts/` 디렉토리만 훑는다.
* 모듈·플러그인이 소유한 관리자 레이아웃은 어느 스위트에도 잡히지 않아 33 지점이 남아
* 있었고, 그중 주문·신고·페이지 관리자 목록은 저장소가 실제로 상한을 적용한 목록이었다.
* 이 테스트는 레이아웃을 소유한 **모든** 디렉토리를 훑어 그 사각을 없앤다.
*
* 개별 파일을 열거하지 않는 이유는 다음에 추가되는 레이아웃이 또 빠지기 때문이다.
*/
class PaginationLastPageCollapseTest extends TestCase
{
/**
* `last_page` 를 1 로 채우는 형태
*/
private const COLLAPSE_PATTERN = '/last_page\s*(?:\?\?|\|\|)\s*1(?![0-9])/';
/**
* 레이아웃을 소유하는 디렉토리 전부
*
* @return array<int, string> 절대 경로 목록
*/
private function layoutRoots(): array
{
return [
base_path('resources/layouts'),
base_path('templates/_bundled'),
base_path('modules/_bundled'),
base_path('plugins/_bundled'),
];
}
/**
* 레이아웃 JSON 파일을 모읍니다.
*
* `_bundled` 밑에서는 `layouts/` 경로에 든 것만 본다 (컴포넌트 매니페스트·언어 파일 제외).
*
* @return array<int, string> 파일 경로 목록
*/
private function collectLayoutFiles(): array
{
$files = [];
foreach ($this->layoutRoots() as $root) {
if (! is_dir($root)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'json') {
continue;
}
$path = str_replace('\\', '/', $file->getPathname());
if (! str_contains($path, '/layouts/')) {
continue;
}
if (str_contains($path, '/node_modules/') || str_contains($path, '/dist/')) {
continue;
}
$files[] = $path;
}
}
sort($files);
return $files;
}
/**
* 한 파일의 위반 줄을 찾습니다.
*
* @param string $path 레이아웃 경로
* @return array<int, string> "경로:줄 내용" 목록
*/
private function violationsIn(string $path): array
{
$violations = [];
$lines = explode("\n", (string) file_get_contents($path));
$relative = str_replace(str_replace('\\', '/', base_path()).'/', '', $path);
foreach ($lines as $index => $line) {
if (str_contains($line, 'audit:allow layout-last-page-null-collapse')) {
continue;
}
if (preg_match(self::COLLAPSE_PATTERN, $line) === 1) {
$violations[] = $relative.':'.($index + 1).' '.trim($line);
continue;
}
// 페이저 노출 조건이 last_page 하나에만 의존하는 형태
if (str_contains($line, '"if"')
&& str_contains($line, 'last_page')
&& ! str_contains($line, 'has_more_pages')
&& ! str_contains($line, 'current_page')) {
$violations[] = $relative.':'.($index + 1).' '.trim($line);
}
}
return $violations;
}
/**
* 스캔 모집단이 비어 있지 않아야 한다 — 0 건을 훑고 통과하면 이 가드는 아무것도 지키지 않는다.
*/
public function test_레이아웃_스캔_모집단이_비어있지_않다(): void
{
$files = $this->collectLayoutFiles();
$this->assertGreaterThan(
300,
count($files),
'레이아웃 스캔 대상이 비정상적으로 적다. 경로 규칙이 바뀌었는지 확인해야 한다.'
);
}
/**
* 어떤 레이아웃도 `last_page` 를 1 로 접지 않는다.
*/
public function test_어떤_레이아웃도_last_page를_1로_접지_않는다(): void
{
$violations = [];
foreach ($this->collectLayoutFiles() as $path) {
$violations = array_merge($violations, $this->violationsIn($path));
}
$this->assertSame(
[],
$violations,
"상한 목록의 last_page(null) 를 1 로 접는 레이아웃이 있다:\n".implode("\n", $violations)
);
}
/**
* 합성 표본이 실제로 red 가 되는지 — 판정기가 살아 있음을 확인한다.
*/
public function test_판정기가_합성_위반을_실제로_잡는다(): void
{
$sample = base_path('storage/framework/testing/pagination_collapse_sample.json');
@mkdir(dirname($sample), 0o775, true);
file_put_contents($sample, implode("\n", [
'{',
' "props": {',
' "totalPages": "{{items?.data?.pagination?.last_page ?? 1}}"',
' },',
' "if": "{{items?.data?.pagination?.last_page > 1}}"',
'}',
]));
try {
$this->assertCount(2, $this->violationsIn($sample));
} finally {
@unlink($sample);
}
}
}
@@ -3,6 +3,7 @@
namespace Tests\Unit\Listeners;
use App\Listeners\CoreActivityLogListener;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
@@ -22,6 +23,11 @@ use Tests\TestCase;
*/
class CoreActivityLogListenerSignatureTest extends TestCase
{
// 아래 호출 가능 검증은 핸들러를 실제로 실행하므로 활동 로그가 기록된다.
// 격리가 없으면 그 행이 프로세스 내내 남아, 테이블 전체 건수를 단언하는 다른 테스트를
// 실행 순서에 따라 실패시킨다 (단독 실행에서는 통과해 원인을 찾기 어렵다).
use RefreshDatabase;
public function test_handle_template_after_deactivate_accepts_string_identifier(): void
{
$listener = $this->app->make(CoreActivityLogListener::class);
@@ -389,7 +389,11 @@ class DatabaseFulltextEngineTest extends TestCase
}
/**
* whereFulltext()가 연산자만 입력 시 1=0 조건으로 빈 결과를 생성하는지 테스트합니다.
* whereFulltext()가 연산자만 입력 시 항상 거짓인 조건으로 빈 결과를 만드는지 테스트합니다.
*
* 조건이 어떤 문자열로 렌더되는지(`1 = 0` / `0 = 1`)는 빌더 구현 사항이므로 단언하지
* 않는다. 여기서 고정할 것은 **매칭을 시도하지 않고(AGAINST 없음) 결과가 비어야 한다**
* 는 동작이다. 리터럴을 박아 두면 빈 whereIn 처럼 동등한 구현으로 바꿀 때 깨진다.
*/
public function test_where_fulltext_uses_false_condition_for_operators_only(): void
{
@@ -405,8 +409,16 @@ class DatabaseFulltextEngineTest extends TestCase
DatabaseFulltextEngine::whereFulltext($query, 'name', '< >');
$sql = $query->toSql();
$this->assertStringContainsString('1 = 0', $sql);
// 매칭을 시도하지 않는다
$this->assertStringNotContainsString('AGAINST', $sql);
// 조건이 아예 없으면 전체 행이 나온다 — 조건은 반드시 붙어야 한다
$this->assertStringContainsString('where', strtolower($sql));
// 그 조건은 어떤 행도 통과시키지 않는 상수 거짓이어야 한다.
// `1 = 0`(raw)과 `0 = 1`(빈 whereIn) 은 같은 뜻이므로 둘 다 허용한다.
$this->assertMatchesRegularExpression('/\b(?:1\s*=\s*0|0\s*=\s*1)\b/', $sql);
// 상수 거짓 조건에는 바인딩이 없다 (사용자 입력이 새어 들어가지 않았다는 뜻)
$this->assertSame([], $query->getBindings());
}
/**
+289
View File
@@ -0,0 +1,289 @@
<?php
namespace Tests\Unit\Search;
use App\Extension\HookManager;
use App\Search\Contracts\KeywordPredicateProvider;
use App\Search\DTO\KeywordSearchContext;
use App\Search\Engines\DatabaseFulltextEngine;
use App\Search\KeywordSearch;
use App\Support\Query\PaginationLimits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\EngineManager;
use Laravel\Scout\Engines\Engine;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 키워드 술어 해석기 계약 테스트 (#519)
*
* G7 은 플러그인이 검색 엔진을 등록할 수 있게 설계돼 있다. 그런데 저장소가 구체 엔진의
* 정적 메서드를 직접 부르면 등록된 엔진은 **호출될 기회 자체가 없어진다** — 오류도 경고도
* 없이 그 사이트의 검색만 조용히 다른 방식으로 동작한다.
*
* 여기서는 "활성 엔진이 실제로 호출되는가" 를 고정한다. 조건이 어떤 SQL 로 렌더되는지는
* 엔진의 자유이므로 단언하지 않는다.
*
* @scenario case=keyword_predicate_contract
*
* @effects keyword_predicate_delegates_to_active_engine,
* keyword_predicate_falls_back_when_engine_lacks_contract,
* like_operator_is_declarative_not_hardcoded,
* like_fallback_escapes_wildcards
*/
class KeywordSearchTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
KeywordSearch::forgetFallbackWarnings();
}
protected function tearDown(): void
{
HookManager::clearFilter(KeywordSearch::LIKE_OPERATORS_FILTER);
parent::tearDown();
}
/**
* 검색 대상 모델을 만듭니다.
*/
private function makeQuery(): Builder
{
$model = new class extends Model
{
protected $table = 'keyword_search_probe';
};
return $model->newQuery();
}
/**
* 활성 엔진을 지정한 인스턴스로 교체합니다.
*
* @param Engine $engine 활성으로 만들 엔진
*/
private function useEngine(Engine $engine): void
{
$manager = $this->mock(EngineManager::class);
$manager->shouldReceive('engine')->andReturn($engine);
$this->app->instance(EngineManager::class, $manager);
}
/**
* 계약을 구현한 엔진이 실제로 호출되는지 확인
*
* @effects keyword_predicate_delegates_to_active_engine
*/
#[Test]
public function delegates_to_active_engine_that_implements_the_contract(): void
{
$engine = $this->makeRecordingEngine();
$this->useEngine($engine);
$query = $this->makeQuery();
KeywordSearch::apply($query, ['title', 'content'], '검색어');
$this->assertSame(
[[['title', 'content'], '검색어', 'and']],
$engine->calls,
'활성 엔진이 호출되지 않았다 — 플러그인 엔진이 우회된다.'
);
$this->assertStringNotContainsString('like', strtolower($query->toSql()), '엔진이 만든 조건 대신 폴백이 적용됐다.');
}
/**
* 호출 내역을 기록하는 계약 구현 엔진을 만듭니다.
*/
private function makeRecordingEngine(): Engine
{
return new class extends Engine implements KeywordPredicateProvider
{
public array $calls = [];
public ?KeywordSearchContext $lastContext = null;
public function applyKeywordPredicate(Builder $query, array $columns, string $keyword, string $boolean, KeywordSearchContext $context): void
{
$this->calls[] = [$columns, $keyword, $boolean];
$this->lastContext = $context;
$query->whereIn($query->getModel()->getQualifiedKeyName(), [7]);
}
public function update($models) {}
public function delete($models) {}
public function search(\Laravel\Scout\Builder $builder) {}
public function paginate(\Laravel\Scout\Builder $builder, $perPage, $page) {}
public function mapIds($results) {}
public function map(\Laravel\Scout\Builder $builder, $results, $model) {}
public function lazyMap(\Laravel\Scout\Builder $builder, $results, $model) {}
public function getTotalCount($results) {}
public function flush($model) {}
public function createIndex($name, array $options = []) {}
public function deleteIndex($name) {}
};
}
/**
* 엔진에게 키 집합 상한이 전달되는지 확인
*
* 외부 검색 서버를 쓰는 엔진은 자기 서버에서 키 집합을 받아 조건으로 붙인다. 상한을
* 손에 쥐어 주지 않으면 매칭이 큰 검색어에서 그 집합 자체가 메모리 폭발이 된다 —
* 이 프로젝트가 실제로 겪은 결함이다. 규정만으로는 강제할 수 없으므로 **값이 실제로
* 도달하는지**를 고정한다.
*
* @effects keyword_predicate_passes_key_cap_to_engine
*/
#[Test]
public function passes_the_key_cap_to_the_engine(): void
{
$engine = $this->makeRecordingEngine();
$this->useEngine($engine);
$query = $this->makeQuery();
KeywordSearch::apply($query, ['title'], '검색어', 'and', 'search');
$this->assertNotNull($engine->lastContext, '엔진이 술어 생성 조건을 받지 못했다.');
$this->assertSame(
PaginationLimits::resultCap('search'),
$engine->lastContext->keyCap,
'엔진이 받은 키 집합 상한이 목록 총 건수 상한과 다르다 — 두 기준이 갈라지면 "엔진이 돌려준 건수" 와 "화면이 보고하는 총 건수" 가 어긋난다.'
);
}
/**
* 계약 미구현 엔진에서는 부분일치로 내려가는지 확인
*
* @effects keyword_predicate_falls_back_when_engine_lacks_contract
*/
#[Test]
public function falls_back_to_partial_match_when_engine_lacks_the_contract(): void
{
$engine = new class extends Engine
{
public function update($models) {}
public function delete($models) {}
public function search(\Laravel\Scout\Builder $builder) {}
public function paginate(\Laravel\Scout\Builder $builder, $perPage, $page) {}
public function mapIds($results) {}
public function map(\Laravel\Scout\Builder $builder, $results, $model) {}
public function lazyMap(\Laravel\Scout\Builder $builder, $results, $model) {}
public function getTotalCount($results) {}
public function flush($model) {}
public function createIndex($name, array $options = []) {}
public function deleteIndex($name) {}
};
$this->useEngine($engine);
$query = $this->makeQuery();
KeywordSearch::apply($query, ['title'], '검색어');
$this->assertStringContainsString('like', strtolower($query->toSql()));
$this->assertContains('%검색어%', $query->getBindings());
}
/**
* 번들 엔진이 계약을 구현하는지 확인
*
* 구현하지 않으면 MySQL 설치본조차 폴백으로 내려간다.
*
* @effects keyword_predicate_delegates_to_active_engine
*/
#[Test]
public function bundled_engine_implements_the_contract(): void
{
$this->assertInstanceOf(
KeywordPredicateProvider::class,
new DatabaseFulltextEngine,
'번들 엔진이 키워드 술어 계약을 구현하지 않는다.'
);
}
/**
* 부분일치 연산자가 선언형 표에서 오는지 확인
*
* 코드에 드라이버명을 박으면 공식 지원 DBMS 가 늘 때마다 코어를 고쳐야 한다.
* 여기서는 표에 없던 드라이버를 훅으로 선언했을 때 그 연산자가 실제로 쓰이는지 본다.
*
* @effects like_operator_is_declarative_not_hardcoded
*/
#[Test]
public function like_operator_comes_from_the_declarative_table(): void
{
$driver = DB::getDriverName();
HookManager::addFilter(KeywordSearch::LIKE_OPERATORS_FILTER, function (array $operators) use ($driver) {
$operators[$driver] = 'ilike';
return $operators;
}, 10, ['type' => 'filter']);
$query = $this->makeQuery();
KeywordSearch::applyLikeMatch($query, ['title'], '검색어');
// `ilike` 는 문자열로 `like` 를 포함하므로 부분일치 단언은 두 연산자를 구분하지
// 못한다 — 낱말 경계로 정확히 가른다.
$this->assertMatchesRegularExpression('/\bilike\b/i', $query->toSql());
}
/**
* 표에 없는 드라이버는 기본 연산자를 쓰는지 확인
*
* @effects like_operator_is_declarative_not_hardcoded
*/
#[Test]
public function unknown_driver_uses_the_configured_default(): void
{
Config::set('core.search.like_operators', []);
$query = $this->makeQuery();
KeywordSearch::applyLikeMatch($query, ['title'], '검색어');
$sql = $query->toSql();
$this->assertMatchesRegularExpression('/(?<!i)\blike\b/i', $sql);
$this->assertDoesNotMatchRegularExpression('/\bilike\b/i', $sql);
}
/**
* 부분일치가 검색어의 와일드카드를 escape 하는지 확인
*
* escape 하지 않으면 `50%` 검색이 `50` 으로 시작하는 모든 행을 반환한다.
*
* @effects like_fallback_escapes_wildcards
*/
#[Test]
public function partial_match_escapes_wildcards_in_the_keyword(): void
{
$query = $this->makeQuery();
KeywordSearch::applyLikeMatch($query, ['title'], '50%_할인');
$this->assertContains('%50\\%\\_할인%', $query->getBindings());
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace Tests\Unit\Search;
use App\Search\SearchPagePolicy;
use Tests\TestCase;
/**
* 검색 커서 적용 판정 계약 테스트 (#519)
*
* 이 판정을 도메인마다 각자 구현하면 규칙이 검색 모듈 수만큼 갈라진다.
* 코어가 규칙을 소유한다는 것을 여기서 고정한다.
*
* @scenario case=search_cursor_policy
*
* @effects cursor_starts_on_first_page,
* deep_page_without_cursor_stays_on_offset,
* cursor_requires_real_columns,
* unknown_sort_name_falls_back_to_offset
*/
class SearchPagePolicyTest extends TestCase
{
/** 검사에 쓰는 정렬 선언 (도메인이 제공하는 형태) */
private const SORT_MAP = [
'latest' => ['created_at', 'desc'],
'views' => ['view_count', 'desc'],
];
/** 커서 경계로 허용한 실제 컬럼 */
private const CURSOR_COLUMNS = ['created_at', 'view_count'];
/**
* 커서가 없어도 첫 페이지면 커서로 시작하는지 확인
*
* 커서를 받은 요청만 커서로 처리하면 첫 커서가 만들어질 자리가 없다. 서버는 커서
* 모드에서만 다음 커서를 내보내므로, 화면은 건넬 커서가 없어 영원히 offset 에 머문다.
* 첫 페이지는 커서가 없는 것이 정상이므로 이를 "커서 없음" 이 아니라 "시작점" 으로 읽는다.
*
* @effects cursor_starts_on_first_page
*/
public function test_first_page_starts_cursor_mode(): void
{
$sortKeys = SearchPagePolicy::sortKeys('latest', self::SORT_MAP);
$this->assertTrue(SearchPagePolicy::usesCursor(null, $sortKeys, self::CURSOR_COLUMNS));
$this->assertTrue(SearchPagePolicy::usesCursor('', $sortKeys, self::CURSOR_COLUMNS));
$this->assertTrue(SearchPagePolicy::usesCursor(null, $sortKeys, self::CURSOR_COLUMNS, page: 1));
}
/**
* 커서 없이 깊은 페이지를 직접 지목한 요청은 offset 을 유지하는지 확인
*
* 주소로 특정 페이지를 열어 둔 링크(딥링크·북마크)는 그 페이지를 그대로 보여줘야 한다.
* 커서로 바꿔 버리면 첫 페이지로 되돌아가 링크가 가리키던 자리를 잃는다.
*
* @effects deep_page_without_cursor_stays_on_offset
*/
public function test_deep_page_without_cursor_stays_on_offset(): void
{
$sortKeys = SearchPagePolicy::sortKeys('latest', self::SORT_MAP);
$this->assertFalse(SearchPagePolicy::usesCursor(null, $sortKeys, self::CURSOR_COLUMNS, page: 2));
$this->assertFalse(SearchPagePolicy::usesCursor('', $sortKeys, self::CURSOR_COLUMNS, page: 7));
}
/**
* 깊은 페이지라도 커서를 들고 왔으면 커서로 이어가는지 확인
*
* @effects cursor_starts_on_first_page
*/
public function test_cursor_wins_over_page_number(): void
{
$sortKeys = SearchPagePolicy::sortKeys('latest', self::SORT_MAP);
$this->assertTrue(SearchPagePolicy::usesCursor('encoded-cursor', $sortKeys, self::CURSOR_COLUMNS, page: 9));
}
/**
* 실제 컬럼 정렬 + 커서가 있으면 커서로 응답하는지 확인
*
* @effects cursor_requires_real_columns
*/
public function test_real_column_sort_with_cursor_uses_cursor(): void
{
foreach (['latest', 'views'] as $sort) {
$sortKeys = SearchPagePolicy::sortKeys($sort, self::SORT_MAP);
$this->assertTrue(
SearchPagePolicy::usesCursor('encoded-cursor', $sortKeys, self::CURSOR_COLUMNS),
$sort.' 정렬은 실제 컬럼이므로 커서를 쓸 수 있어야 한다'
);
}
}
/**
* 선언에 없는 정렬 이름(관련도순 등)은 offset 을 유지하는지 확인
*
* 관련도순은 FULLTEXT 점수라는 계산값으로 정렬하므로 WHERE 절 경계로 쓸 수 없다.
*
* @effects unknown_sort_name_falls_back_to_offset
*/
public function test_unknown_sort_name_falls_back_to_offset(): void
{
$sortKeys = SearchPagePolicy::sortKeys('relevance', self::SORT_MAP);
$this->assertSame([], $sortKeys);
$this->assertFalse(SearchPagePolicy::usesCursor('encoded-cursor', $sortKeys, self::CURSOR_COLUMNS));
}
/**
* 허용 목록에 없는 컬럼으로 정렬하면 커서를 쓰지 않는지 확인
*
* @effects cursor_requires_real_columns
*/
public function test_column_outside_whitelist_falls_back_to_offset(): void
{
$sortKeys = SearchPagePolicy::sortKeys('score', ['score' => ['_ft_score', 'desc']]);
$this->assertFalse(SearchPagePolicy::usesCursor('encoded-cursor', $sortKeys, self::CURSOR_COLUMNS));
}
}
@@ -0,0 +1,100 @@
<?php
namespace Tests\Unit\Support\Query;
use App\Enums\TotalRelation;
use App\Models\User;
use App\Support\Query\BoundedCount;
use App\Support\Query\BoundedPaginator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* BoundedCount 계약 테스트
*
* 목록을 조회하지 않고 건수만 세는 자리(탭 배지 등)에서, 상한에 걸려 잘린 값이
* 정확한 것처럼 보고되지 않는지 확인한다. 잘린 값은 오류로 드러나지 않고 그냥
* 틀린 숫자로만 보이므로 이 계약이 유일한 방어선이다.
*/
class BoundedCountTest extends TestCase
{
use RefreshDatabase;
/**
* 상한 이하면 정확한 건수와 Exact 정확도를 보고하는지 확인
*/
public function test_reports_exact_when_under_cap(): void
{
User::factory()->count(6)->create();
$count = BoundedPaginator::count(User::query(), 100);
$this->assertInstanceOf(BoundedCount::class, $count);
$this->assertSame(User::query()->count(), $count->total);
$this->assertSame(TotalRelation::Exact, $count->totalRelation());
$this->assertFalse($count->isTruncated());
$this->assertSame(100, $count->resultCap());
}
/**
* 상한을 넘으면 상한값 + AtLeast 를 보고하는지 확인
*/
public function test_reports_at_least_when_over_cap(): void
{
User::factory()->count(9)->create();
$count = BoundedPaginator::count(User::query(), 4);
$this->assertSame(4, $count->total);
$this->assertSame(TotalRelation::AtLeast, $count->totalRelation());
$this->assertTrue($count->isTruncated());
}
/**
* 상한이 없으면(0 또는 null) 항상 정확한 건수를 세는지 확인
*/
public function test_null_cap_means_unlimited(): void
{
User::factory()->count(5)->create();
$count = BoundedPaginator::count(User::query(), null);
$this->assertSame(User::query()->count(), $count->total);
$this->assertFalse($count->isTruncated());
$this->assertNull($count->resultCap());
}
/**
* 응답에 실을 필드 묶음이 한 곳에서 조립되는지 확인
*
* 배지마다 키를 손으로 조립하면 한 군데만 빠져도 그 화면에서만 잘린 값이
* 정확한 것처럼 나간다.
*/
public function test_to_array_carries_every_accuracy_field(): void
{
User::factory()->count(9)->create();
$payload = BoundedPaginator::count(User::query(), 4)->toArray();
$this->assertSame([
'total' => 4,
'total_relation' => 'at_least',
'total_is_exact' => false,
'result_cap' => 4,
], $payload);
}
/**
* 필터가 걸린 쿼리에서도 그 술어 기준으로 세는지 확인
*/
public function test_respects_query_filters(): void
{
User::factory()->count(4)->create(['status' => 'active']);
User::factory()->count(3)->create(['status' => 'inactive']);
$count = BoundedPaginator::count(User::query()->where('status', 'active'), 100);
$this->assertSame(4, $count->total);
$this->assertFalse($count->isTruncated());
}
}
@@ -0,0 +1,333 @@
<?php
namespace Tests\Unit\Support\Query;
use App\Enums\TotalRelation;
use App\Models\User;
use App\Support\Query\BoundedPage;
use App\Support\Query\BoundedPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* BoundedPaginator 계약 테스트
*
* 검색과 무관한 임의 모델(User)로 검증한다. 이 계약은 특정 도메인 지식을 갖지 않으므로,
* 검색 화면을 거치지 않고도 성립해야 한다.
*/
class BoundedPaginatorTest extends TestCase
{
use RefreshDatabase;
/**
* 상한 이하면 총 건수가 정확하고 표준 paginate 와 값이 같은지 확인
*/
public function test_total_is_exact_when_under_cap(): void
{
User::factory()->count(7)->create();
$page = BoundedPaginator::paginate(User::query(), perPage: 3, page: 1, resultCap: 100);
$this->assertSame(User::query()->count(), $page->total());
$this->assertSame(TotalRelation::Exact, $page->totalRelation());
$this->assertFalse($page->isTruncated());
$this->assertSame(100, $page->resultCap());
}
/**
* 상한을 넘으면 총 건수가 하한(AtLeast)으로 보고되는지 확인
*/
public function test_total_is_at_least_when_over_cap(): void
{
User::factory()->count(9)->create();
$page = BoundedPaginator::paginate(User::query(), perPage: 2, page: 1, resultCap: 4);
$this->assertSame(4, $page->total());
$this->assertSame(TotalRelation::AtLeast, $page->totalRelation());
$this->assertTrue($page->isTruncated());
}
/**
* 총 건수가 잘려도 마지막 페이지 번호만 사라지고 "다음" 이동은 유지되는지 확인
*/
public function test_truncated_hides_last_page_but_keeps_next(): void
{
User::factory()->count(9)->create();
$page = BoundedPaginator::paginate(User::query(), perPage: 2, page: 1, resultCap: 4);
$this->assertNull($page->lastPage(), '총 건수가 부정확하면 마지막 페이지를 계산할 수 없다');
$this->assertTrue($page->hasMorePages(), '상한과 무관하게 다음 페이지 이동은 열려 있어야 한다');
}
/**
* 상한을 넘긴 상태에서도 상한 뒤쪽 페이지까지 실제로 이동되는지 확인
*/
public function test_can_navigate_past_the_cap(): void
{
User::factory()->count(9)->create();
// 상한 4 < 실제 9. 5페이지(9~10번째 행)는 상한 바깥이지만 조회되어야 한다.
$page = BoundedPaginator::paginate(User::query(), perPage: 2, page: 5, resultCap: 4);
$this->assertCount(1, $page->items(), '상한 바깥 페이지도 실제 행을 돌려준다');
$this->assertFalse($page->hasMorePages());
}
/**
* 다음 페이지 존재 여부가 per_page + 1 실측으로 정확한지 확인
*/
public function test_has_more_pages_is_probed_exactly(): void
{
User::factory()->count(6)->create();
$notLast = BoundedPaginator::paginate(User::query(), perPage: 3, page: 1, resultCap: 100);
$last = BoundedPaginator::paginate(User::query(), perPage: 3, page: 2, resultCap: 100);
$this->assertTrue($notLast->hasMorePages());
$this->assertFalse($last->hasMorePages());
$this->assertCount(3, $notLast->items(), 'per_page + 1 로 읽어도 per_page 만 돌려준다');
$this->assertCount(3, $last->items());
}
/**
* 마지막 페이지가 정확히 채워졌을 때 다음 페이지가 없다고 판정하는지 확인
*/
public function test_exactly_full_last_page_reports_no_more(): void
{
User::factory()->count(4)->create();
$page = BoundedPaginator::paginate(User::query(), perPage: 2, page: 2, resultCap: 100);
$this->assertFalse($page->hasMorePages());
$this->assertSame(2, $page->lastPage());
}
/**
* GROUP BY 쿼리의 총 건수가 그룹 수인지 확인 (행 수가 아님)
*/
public function test_group_by_counts_groups_not_rows(): void
{
User::factory()->count(3)->create(['status' => 'active']);
User::factory()->count(2)->create(['status' => 'inactive']);
$query = User::query()
->select('status', DB::raw('count(*) as c'))
->groupBy('status');
[$total, $relation] = BoundedPaginator::countWithCap($query, 100);
$this->assertSame(2, $total, 'status 그룹은 2개');
$this->assertSame(TotalRelation::Exact, $relation);
}
/**
* 상한이 null 이면 정확한 전량 COUNT 로 동작하는지 확인
*/
public function test_null_cap_counts_everything_exactly(): void
{
User::factory()->count(5)->create();
[$total, $relation] = BoundedPaginator::countWithCap(User::query(), null);
$this->assertSame(User::query()->count(), $total);
$this->assertSame(TotalRelation::Exact, $relation);
}
/**
* 호출자의 빌더가 페이지 조회로 오염되지 않는지 확인
*/
public function test_caller_builder_is_not_mutated(): void
{
User::factory()->count(5)->create();
$query = User::query();
BoundedPaginator::paginate($query, perPage: 2, page: 1, resultCap: 100);
$this->assertNull($query->toBase()->limit, '페이지 조회가 원본 빌더에 LIMIT 을 남기면 안 된다');
$this->assertSame(5, $query->count());
}
/**
* 쿼리 빌더(`DB::table`)를 그대로 넘겨도 동작하는지 확인
*
* 이 클래스는 "임의의 빌더" 를 받는다고 선언하고 supports() 도 쿼리 빌더를 허용한다.
* 그런데 집계 경로가 Eloquent 전용 메서드를 무조건 부르면, 모델을 거치지 않는 호출이
* 예외로 죽는다. 계약이 연 입구는 실제로 통해야 한다.
*/
public function test_plain_query_builder_is_supported(): void
{
User::factory()->count(5)->create();
$this->assertTrue(BoundedPaginator::supports(DB::table('users')));
[$total, $relation] = BoundedPaginator::countWithCap(DB::table('users'), 100);
$this->assertSame(5, $total);
$this->assertTrue($relation->isExact());
$page = BoundedPaginator::paginate(DB::table('users')->orderBy('id'), perPage: 2, page: 1, resultCap: 100);
$this->assertCount(2, $page->items());
$this->assertSame(5, $page->total());
$this->assertTrue($page->hasMorePages());
}
/**
* 관계(`$model->relation()`)를 그대로 넘겨도 동작하는지 확인
*
* Laravel 에서 `$user->notifications()->paginate()` 는 관계가 빌더로 호출을 넘겨 주기
* 때문에 성립한다. 정적 메서드는 그 전달을 받지 못하므로 관계를 직접 이해해야 한다.
* 이 입구가 막히면 "회원 1명에 종속된 목록" 이라는 가장 흔한 형태가 통째로 500 이 된다.
*/
public function test_eloquent_relation_is_supported(): void
{
$user = User::factory()->create();
foreach (range(1, 3) as $i) {
$user->notifications()->create([
'id' => (string) Str::uuid(),
'type' => 'test',
'data' => ['message' => 'n'.$i],
]);
}
$this->assertTrue(BoundedPaginator::supports($user->notifications()));
$page = BoundedPaginator::paginate($user->notifications(), perPage: 2, page: 1, resultCap: 100);
$this->assertCount(2, $page->items());
$this->assertSame(3, $page->total(), '관계의 소속 조건이 유지되어야 한다');
$this->assertTrue($page->hasMorePages());
$count = BoundedPaginator::count($user->notifications(), 100);
$this->assertSame(3, $count->total());
}
/**
* 관계 입력에서도 상한이 실제로 걸리는지 확인 (회귀 수정이 성능을 되돌리지 않았는가)
*
* 관계를 받아들이게 넓히면서 "그냥 표준 paginate 로 흘려보내기" 로 고치면 타입 오류는
* 사라지지만 상한이 함께 사라진다. 오류가 없어졌다는 것만으로는 이 계약이 지켜졌다고
* 말할 수 없으므로, 관계 경로에서도 잘림이 보고되는지를 따로 못박는다.
*/
public function test_relation_still_honours_the_cap(): void
{
$user = User::factory()->create();
foreach (range(1, 6) as $i) {
$user->notifications()->create([
'id' => (string) Str::uuid(),
'type' => 'test',
'data' => ['message' => 'n'.$i],
]);
}
$page = BoundedPaginator::paginate($user->notifications(), perPage: 2, page: 1, resultCap: 3);
$this->assertSame(3, $page->total(), '상한이 걸리지 않았다 — 관계 경로가 표준 집계로 새고 있다');
$this->assertSame(TotalRelation::AtLeast, $page->totalRelation());
$this->assertNull($page->lastPage(), '잘렸으면 마지막 페이지를 계산할 수 없다');
$this->assertTrue($page->hasMorePages(), '상한과 무관하게 다음 이동은 열려 있어야 한다');
}
/**
* 쿼리 빌더에서도 상한 초과가 "이상" 으로 보고되는지 확인
*/
public function test_plain_query_builder_reports_at_least_over_cap(): void
{
User::factory()->count(5)->create();
[$total, $relation] = BoundedPaginator::countWithCap(DB::table('users'), 2);
$this->assertSame(2, $total);
$this->assertSame(TotalRelation::AtLeast, $relation);
}
/**
* 이 계약의 입력 범위가 표준 `paginate()` 보다 좁지 않은지 확인
*
* 이 계약은 기존 `->paginate()` 호출을 대체하려고 만들었다. 그런데 받는 타입이 표준보다
* 좁으면, 바꾸는 것만으로 멀쩡하던 목록이 죽는다 — 실제로 그렇게 두 번 터졌다
* (쿼리 빌더에서 BadMethodCallException, 관계에서 TypeError).
*
* 그래서 "표준에서 되는 형태는 여기서도 된다" 를 형태별로 고정한다. 새 형태를 지원하게
* 넓힐 때 이 목록에 추가하고, 좁히는 변경은 여기서 걸린다.
*/
public function test_accepts_every_shape_standard_paginate_accepts(): void
{
$user = User::factory()->create();
$user->notifications()->create([
'id' => (string) Str::uuid(),
'type' => 'test',
'data' => ['message' => 'n'],
]);
$shapes = [
'Eloquent 빌더' => User::query(),
'쿼리 빌더' => DB::table('users'),
'관계' => $user->notifications(),
];
foreach ($shapes as $label => $query) {
$this->assertTrue(
BoundedPaginator::supports($query),
"supports() 가 {$label} 를 거부한다 — 표준 paginate 는 받는 형태다"
);
// 실제로 통과하는지까지 본다. supports() 만 true 이고 본체가 죽는 경우가 있었다.
$page = BoundedPaginator::paginate($query, perPage: 1, page: 1, resultCap: 100);
$this->assertInstanceOf(
BoundedPage::class,
$page,
"{$label} 입력이 페이지 결과를 만들지 못했다"
);
$this->assertSame(
TotalRelation::Exact,
$page->totalRelation(),
"{$label} 입력에서 정확도 계약이 깨졌다"
);
}
}
/**
* 정렬이 걸린 빌더도 상한 집계가 성립하는지 확인
*/
public function test_ordered_query_is_countable(): void
{
User::factory()->count(5)->create();
$page = BoundedPaginator::paginate(
User::query()->orderBy('id', 'desc'),
perPage: 2,
page: 1,
resultCap: 3
);
$this->assertSame(3, $page->total());
$this->assertSame(TotalRelation::AtLeast, $page->totalRelation());
}
/**
* BoundedPage 가 표준 페이지네이터 인터페이스를 그대로 만족하는지 확인
*/
public function test_bounded_page_is_a_standard_paginator(): void
{
User::factory()->count(5)->create();
$page = BoundedPaginator::paginate(User::query(), perPage: 2, page: 2, resultCap: 100);
$this->assertInstanceOf(BoundedPage::class, $page);
$this->assertInstanceOf(LengthAwarePaginator::class, $page);
$this->assertSame(2, $page->currentPage());
$this->assertSame(2, $page->perPage());
$this->assertSame(3, $page->firstItem());
$this->assertSame(4, $page->lastItem());
}
}
@@ -0,0 +1,240 @@
<?php
namespace Tests\Unit\Support\Query;
use App\Extension\HookManager;
use App\Http\Resources\BaseApiCollection;
use App\Models\User;
use App\Support\Query\BoundedPaginator;
use App\Support\Query\KeysetPaginator;
use App\Support\Query\PaginationLimits;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Tests\TestCase;
/**
* 페이지네이션 공통 계약 테스트
*
* 응답 메타(BaseApiCollection::paginationMeta)·커서 표준(KeysetPaginator)·한계값 해석
* (PaginationLimits)을 검색과 무관한 임의 모델로 검증한다.
*/
class PaginationContractTest extends TestCase
{
use RefreshDatabase;
/**
* 표준 paginate 를 쓰는 기존 컬렉션의 응답이 이전과 동일한지 확인
*
* 필드가 하나라도 늘거나 줄면 기존 화면의 바인딩이 조용히 어긋난다.
*/
public function test_standard_paginator_meta_is_unchanged(): void
{
User::factory()->count(5)->create();
$meta = $this->metaFor(User::query()->paginate(2, ['*'], 'page', 1));
$this->assertSame(
['current_page', 'per_page', 'from', 'to', 'has_more_pages', 'last_page', 'total'],
array_keys($meta),
'표준 paginate 응답에는 상한형 전용 필드가 붙지 않는다'
);
$this->assertSame(1, $meta['current_page']);
$this->assertSame(2, $meta['per_page']);
$this->assertSame(3, $meta['last_page']);
$this->assertSame(5, $meta['total']);
$this->assertSame(1, $meta['from']);
$this->assertSame(2, $meta['to']);
$this->assertTrue($meta['has_more_pages']);
}
/**
* 상한형 페이지가 정확도 메타를 함께 내보내는지 확인 (상한 이하)
*/
public function test_bounded_meta_reports_exact_total(): void
{
User::factory()->count(5)->create();
$meta = $this->metaFor(BoundedPaginator::paginate(User::query(), 2, 1, 100));
$this->assertSame('exact', $meta['total_relation']);
$this->assertTrue($meta['total_is_exact']);
$this->assertSame(100, $meta['result_cap']);
$this->assertSame(5, $meta['total']);
$this->assertSame(3, $meta['last_page']);
}
/**
* 상한 초과 시 정확도 메타가 하한이고 마지막 페이지가 비는지 확인
*/
public function test_bounded_meta_reports_at_least_and_null_last_page(): void
{
User::factory()->count(9)->create();
$meta = $this->metaFor(BoundedPaginator::paginate(User::query(), 2, 1, 4));
$this->assertSame('at_least', $meta['total_relation']);
$this->assertFalse($meta['total_is_exact']);
$this->assertSame(4, $meta['result_cap']);
$this->assertSame(4, $meta['total']);
$this->assertNull($meta['last_page'], '마지막 페이지 점프는 계산 불가');
$this->assertTrue($meta['has_more_pages'], '"다음" 이동은 계속 열려 있다');
}
/**
* 단순형(simplePaginate)은 총 건수를 세지 않으므로 total/last_page 를 내보내지 않는지 확인
*/
public function test_simple_paginator_meta_omits_total(): void
{
User::factory()->count(5)->create();
$meta = $this->metaFor(User::query()->simplePaginate(2, ['*'], 'page', 1));
$this->assertArrayNotHasKey('total', $meta, '세지 않은 값을 0 으로 채우면 화면이 그것을 사실로 읽는다');
$this->assertArrayNotHasKey('last_page', $meta);
$this->assertTrue($meta['has_more_pages']);
}
/**
* 커서형 페이지가 앞뒤 커서를 메타로 내보내는지 확인
*/
public function test_cursor_paginator_meta_exposes_cursors(): void
{
User::factory()->count(5)->create();
$first = User::query()->orderBy('id')->cursorPaginate(2);
$meta = $this->metaFor($first);
$this->assertArrayHasKey('next_cursor', $meta);
$this->assertArrayHasKey('prev_cursor', $meta);
$this->assertNotNull($meta['next_cursor']);
$this->assertNull($meta['prev_cursor'], '첫 페이지에는 이전 커서가 없다');
$this->assertTrue($meta['has_more_pages']);
}
/**
* 커서로 끝까지 이동해도 행이 새거나 겹치지 않는지 확인
*/
public function test_keyset_cursor_round_trip_covers_every_row_once(): void
{
User::factory()->count(7)->create();
$seen = [];
$cursor = null;
do {
$page = KeysetPaginator::paginate(
User::query(),
perPage: 2,
sortKeys: [['created_at', 'desc']],
uniqueKey: 'id',
cursor: $cursor
);
foreach ($page->items() as $user) {
$seen[] = $user->id;
}
$cursor = KeysetPaginator::nextCursor($page);
} while ($cursor !== null);
$expected = User::query()->pluck('id')->all();
sort($expected);
$sortedSeen = $seen;
sort($sortedSeen);
$this->assertSame($expected, $sortedSeen, '모든 행이 정확히 한 번씩 나와야 한다');
$this->assertSame(count($seen), count(array_unique($seen)), '중복 노출 금지');
}
/**
* 깨진 커서 문자열이 예외 대신 첫 페이지로 처리되는지 확인
*/
public function test_malformed_cursor_falls_back_to_first_page(): void
{
User::factory()->count(3)->create();
$this->assertNull(KeysetPaginator::decode('not-a-valid-cursor'));
$page = KeysetPaginator::paginate(
User::query(),
perPage: 2,
sortKeys: [['id', 'desc']],
uniqueKey: 'id',
cursor: 'not-a-valid-cursor'
);
$this->assertCount(2, $page->items());
}
/**
* 계산값 정렬은 커서 대상이 아님을 판정하는지 확인
*/
public function test_supports_rejects_non_column_sort_keys(): void
{
$whitelist = ['created_at', 'id'];
$this->assertTrue(KeysetPaginator::supports([['created_at', 'desc']], $whitelist));
$this->assertFalse(
KeysetPaginator::supports([['_ft_score', 'desc']], $whitelist),
'계산값은 WHERE 절 경계로 쓸 수 없다'
);
$this->assertFalse(KeysetPaginator::supports([], $whitelist));
}
/**
* 한계값이 config 기본값 → 환경설정 → 필터 훅 순으로 해석되는지 확인
*/
public function test_pagination_limits_resolution_order(): void
{
config(['core.pagination.result_cap' => 5000, 'g7_settings.core.pagination' => []]);
$this->assertSame(5000, PaginationLimits::resultCap(), 'config 기본값');
config(['g7_settings.core.pagination.result_cap' => 777]);
$this->assertSame(777, PaginationLimits::resultCap(), '관리자 환경설정이 config 를 덮는다');
HookManager::addFilter('core.pagination.filter_result_cap', fn ($value) => 42);
$this->assertSame(42, PaginationLimits::resultCap(), '확장 필터 훅이 최종 결정');
}
/**
* 한계값 0 이 무제한(null)으로 해석되는지 확인
*/
public function test_zero_limit_means_unlimited(): void
{
config(['g7_settings.core.pagination.result_cap' => 0, 'g7_settings.core.pagination.max_page' => 0]);
$this->assertNull(PaginationLimits::resultCap());
$this->assertNull(PaginationLimits::maxPage());
}
/**
* 페이지네이터의 pagination 메타를 뽑아냅니다.
*
* @param mixed $paginator 페이지 결과
* @return array<string, mixed> pagination 메타
*/
private function metaFor(mixed $paginator): array
{
$collection = new PaginationMetaProbeCollection($paginator);
return $collection->toArray(Request::create('/'))['pagination'] ?? [];
}
}
/**
* paginationMeta() 결과만 확인하기 위한 테스트 전용 컬렉션
*/
class PaginationMetaProbeCollection extends BaseApiCollection
{
/**
* pagination 메타만 담은 배열을 반환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, mixed> pagination 메타
*/
public function toArray(Request $request): array
{
return $this->paginationMeta();
}
}

Some files were not shown because too many files have changed in this diff Show More