Files
Gnuboard7/tests/Feature/Performance/DeferredJoinBoundedTotalTest.php
HeuJung 2ae1972a3b perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리
공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이
기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은
per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지
번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로
정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은
계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다.

계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면
그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다.
응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새
필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate
응답은 필드 단위로 이전과 동일하다.

요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일
재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어
화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한
변경이 종전처럼 다음 요청에 반영된다.

검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한
엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어
활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩
대신 선언형 config 로 옮겼다.

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
2026-08-06 11:13:51 +09:00

195 lines
6.5 KiB
PHP

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