From e619b21b2b743e3d6dc244d9ff0c75a40bafa35b Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 4 Aug 2026 09:35:00 +0900 Subject: [PATCH] =?UTF-8?q?perf(page):=20=EA=B2=80=EC=83=89=20=EC=A0=84?= =?UTF-8?q?=EB=9F=89=20=EC=A0=81=EC=9E=AC=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=ED=95=9C=20=EA=B3=84=EC=95=BD=20=EC=86=8C=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색이 조건에 맞는 페이지 전체를 읽어 온 뒤 PHP 에서 화면 분량만 잘라 내고 있었다. 실제 page/per_page 를 저장소까지 하달해 필요한 분량만 조회한다. 구체 엔진 직접 호출도 해석기 경유로 옮겨 플러그인 검색엔진이 페이지 검색에 적용된다. 관리자 목록은 화면에 본문이 나오지 않는데도 항목마다 본문을 함께 읽고 있었다. 목록 조회에서 제외하고 총 건수에 상한 계약을 적용했다. 발행 페이지 목록과 통합검색의 페이지 탭 정렬에 필요한 색인을 더했다. 신규 설치는 마이그레이션이, 기존 사이트는 업그레이드 스텝이 적용한다. 관리자 목록의 페이저가 총 건수를 끝까지 세지 못한 경우 "1페이지뿐" 으로 접혀 뒤쪽 페이지에 도달할 수 없던 것을 고쳤다. --- modules/_bundled/sirsoft-page/CHANGELOG.md | 12 ++ ...dd_published_sort_index_to_pages_table.php | 72 +++++++++ .../layouts/admin/admin_page_list.json | 3 +- .../src/Listeners/SearchPagesListener.php | 92 ++++++++--- .../Contracts/PageRepositoryInterface.php | 40 ++++- .../src/Repositories/PageRepository.php | 103 ++++++++---- .../sirsoft-page/src/Services/PageService.php | 67 +++++++- .../ListQueryCountRegressionTest.php | 77 +++++++++ .../PublishedSortIndexCoverageTest.php | 117 ++++++++++++++ .../Repositories/PageRepositorySearchTest.php | 25 +-- .../Listeners/SearchPagesListenerTest.php | 146 ++++++++++++++---- .../sirsoft-page/upgrades/Upgrade_1_0_2.php | 3 + .../migrations/02_AddPublishedSortIndex.php | 78 ++++++++++ 13 files changed, 727 insertions(+), 108 deletions(-) create mode 100644 modules/_bundled/sirsoft-page/database/migrations/2026_08_02_000001_add_published_sort_index_to_pages_table.php create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/Performance/ListQueryCountRegressionTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/Performance/PublishedSortIndexCoverageTest.php create mode 100644 modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/02_AddPublishedSortIndex.php diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 7508bc3c..8fc5c5e1 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -17,9 +17,14 @@ ### Changed +- 통합 검색의 페이지 결과를 최신순·오래된순으로 볼 때 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. +- 플러그인으로 다른 검색엔진을 연결하면 페이지 검색에도 그 엔진이 적용됩니다. 이전에는 연결한 엔진과 무관하게 기본 방식으로만 검색됐습니다. - 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. - 페이지 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 항목의 본문까지 함께 읽어 느려졌지만, 이제 현재 페이지의 항목만 본문을 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) - 관리자 페이지 목록을 열 때 페이지 본문을 데이터베이스에서 읽지 않습니다. 목록은 제목·주소·발행 여부만 쓰는데 본문까지 함께 읽고 있었습니다. 본문이 긴 페이지가 많은 사이트일수록 목록을 여는 속도와 서버 메모리 사용이 개선되며, 화면에 보이는 내용은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 발행 페이지 목록과 통합검색의 페이지 탭 정렬에 필요한 색인을 추가했습니다. 기존 사이트도 업데이트 시 자동으로 반영됩니다. +- 페이지 검색 탭의 배지 숫자도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 배지를 표시하지 않는 화면에서는 그 숫자를 세는 작업 자체를 건너뜁니다. +- 관리자 페이지 목록의 총 건수도 일정 규모까지만 정확히 세고 그보다 많으면 "N건 이상" 으로 표시합니다. 페이지 이동은 끝까지 열려 있습니다. - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. - 첨부파일 저장 디스크 설정 항목을 제거했습니다. 설정 파일 자체가 그 디스크에 저장되는 구조라 값을 바꿔도 반영될 수 없는 항목이었습니다(기존에도 항상 기본값으로 동작). 저장 위치는 서버 파일 저장소 설정으로 관리합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) @@ -35,6 +40,13 @@ - 통합검색의 페이지 결과에서 같은 시각에 등록된 페이지가 여러 건일 때 목록 순서가 흔들리던 문제를 수정했습니다. - 페이지 관리 목록에서 뒤쪽 페이지를 보던 중 정렬을 바꾸면 이전 페이지 번호에 그대로 머물러 엉뚱한 구간이 보이던 문제를 수정했습니다. 이제 정렬을 바꾸면 첫 페이지부터 보여줍니다. +- 페이지 관리 목록에서 페이지가 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. +### Changed + +- 페이지 검색이 조건에 맞는 페이지 전체를 읽어 온 뒤 화면에 보일 만큼만 잘라 내던 방식을 바꿨습니다. 이제 필요한 페이지 분량만 조회하므로, 페이지가 많은 사이트에서 검색이 빨라지고 메모리 사용량이 줄어듭니다. +- 검색 결과가 아주 많으면 총 건수를 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다. +- 발행 페이지 목록의 정렬 색인을 추가했습니다. 기존 사이트도 업데이트 시 자동으로 반영됩니다. + ## [1.0.1] - 2026-07-08 ### Fixed diff --git a/modules/_bundled/sirsoft-page/database/migrations/2026_08_02_000001_add_published_sort_index_to_pages_table.php b/modules/_bundled/sirsoft-page/database/migrations/2026_08_02_000001_add_published_sort_index_to_pages_table.php new file mode 100644 index 00000000..166ea1b4 --- /dev/null +++ b/modules/_bundled/sirsoft-page/database/migrations/2026_08_02_000001_add_published_sort_index_to_pages_table.php @@ -0,0 +1,72 @@ + + */ + private const COLUMNS = ['published', 'created_at', 'id']; + + /** + * Run the migrations. + */ + public function up(): void + { + if (! Schema::hasTable(self::TABLE)) { + return; + } + + if (in_array(self::INDEX, array_column(Schema::getIndexes(self::TABLE), 'name'), true)) { + return; + } + + Schema::table(self::TABLE, function (Blueprint $blueprint) { + $blueprint->index(self::COLUMNS, self::INDEX); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable(self::TABLE)) { + return; + } + + if (! in_array(self::INDEX, array_column(Schema::getIndexes(self::TABLE), 'name'), true)) { + return; + } + + Schema::table(self::TABLE, function (Blueprint $blueprint) { + $blueprint->dropIndex(self::INDEX); + }); + } +}; diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json index 697e4f81..669df1e4 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json @@ -766,7 +766,8 @@ "pageSize": 20, "serverSidePagination": true, "serverCurrentPage": "{{pages?.data?.meta?.current_page ?? 1}}", - "serverTotalPages": "{{pages?.data?.meta?.last_page ?? 1}}", + "serverTotalPages": "{{pages?.data?.meta?.last_page ?? null}}", + "serverHasMorePages": "{{pages?.data?.meta?.has_more_pages ?? false}}", "selectable": true, "selectedCountText": "", "selectedIds": "{{_global.selectedIds_admin_pages || []}}", diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php index 25196217..b547eae4 100644 --- a/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php +++ b/modules/_bundled/sirsoft-page/src/Listeners/SearchPagesListener.php @@ -3,6 +3,9 @@ namespace Modules\Sirsoft\Page\Listeners; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\TotalRelation; +use App\Search\SearchCategoryPayload; +use App\Support\Query\BoundedCount; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Page\Services\PageService; @@ -89,10 +92,22 @@ class SearchPagesListener implements HookListenerInterface try { if (! $isRelevantTab) { - $results['pages'] = [ - 'total' => $this->pageService->countByKeyword($q), - 'items' => [], - ]; + // 다른 탭을 보는 중이라도 탭 배지에는 건수가 필요하다. 다만 상한을 건 + // 집계라 대량 매칭에서도 비용이 일정하다. + // 잘린 값을 정확한 것처럼 내보내지 않도록 정확도를 함께 싣는다. + // + // 배지를 그리지 않는 화면은 건수를 요청하지 않는다 — 그 경우 집계를 생략한다. + if (! ($context['include_inactive_counts'] ?? true)) { + $results['pages'] = SearchCategoryPayload::fromCountOnly( + new BoundedCount(0, TotalRelation::Exact, null) + ); + + return $results; + } + + $results['pages'] = SearchCategoryPayload::fromCountOnly( + $this->pageService->countByKeyword($q) + ); return $results; } @@ -100,18 +115,38 @@ class SearchPagesListener implements HookListenerInterface $sort = $context['sort'] ?? 'relevance'; [$orderBy, $direction] = $this->resolveSortOrder($sort); - $limit = ($type === 'all') - ? ($context['all_tab_limit'] ?? 5) - : PHP_INT_MAX; + // 전체 탭은 미리보기 몇 건, 페이지 탭은 실제 요청 페이지를 그대로 하달한다. + // 종전에는 PHP_INT_MAX 로 매칭 전량을 읽고 PHP 에서 잘라냈다. + $isAllTab = ($type === 'all'); + $perPage = $isAllTab + ? (int) ($context['all_tab_limit'] ?? 5) + : (int) ($context['per_page'] ?? 10); + $pageNumber = $isAllTab ? 1 : (int) ($context['page'] ?? 1); - $searchResult = $this->pageService->searchByKeyword($q, $orderBy, $direction, $limit); + /** 조회 결과 항목을 화면 형태로 가공한다. */ + $format = fn (iterable $items): array => collect($items) + ->map(fn ($page) => $this->formatPageResult($page, $q)) + ->toArray(); - $results['pages'] = [ - 'total' => $searchResult['total'], - 'items' => $searchResult['items']->map( - fn ($page) => $this->formatPageResult($page, $q) - )->toArray(), - ]; + // 커서를 받았고 그 정렬을 커서로 처리할 수 있으면 키셋으로 응답한다. + // 전체 탭은 미리보기 몇 건뿐이라 깊은 페이지가 없어 대상이 아니다. + $cursorPage = $isAllTab + ? null + : $this->pageService->searchByKeywordWithCursor($q, $sort, $perPage, $context['cursor'] ?? null); + + if ($cursorPage !== null) { + $results['pages'] = SearchCategoryPayload::fromCursor( + $cursorPage, + $this->pageService->countByKeyword($q), + $format($cursorPage->items()) + ); + + return $results; + } + + $searchPage = $this->pageService->searchByKeyword($q, $orderBy, $direction, $perPage, $pageNumber); + + $results['pages'] = SearchCategoryPayload::fromBounded($searchPage, $format($searchPage->items())); } catch (\Exception $e) { Log::error('Search pages error', ['message' => $e->getMessage(), 'q' => $q]); } @@ -136,28 +171,35 @@ class SearchPagesListener implements HookListenerInterface $pagesData = $results['pages']; $type = $context['type'] ?? 'all'; $total = $pagesData['total'] ?? 0; + // 항목은 이미 요청한 페이지 분량만 조회돼 있다 — 여기서 다시 자르지 않는다. $items = $pagesData['items'] ?? []; $response['pages_count'] = $total; if ($type === 'all') { - $allTabLimit = $context['all_tab_limit'] ?? 5; $response['pages'] = [ 'total' => $total, - 'items' => array_slice($items, 0, $allTabLimit), + 'total_relation' => $pagesData['total_relation'] ?? null, + 'total_is_exact' => $pagesData['total_is_exact'] ?? true, + 'result_cap' => $pagesData['result_cap'] ?? null, + 'items' => $items, ]; } elseif ($type === 'pages') { - $page = $context['page'] ?? 1; - $perPage = $context['per_page'] ?? 10; - $offset = ($page - 1) * $perPage; - $response['pages'] = [ 'total' => $total, - 'items' => array_slice($items, $offset, $perPage), + 'total_relation' => $pagesData['total_relation'] ?? null, + 'total_is_exact' => $pagesData['total_is_exact'] ?? true, + 'result_cap' => $pagesData['result_cap'] ?? null, + 'items' => $items, ]; - $response['current_page'] = $page; - $response['per_page'] = $perPage; - $response['last_page'] = max(1, (int) ceil($total / $perPage)); + $response['current_page'] = (int) ($context['page'] ?? 1); + $response['per_page'] = (int) ($context['per_page'] ?? 10); + // 총 건수가 상한에 걸리면 마지막 페이지를 계산할 수 없다 — null 로 알린다. + $response['last_page'] = $pagesData['last_page'] ?? null; + $response['has_more_pages'] = $pagesData['has_more_pages'] ?? false; + // 커서 응답이면 다음/이전 커서를 함께 실어 깊은 페이지를 OFFSET 없이 넘긴다. + $response['next_cursor'] = $pagesData['next_cursor'] ?? null; + $response['prev_cursor'] = $pagesData['prev_cursor'] ?? null; } return $response; @@ -175,7 +217,7 @@ class SearchPagesListener implements HookListenerInterface { return match ($sort) { 'oldest' => ['created_at', 'asc'], - default => ['created_at', 'desc'], + default => ['created_at', 'desc'], }; } diff --git a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php index 3635fd25..15eec6dd 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php @@ -2,8 +2,10 @@ namespace Modules\Sirsoft\Page\Repositories\Contracts; -use Illuminate\Database\Eloquent\Collection; +use App\Support\Query\BoundedCount; +use App\Support\Query\BoundedPage; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Modules\Sirsoft\Page\Models\Page; @@ -91,22 +93,48 @@ interface PageRepositoryInterface */ public function slugExists(string $slug, ?int $excludeId = null): bool; + /** + * 키워드로 페이지를 커서(키셋)로 검색합니다. + * + * 커서 적용 가능 여부 판정은 코어가 담당하므로, 이 메서드는 이미 검증된 정렬 키를 + * 받아 조회만 수행합니다. + * + * @param string $keyword 검색 키워드 + * @param array $sortKeys [[컬럼, 방향], ...] + * @param int $perPage 페이지당 항목 수 + * @param string|null $cursor 인코딩된 커서 (첫 페이지면 null) + * @return CursorPaginator 커서 페이지 결과 + */ + public function searchByKeywordWithCursor( + string $keyword, + array $sortKeys, + int $perPage = 10, + ?string $cursor = null + ): CursorPaginator; + /** * 키워드로 페이지를 검색합니다. * * @param string $keyword 검색 키워드 * @param string $orderBy 정렬 컬럼 * @param string $direction 정렬 방향 (asc, desc) - * @param int $limit 조회할 최대 항목 수 - * @return array{total: int, items: Collection} + * @param int $perPage 페이지당 항목 수 + * @param int $page 페이지 번호 + * @return BoundedPage 페이지 결과 (총 건수 정확도 포함) */ - public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array; + public function searchByKeyword( + string $keyword, + string $orderBy = 'created_at', + string $direction = 'desc', + int $perPage = 10, + int $page = 1 + ): BoundedPage; /** * 키워드와 일치하는 발행된 페이지 수를 조회합니다. * * @param string $keyword 검색 키워드 - * @return int 일치하는 페이지 수 + * @return BoundedCount 일치하는 페이지 수 (정확도 포함) */ - public function countByKeyword(string $keyword): int; + public function countByKeyword(string $keyword): BoundedCount; } diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php index 699300d0..44b8a150 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php @@ -7,9 +7,15 @@ use App\Repositories\Concerns\HasMultipleSearchFilters; use App\Repositories\Concerns\PaginatesWithDeferredJoin; use App\Repositories\Concerns\ResolvesSortSpec; use App\Search\Engines\DatabaseFulltextEngine; +use App\Search\KeywordSearch; +use App\Support\Query\BoundedCount; +use App\Support\Query\BoundedPage; +use App\Support\Query\BoundedPaginator; +use App\Support\Query\KeysetPaginator; +use App\Support\Query\PaginationLimits; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Modules\Sirsoft\Page\Models\Page; use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; @@ -53,20 +59,20 @@ class PageRepository implements PageRepositoryInterface $this->applyFilters($query, $filters); - // 지연 조인: 본문 content(longText)는 목록에 쓰이지 않는데도 OFFSET 이 훑는 - // 모든 행에서 함께 읽힌다. inner 는 id 만 훑고 본문은 이번 페이지에서만 읽는다. - // 목록이 실제로 쓰는 컬럼만 읽는다. 본문(`content`)은 longText 인데 목록 표현 - // (`PageResource::toListArray`)이 출력하지 않으므로, 이번 페이지 행에서도 읽을 이유가 없다. + // 지연 조인: inner 는 id 만 훑는다. outer 도 목록 리소스가 실제로 쓰는 컬럼만 + // 읽는다 — 본문 content(mediumText)는 목록 표현(`PageResource::toListArray`)이 + // 출력하지 않으므로 한 페이지 분량이라도 읽을 이유가 없다 (오버플로 페이지 읽기 제거). // 응답 형태는 변하지 않고 DB/메모리 비용만 줄어든다. return $this->paginateWithDeferredJoin( query: $query, columns: [ - 'id', 'slug', 'title', 'published', 'published_at', 'current_version', - 'created_by', 'updated_by', 'created_at', 'updated_at', + 'id', 'slug', 'title', 'published', 'published_at', + 'current_version', 'created_by', 'updated_by', 'created_at', 'updated_at', ], sort: $this->resolveListSortSpec($filters), perPage: $perPage, relations: ['creator', 'updater'], + resultCap: PaginationLimits::resultCap('admin.pages'), ); } @@ -183,35 +189,75 @@ class PageRepository implements PageRepositoryInterface * @param string $keyword 검색 키워드 * @param string $orderBy 정렬 컬럼 * @param string $direction 정렬 방향 (asc, desc) - * @param int $limit 조회할 최대 항목 수 - * @return array{total: int, items: Collection} + * @param int $perPage 페이지당 항목 수 + * @param int $page 페이지 번호 + * @return BoundedPage 페이지 결과 (총 건수 정확도 포함) */ - public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array - { - $results = $this->buildKeywordQuery($keyword) + public function searchByKeyword( + string $keyword, + string $orderBy = 'created_at', + string $direction = 'desc', + int $perPage = 10, + int $page = 1 + ): BoundedPage { + $query = $this->buildKeywordQuery($keyword) ->orderBy($orderBy, $direction) // 전순서 보장 — 정렬 컬럼이 비고유라 페이지 경계가 흔들릴 수 있다 - ->orderBy('id', $direction === 'asc' ? 'asc' : 'desc') - // audit:allow repository-paginate-column-pruning reason: 통합검색 전용 진입점 — - // 유일한 호출자(SearchPagesListener)가 전체 탭은 limit=5, 페이지 탭은 limit=PHP_INT_MAX 로 - // 1페이지에 결과를 받아가므로 OFFSET 이 발생하지 않는다 - ->paginate($limit); + ->orderBy('id', $direction === 'asc' ? 'asc' : 'desc'); - return [ - 'total' => $results->total(), - 'items' => $results->getCollection(), - ]; + // 검색 결과 카드가 실제로 쓰는 컬럼만 읽는다. + // + // content 만 예외로 남긴다. 검색 결과는 키워드 **주변** 문맥을 잘라 보여주므로 + // 본문 어디에 키워드가 있든 그 위치를 찾을 수 있어야 하고, 이 컬럼은 다국어 + // JSON 이라 DB 에서 `SUBSTRING` 으로 자르면 캐스팅이 깨진다. 관리자 페이지 목록 + // (위 paginate)은 본문을 전혀 쓰지 않으므로 그쪽에서는 제외했다. + // + // 비용 측면의 목적은 달성돼 있다 — 종전에는 매칭 전량의 본문을 끌어왔지만 + // 지금은 이번 페이지 분량(per_page + 1)만 읽는다. + return BoundedPaginator::paginate( + $query, + perPage: $perPage, + page: $page, + resultCap: PaginationLimits::resultCap('search'), + columns: ['id', 'slug', 'title', 'content', 'published_at', 'created_at'], + ); + } + + /** + * {@inheritDoc} + */ + public function searchByKeywordWithCursor( + string $keyword, + array $sortKeys, + int $perPage = 10, + ?string $cursor = null + ): CursorPaginator { + // 커서 모드에는 OFFSET 이 없다 — 건너뛸 행을 실제로 읽던 비용이 사라진다. + // 읽는 컬럼은 offset 경로와 같게 유지해 화면이 받는 항목 형태를 맞춘다. + return KeysetPaginator::paginate( + query: $this->buildKeywordQuery($keyword), + perPage: $perPage, + sortKeys: $sortKeys, + uniqueKey: 'id', + cursor: $cursor, + columns: ['id', 'slug', 'title', 'content', 'published_at', 'created_at'], + ); } /** * 키워드와 일치하는 발행된 페이지 수를 조회합니다. * * @param string $keyword 검색 키워드 - * @return int 일치하는 페이지 수 + * @return BoundedCount 일치하는 페이지 수 (정확도 포함) */ - public function countByKeyword(string $keyword): int + public function countByKeyword(string $keyword): BoundedCount { - return $this->buildKeywordQuery($keyword)->count(); + // 비활성 탭의 배지용 건수다. 상한을 걸어 대량 매칭에서도 비용이 일정하게 유지된다. + // 상한에 걸리면 값이 잘리므로 정확도를 함께 돌려준다. + return BoundedPaginator::count( + $this->buildKeywordQuery($keyword), + PaginationLimits::resultCap('search') + ); } /** @@ -225,8 +271,9 @@ class PageRepository implements PageRepositoryInterface * `WHERE published = ? OR slug LIKE ? AND id IN (...)` 형태로 연산자 우선순위가 깨져 * `published = ?` 만 유효해지고 total 이 모든 발행 페이지 수로 부풀려지는 회귀가 발생한다. * 이 Repository 의 applyTitleKeywordSearch 에서 이미 사용중인 - * `DatabaseFulltextEngine::whereFulltext` 정적 헬퍼 패턴과 동일하게 해결한다 - * (Scout 엔진은 indexing 및 다른 검색에서 계속 사용됨). + * `KeywordSearch::apply()` 패턴과 동일하게 해결한다 — 키워드 조건을 진행 중인 쿼리에 + * 직접 붙이므로 Scout 의 재계수 경로를 타지 않는다. 어떤 조건이 붙는지는 활성 검색 + * 엔진이 정하므로 플러그인 엔진도 이 경로를 그대로 탄다. * * @param string $keyword 검색 키워드 */ @@ -253,7 +300,7 @@ class PageRepository implements PageRepositoryInterface } // 본문은 순수 텍스트/HTML 이라 FULLTEXT 가 유효하다. - DatabaseFulltextEngine::whereFulltext($q, 'content', $keyword, 'or'); + KeywordSearch::apply($q, 'content', $keyword, 'or'); $q->orWhere('slug', 'like', '%'.$keyword.'%'); }); } @@ -332,7 +379,7 @@ class PageRepository implements PageRepositoryInterface $query->where('slug', 'like', "%{$keyword}%"); } - DatabaseFulltextEngine::whereFulltext($query, 'title', $keyword, 'or'); + KeywordSearch::apply($query, 'title', $keyword, 'or'); } /** diff --git a/modules/_bundled/sirsoft-page/src/Services/PageService.php b/modules/_bundled/sirsoft-page/src/Services/PageService.php index 6a5127ba..82582dcb 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageService.php @@ -4,8 +4,12 @@ namespace Modules\Sirsoft\Page\Services; use App\Extension\HookManager; use App\Helpers\PermissionHelper; +use App\Search\SearchPagePolicy; +use App\Support\Query\BoundedCount; +use App\Support\Query\BoundedPage; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -22,6 +26,22 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; */ class PageService { + /** + * 검색 정렬 이름 → [실제 컬럼, 방향] 선언 + * + * 코어({@see SearchPagePolicy})가 이 선언을 읽어 커서 적용 여부를 판정한다. + * 여기에 없는 정렬 이름(관련도순 등)은 커서로 처리하지 않고 offset 을 유지한다. + */ + public const SEARCH_SORT_MAP = [ + 'latest' => ['created_at', 'desc'], + 'oldest' => ['created_at', 'asc'], + ]; + + /** + * 커서(키셋) 경계로 쓸 수 있는 실제 컬럼 선언 + */ + public const SEARCH_CURSOR_COLUMNS = ['created_at']; + public function __construct( private PageRepositoryInterface $pageRepository, private PageVersionRepositoryInterface $pageVersionRepository, @@ -380,21 +400,54 @@ class PageService * @param string $keyword 검색 키워드 * @param string $orderBy 정렬 컬럼 * @param string $direction 정렬 방향 (asc, desc) - * @param int $limit 조회할 최대 항목 수 - * @return array{total: int, items: Collection} + * @param int $perPage 페이지당 항목 수 + * @param int $page 페이지 번호 + * @return BoundedPage 페이지 결과 (총 건수 정확도 포함) */ - public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array - { - return $this->pageRepository->searchByKeyword($keyword, $orderBy, $direction, $limit); + public function searchByKeyword( + string $keyword, + string $orderBy = 'created_at', + string $direction = 'desc', + int $perPage = 10, + int $page = 1 + ): BoundedPage { + return $this->pageRepository->searchByKeyword($keyword, $orderBy, $direction, $perPage, $page); + } + + /** + * 키워드로 페이지를 커서(키셋)로 검색합니다. + * + * 커서 적용 가능 여부는 코어({@see SearchPagePolicy})가 판정한다. 이 서비스는 + * 정렬 선언({@see self::SEARCH_SORT_MAP})만 제공하고 규칙을 다시 쓰지 않는다. + * + * @param string $keyword 검색 키워드 + * @param string $sort 정렬 옵션 + * @param int $perPage 페이지당 항목 수 + * @param string|null $cursor 인코딩된 커서 (첫 페이지면 null) + * @return CursorPaginator|null 커서 페이지 결과 (커서 적용 불가 시 null) + */ + public function searchByKeywordWithCursor( + string $keyword, + string $sort = 'latest', + int $perPage = 10, + ?string $cursor = null + ): ?CursorPaginator { + $sortKeys = SearchPagePolicy::sortKeys($sort, self::SEARCH_SORT_MAP); + + if (! SearchPagePolicy::usesCursor($cursor, $sortKeys, self::SEARCH_CURSOR_COLUMNS)) { + return null; + } + + return $this->pageRepository->searchByKeywordWithCursor($keyword, $sortKeys, $perPage, $cursor); } /** * 키워드와 일치하는 발행된 페이지 수를 조회합니다. * * @param string $keyword 검색 키워드 - * @return int 일치하는 페이지 수 + * @return BoundedCount 일치하는 페이지 수 (정확도 포함) */ - public function countByKeyword(string $keyword): int + public function countByKeyword(string $keyword): BoundedCount { return $this->pageRepository->countByKeyword($keyword); } diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Performance/ListQueryCountRegressionTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Performance/ListQueryCountRegressionTest.php new file mode 100644 index 00000000..65beee9d --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/Performance/ListQueryCountRegressionTest.php @@ -0,0 +1,77 @@ + $prefix.'-'.$i, + 'title' => ['ko' => '페이지 '.$prefix.$i], + 'content' => '본문 내용', + 'published' => true, + ]); + } + } + + /** + * 페이지 목록: 페이지 수가 늘어도 쿼리 수가 늘지 않는지 확인 + * + * @effects page_list_query_count_stable + */ + public function test_page_list_query_count_is_stable(): void + { + $repository = app(PageRepositoryInterface::class); + $this->seedPages(5, 'list-a'); + + $this->assertQueryCountStableAsDataGrows( + measure: fn () => $repository->paginate([], 50), + grow: fn () => $this->seedPages(10, 'list-b'), + context: '페이지 목록', + ); + } + + /** + * 페이지 검색: 매칭 수가 늘어도 쿼리 수가 늘지 않는지 확인 + * + * 종전에는 PHP_INT_MAX 로 매칭 전량을 읽고 PHP 에서 잘라냈다. + * + * @effects page_search_query_count_stable + */ + public function test_page_search_query_count_is_stable(): void + { + $repository = app(PageRepositoryInterface::class); + $this->seedPages(5, 'search-a'); + + $this->assertQueryCountStableAsDataGrows( + measure: fn () => $repository->searchByKeyword('페이지', 'created_at', 'desc', 10, 1), + grow: fn () => $this->seedPages(10, 'search-b'), + context: '페이지 검색', + ); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Performance/PublishedSortIndexCoverageTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Performance/PublishedSortIndexCoverageTest.php new file mode 100644 index 00000000..bcac28fd --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/Performance/PublishedSortIndexCoverageTest.php @@ -0,0 +1,117 @@ +getDriverName(), ['mysql', 'mariadb'], true)) { + $this->markTestSkipped('EXPLAIN 판정은 MySQL 계열에서만 수행합니다.'); + } + } + + /** + * 정렬을 덮는 색인이 존재하는지 확인 + * + * @effects published_sort_index_exists + */ + public function test_published_sort_index_exists(): void + { + $names = array_column(Schema::getIndexes('pages'), 'name'); + + $this->assertContains( + 'idx_pages_published_created_id', + $names, + '발행 조건 + 등록일 정렬을 덮는 색인이 없다' + ); + } + + /** + * 색인 컬럼 순서가 조건·정렬 순서와 일치하는지 확인 + * + * @effects published_sort_index_column_order + */ + public function test_index_column_order_matches_query_shape(): void + { + $index = collect(Schema::getIndexes('pages'))->firstWhere('name', 'idx_pages_published_created_id'); + + $this->assertNotNull($index); + $this->assertSame( + ['published', 'created_at', 'id'], + array_values($index['columns']), + '색인 컬럼 순서가 조건·정렬 순서와 다르다 — 존재해도 정렬에 쓰이지 않는다' + ); + } + + /** + * 옵티마이저가 실제로 이 색인을 **선택**하는지 EXPLAIN 으로 확인 + * + * `possible_keys` 는 "쓸 수 있었다" 일 뿐이라, 후보에만 올라 있고 실제로는 전체 + * 스캔을 골라도 통과해 버린다. 판정은 실행계획이 확정한 `key` 로만 한다. + * + * @effects page_list_query_uses_published_sort_index + */ + public function test_query_plan_uses_the_index(): void + { + $rows = []; + + // 행이 몇 건뿐이면 전체 스캔이 정상 선택이라 key 단언이 성립하지 않는다. + for ($i = 0; $i < 300; $i++) { + $rows[] = [ + 'slug' => 'idx-test-'.$i, + 'title' => json_encode(['ko' => '색인 테스트 '.$i], JSON_UNESCAPED_UNICODE), + 'content' => json_encode(['ko' => '본문'], JSON_UNESCAPED_UNICODE), + 'published' => $i % 10 !== 0, + 'created_at' => now()->subMinutes($i), + 'updated_at' => now(), + ]; + } + + DB::table('pages')->insert($rows); + DB::statement('ANALYZE TABLE '.DB::getTablePrefix().'pages'); + + $query = Page::query() + ->where('published', true) + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc') + ->limit(10) + ->toBase(); + + $plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings()); + + $this->assertNotEmpty($plan, 'EXPLAIN 결과가 비어 있다'); + + $row = (array) $plan[0]; + $chosen = (string) ($row['key'] ?? ''); + + $this->assertStringContainsString( + 'idx_pages_published_created_id', + $chosen, + '옵티마이저가 이 색인을 고르지 않았다 (선택: '.($chosen !== '' ? $chosen : '없음(전체 스캔)'). + ', 후보: '.($row['possible_keys'] ?? '없음').') — 컬럼 순서나 조건 형태가 어긋났다' + ); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php index dbfd37a5..850dbf09 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/Repositories/PageRepositorySearchTest.php @@ -120,8 +120,8 @@ class PageRepositorySearchTest extends TestCase $result = $this->repository->searchByKeyword('이용약관'); - $this->assertEquals(1, $result['total']); - $this->assertEquals('test-terms-search', $result['items']->first()->slug); + $this->assertEquals(1, $result->total()); + $this->assertEquals('test-terms-search', $result->getCollection()->first()->slug); } /** @@ -144,9 +144,9 @@ class PageRepositorySearchTest extends TestCase $koFull = $this->repository->searchByKeyword('이용약관'); $koPartial = $this->repository->searchByKeyword('약관'); - $this->assertEquals(1, $koFull['total'], '제목 전체 일치가 검색되지 않았다'); - $this->assertEquals('aaa-bbb-ccc', $koFull['items']->first()->slug); - $this->assertEquals(1, $koPartial['total'], '제목 부분 일치가 검색되지 않았다'); + $this->assertEquals(1, $koFull->total(), '제목 전체 일치가 검색되지 않았다'); + $this->assertEquals('aaa-bbb-ccc', $koFull->getCollection()->first()->slug); + $this->assertEquals(1, $koPartial->total(), '제목 부분 일치가 검색되지 않았다'); } /** @@ -160,8 +160,8 @@ class PageRepositorySearchTest extends TestCase $result = $this->repository->searchByKeyword('Terms'); - $this->assertEquals(1, $result['total'], '영문 제목으로 검색되지 않았다'); - $this->assertEquals('ddd-eee-fff', $result['items']->first()->slug); + $this->assertEquals(1, $result->total(), '영문 제목으로 검색되지 않았다'); + $this->assertEquals('ddd-eee-fff', $result->getCollection()->first()->slug); } /** @@ -174,8 +174,8 @@ class PageRepositorySearchTest extends TestCase $result = $this->repository->searchByKeyword('쿠키 정책'); - $this->assertEquals(1, $result['total']); - $this->assertEquals('test-guide-search', $result['items']->first()->slug); + $this->assertEquals(1, $result->total()); + $this->assertEquals('test-guide-search', $result->getCollection()->first()->slug); } /** @@ -188,7 +188,10 @@ class PageRepositorySearchTest extends TestCase $count = $this->repository->countByKeyword('사이트 점검'); - $this->assertEquals(1, $count); + // 건수만 세는 자리도 정확도를 함께 돌려준다 (#519) — 상한에 걸려 잘린 값이 + // 정확한 것처럼 화면에 나가지 않도록. + $this->assertSame(1, $count->total); + $this->assertTrue($count->totalRelation()->isExact()); } /** @@ -201,7 +204,7 @@ class PageRepositorySearchTest extends TestCase $result = $this->repository->searchByKeyword('독점 서비스'); - $this->assertEquals(0, $result['total']); + $this->assertEquals(0, $result->total()); } // ─── 헬퍼 ──────────────────────────────────────────── diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php index e06da21d..9ab8c001 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php @@ -2,11 +2,15 @@ namespace Modules\Sirsoft\Page\Tests\Unit\Listeners; +use App\Enums\TotalRelation; +use App\Support\Query\BoundedCount; +use App\Support\Query\BoundedPage; +use Carbon\Carbon; use Illuminate\Support\Collection; +use Mockery; use Modules\Sirsoft\Page\Listeners\SearchPagesListener; use Modules\Sirsoft\Page\Services\PageService; use Modules\Sirsoft\Page\Tests\ModuleTestCase; -use Mockery; /** * SearchPagesListener 단위 테스트 @@ -28,6 +32,14 @@ class SearchPagesListenerTest extends ModuleTestCase parent::setUp(); $this->pageServiceMock = Mockery::mock(PageService::class); + + // 커서 경로는 코어 정책이 판정한다 — 커서를 보내지 않는 이 케이스들에서는 + // 서비스가 null 을 돌려주고 호출자는 offset 경로로 떨어진다. + $this->pageServiceMock + ->shouldReceive('searchByKeywordWithCursor') + ->andReturnNull() + ->byDefault(); + $this->listener = new SearchPagesListener($this->pageServiceMock); } @@ -92,6 +104,29 @@ class SearchPagesListenerTest extends ModuleTestCase $this->assertSame($original, $result); } + /** + * 검색 결과 페이지(BoundedPage)를 만듭니다. + * + * 저장소가 반환하는 계약과 같은 형태를 테스트에서도 그대로 씁니다. + * + * @param Collection $items 페이지 항목 + * @param int $total 총 건수 + * @param int $perPage 페이지당 건수 + * @return BoundedPage 페이지 결과 + */ + private function boundedPage(Collection $items, int $total, int $perPage = 5): BoundedPage + { + return new BoundedPage( + items: $items, + total: $total, + perPage: $perPage, + currentPage: 1, + totalRelation: TotalRelation::Exact, + resultCap: 10000, + hasMorePages: false, + ); + } + /** * 관련 없는 탭(posts)일 때 total만 반환하고 items는 빈 배열인지 확인 */ @@ -101,7 +136,7 @@ class SearchPagesListenerTest extends ModuleTestCase ->shouldReceive('countByKeyword') ->with('Laravel') ->once() - ->andReturn(5); + ->andReturn(new BoundedCount(5, TotalRelation::Exact, 10000)); $result = $this->listener->searchPages([], [ 'q' => 'Laravel', @@ -112,6 +147,28 @@ class SearchPagesListenerTest extends ModuleTestCase $this->assertEmpty($result['pages']['items']); } + /** + * 비활성 탭 배지도 잘린 건수를 정확한 것처럼 보고하지 않는지 확인 (#519) + */ + public function test_count_only_badge_carries_accuracy_when_truncated(): void + { + $this->pageServiceMock + ->shouldReceive('countByKeyword') + ->with('Laravel') + ->once() + ->andReturn(new BoundedCount(10000, TotalRelation::AtLeast, 10000)); + + $result = $this->listener->searchPages([], [ + 'q' => 'Laravel', + 'type' => 'posts', + ]); + + $this->assertSame(10000, $result['pages']['total']); + $this->assertSame('at_least', $result['pages']['total_relation']); + $this->assertFalse($result['pages']['total_is_exact']); + $this->assertSame(10000, $result['pages']['result_cap']); + } + /** * all 탭에서 검색 결과를 반환하는지 확인 */ @@ -121,12 +178,9 @@ class SearchPagesListenerTest extends ModuleTestCase $this->pageServiceMock ->shouldReceive('searchByKeyword') - ->with('약관', 'created_at', 'desc', 5) + ->with('약관', 'created_at', 'desc', 5, 1) ->once() - ->andReturn([ - 'total' => 1, - 'items' => new Collection([$fakePage]), - ]); + ->andReturn($this->boundedPage(new Collection([$fakePage]), 1)); $result = $this->listener->searchPages([], [ 'q' => '약관', @@ -147,12 +201,9 @@ class SearchPagesListenerTest extends ModuleTestCase $this->pageServiceMock ->shouldReceive('searchByKeyword') - ->with('개인정보', 'created_at', 'desc', PHP_INT_MAX) + ->with('개인정보', 'created_at', 'desc', 10, 1) ->once() - ->andReturn([ - 'total' => 1, - 'items' => new Collection([$fakePage]), - ]); + ->andReturn($this->boundedPage(new Collection([$fakePage]), 1)); $result = $this->listener->searchPages([], [ 'q' => '개인정보', @@ -170,9 +221,9 @@ class SearchPagesListenerTest extends ModuleTestCase { $this->pageServiceMock ->shouldReceive('searchByKeyword') - ->with('G7', 'created_at', 'asc', 5) + ->with('G7', 'created_at', 'asc', 5, 1) ->once() - ->andReturn(['total' => 0, 'items' => new Collection()]); + ->andReturn($this->boundedPage(new Collection, 0)); $result = $this->listener->searchPages([], [ 'q' => 'G7', @@ -192,9 +243,9 @@ class SearchPagesListenerTest extends ModuleTestCase { $this->pageServiceMock ->shouldReceive('searchByKeyword') - ->with('G7', 'created_at', 'desc', 5) + ->with('G7', 'created_at', 'desc', 5, 1) ->once() - ->andReturn(['total' => 0, 'items' => new Collection()]); + ->andReturn($this->boundedPage(new Collection, 0)); $result = $this->listener->searchPages([], [ 'q' => 'G7', @@ -217,10 +268,7 @@ class SearchPagesListenerTest extends ModuleTestCase $this->pageServiceMock ->shouldReceive('searchByKeyword') ->once() - ->andReturn([ - 'total' => 1, - 'items' => new Collection([$fakePage]), - ]); + ->andReturn($this->boundedPage(new Collection([$fakePage]), 1)); $result = $this->listener->searchPages([], [ 'q' => '약관', @@ -249,10 +297,7 @@ class SearchPagesListenerTest extends ModuleTestCase $this->pageServiceMock ->shouldReceive('searchByKeyword') ->once() - ->andReturn([ - 'total' => 1, - 'items' => new Collection([$fakePage]), - ]); + ->andReturn($this->boundedPage(new Collection([$fakePage]), 1)); $result = $this->listener->searchPages([], [ 'q' => '이용', @@ -298,11 +343,14 @@ class SearchPagesListenerTest extends ModuleTestCase } /** - * all 탭에서 pages_count와 pages 슬라이스가 반환되는지 확인 + * all 탭에서 pages_count 와 조회된 항목이 그대로 반환되는지 확인 + * + * 항목은 저장소가 이미 요청한 분량만 조회해 오므로 여기서 다시 자르지 않는다. + * 종전에는 전량을 받아 PHP 에서 잘랐고, 그래서 매칭이 많을수록 메모리가 함께 커졌다. */ public function test_build_pages_response_returns_summary_for_all_tab(): void { - $items = array_map(fn ($i) => ['id' => $i], range(1, 5)); + $items = array_map(fn ($i) => ['id' => $i], range(1, 3)); $results = [ 'pages' => ['total' => 10, 'items' => $items], ]; @@ -314,7 +362,7 @@ class SearchPagesListenerTest extends ModuleTestCase $this->assertEquals(10, $response['pages_count']); $this->assertEquals(10, $response['pages']['total']); - $this->assertCount(3, $response['pages']['items']); + $this->assertCount(3, $response['pages']['items'], '조회된 항목을 그대로 싣는다'); } /** @@ -322,9 +370,15 @@ class SearchPagesListenerTest extends ModuleTestCase */ public function test_build_pages_response_returns_pagination_for_pages_tab(): void { - $items = array_map(fn ($i) => ['id' => $i], range(1, 25)); + // 2페이지 분량(10건)만 조회돼 있는 상태 — 저장소가 이미 잘라서 준다 + $items = array_map(fn ($i) => ['id' => $i], range(11, 20)); $results = [ - 'pages' => ['total' => 25, 'items' => $items], + 'pages' => [ + 'total' => 25, + 'items' => $items, + 'last_page' => 3, + 'has_more_pages' => true, + ], ]; $response = $this->listener->buildPagesResponse([], $results, [ @@ -338,6 +392,38 @@ class SearchPagesListenerTest extends ModuleTestCase $this->assertEquals(2, $response['current_page']); $this->assertEquals(10, $response['per_page']); $this->assertEquals(3, $response['last_page']); + $this->assertTrue($response['has_more_pages']); + } + + /** + * 총 건수가 상한에 걸리면 마지막 페이지가 null 로 전달되는지 확인 + * + * 계산할 수 없는 값을 1 이나 임의 숫자로 채우면 화면이 그것을 사실로 읽는다. + */ + public function test_build_pages_response_omits_last_page_when_total_is_bounded(): void + { + $results = [ + 'pages' => [ + 'total' => 10000, + 'total_is_exact' => false, + 'total_relation' => 'at_least', + 'result_cap' => 10000, + 'items' => [['id' => 1]], + 'last_page' => null, + 'has_more_pages' => true, + ], + ]; + + $response = $this->listener->buildPagesResponse([], $results, [ + 'type' => 'pages', + 'page' => 1, + 'per_page' => 10, + ]); + + $this->assertNull($response['last_page'], '마지막 페이지는 계산 불가'); + $this->assertTrue($response['has_more_pages'], '"다음" 이동은 계속 열려 있다'); + $this->assertFalse($response['pages']['total_is_exact']); + $this->assertEquals('at_least', $response['pages']['total_relation']); } // ─── addValidationRules() ──────────────────────────── @@ -387,7 +473,7 @@ class SearchPagesListenerTest extends ModuleTestCase public array $content; - public ?\Carbon\Carbon $published_at; + public ?Carbon $published_at; public function __construct(string $title, string $slug, string $contentKo) { diff --git a/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php index a3c9d6dc..106a7696 100644 --- a/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php +++ b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_0_2.php @@ -11,6 +11,9 @@ use App\Extension\AbstractUpgradeStep; * `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서 * 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다. * + * 함께 발행 페이지 목록의 정렬 색인을 기존 사이트에 반영한다(02). 마이그레이션만 추가하면 + * 신규 설치에만 반영되므로 업그레이드 시점에 동일 색인을 적용한다. + * * 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약). */ class Upgrade_1_0_2 extends AbstractUpgradeStep {} diff --git a/modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/02_AddPublishedSortIndex.php b/modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/02_AddPublishedSortIndex.php new file mode 100644 index 00000000..c9f83a74 --- /dev/null +++ b/modules/_bundled/sirsoft-page/upgrades/data/1.0.2/migrations/02_AddPublishedSortIndex.php @@ -0,0 +1,78 @@ + + */ + private const COLUMNS = ['published', 'created_at', 'id']; + + /** + * 마이그레이션 식별자를 반환합니다. + * + * @return string 마이그레이션 이름 + */ + public function name(): string + { + return 'AddPublishedSortIndex'; + } + + /** + * 정렬 색인을 적용합니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! Schema::hasTable(self::TABLE)) { + $context->logger->info('[page:1.0.2] 테이블 부재 — 색인 적용 스킵: '.self::TABLE); + + return; + } + + if (in_array(self::INDEX, array_column(Schema::getIndexes(self::TABLE), 'name'), true)) { + $context->logger->info('[page:1.0.2] 이미 적용된 색인 — 스킵: '.self::INDEX); + + return; + } + + $context->logger->info('[page:1.0.2] 목록 정렬 색인 적용 시작 (데이터가 많으면 수 분 걸릴 수 있고 그동안 해당 테이블 쓰기가 대기합니다)'); + + Schema::table(self::TABLE, function ($blueprint) { + $blueprint->index(self::COLUMNS, self::INDEX); + }); + + $context->logger->info('[page:1.0.2] 색인 적용 완료: '.self::INDEX); + } +}