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); + } +}