공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다. 응답·행의 무게를 다룬 · 과 겹치지 않는다. 공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은 per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로 정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은 계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다. 계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면 그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다. 응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새 필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate 응답은 필드 단위로 이전과 동일하다. 요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일 재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어 화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한 변경이 종전처럼 다음 요청에 반영된다. 검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한 엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어 활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩 대신 선언형 config 로 옮겼다. 총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을 돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
122 lines
4.7 KiB
PHP
122 lines
4.7 KiB
PHP
<?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));
|
|
}
|
|
}
|