Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/PostDetailQueryCountRegressionTest.php
T
HeuJung 7b859d4200 fix(core,basic,ecommerce): 쇼핑 화면 호출 통합 원복·검색 주소 승계·라우트 캐시 갱신 및 상한값 설정 저장 정합
합의되지 않은 구조 변경을 되돌리고, 계획서 전수조사에서 드러난 결함을 처리했다.

쇼핑 화면 호출 통합(F4)은 API 쿼리 튜닝이 아니라 화면 구조 변경이라 원복했다.
레이아웃 9개를 develop 상태로 복원하고 전용 엔드포인트·컨트롤러·문서·테스트를 걷어냈다.
되돌리면 함께 사라지는 비-F4 수정 둘(페이저 has_more_pages 판정 3곳, Icon 크기 클래스
23줄)은 재적용했다. 성능 개선분은 전부 잔존한다.

통합검색 화면 상태를 전역 상태에서 URL 쿼리로 옮겼다. 종전에는 탭·필터·정렬·페이지가
주소에 없어 새로고침·뒤로가기에서 초기화되고 결과를 공유할 수도 없었다. 화면은 정상으로
보이고 콘솔 에러도 없어 코드만으로는 드러나지 않는 형태였다.

확장이 라우트를 추가해도 라우트 캐시가 걸린 사이트에서는 그 주소가 404 가 되던 구멍을
막았다. 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 예외도 경고도 남지 않는다.
같은 문제를 이미 푼 ConfigCacheHelper 를 미러링해 갱신 지점을 한 곳으로 모았고,
코어 업데이트가 캐시를 비우기만 하고 되살리지 않던 문제도 함께 고쳤다.

상품·페이지 검색이 커서 판정에 페이지 번호를 넘기지 않아, 커서 없이 깊은 페이지를
지목한 딥링크가 조용히 1 페이지를 돌려주던 결함을 고쳤다. 호출 지점을 손으로 열거하는
대신 저장소를 훑는 패리티 테스트로 다음 도메인의 재발을 막는다.

환경설정 고급 탭의 목록 상한값이 저장되지 않던 결함을 고쳤다. 화면·검증·읽기는 모두
있었는데 저장 시 카테고리 분류표가 손으로 열거돼 있어 그 값만 버려지고 있었다. 분류표를
설정 정의에서 도출하도록 바꿔 다음 카테고리가 합류해도 같은 일이 생기지 않게 했다.
범위를 벗어난 값의 안내에 내부 식별자가 노출되던 것도 함께 정리했다.

언어팩 재설치가 활성 팩을 자기 자신과의 슬롯 충돌로 오인해 강등하던 회귀와, CLI 설치가
HTTP 권한 컨텍스트 없이 자동 활성화되는지를 고정하는 테스트를 함께 담았다.
2026-08-06 11:13:54 +09:00

169 lines
6.3 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Models\Comment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use Tests\Concerns\CountsQueries;
/**
* 게시글 상세 조회의 중복 읽기 회귀 테스트 (#519 F3)
*
* 상세 화면은 사이트에서 가장 자주 열리는 경로다. 종전에는 권한 판정용으로 글 행을 한 번
* 읽고, 관계를 붙여 응답에 쓸 글 행을 한 번 더 읽어 **같은 행을 두 번** 가져왔다. 게시판
* 행도 컨트롤러·서비스·저장소가 각각 slug 로 다시 조회해 세 번 읽혔다.
*
* 여기서는 "같은 행을 몇 번 읽는가" 만 단언한다. 관계 로딩(첨부·댓글·답글)은 별개 쿼리이고
* 게시판 설정에 따라 수가 달라지므로 총 쿼리 수를 고정하지 않는다 — 고정하면 정상적인
* 기능 추가마다 깨져 결국 숫자만 올리는 테스트가 된다.
*
* @scenario case=post_detail_duplicate_read
*
* @effects post_detail_reads_post_row_once,
* post_detail_reads_board_row_once
*/
class PostDetailQueryCountRegressionTest extends BoardTestCase
{
use CountsQueries;
private Post $post;
protected function setUp(): void
{
parent::setUp();
$this->post = Post::create([
'board_id' => $this->board->id,
'title' => '상세 중복 조회 점검',
'content' => '본문',
'author_name' => '작성자',
'status' => 'published',
'ip_address' => '127.0.0.1',
]);
}
/**
* 상세 조회 중 실행된 SQL 을 수집합니다.
*
* @return array<int, string> 실행된 SQL 목록
*/
private function captureDetailQueries(): array
{
return $this->captureQueries(function () {
$response = $this->getJson(
'/api/modules/sirsoft-board/boards/'.$this->board->slug.'/posts/'.$this->post->id
);
$response->assertOk();
});
}
/**
* 특정 테이블의 전체 컬럼(`select *`) 조회만 셉니다.
*
* 한 컬럼만 읽는 조회(조회수 갱신 후의 `select view_count` 등)는 목적이 다른 경량
* 질의이므로 중복 읽기 판정에서 제외한다. 여기서 잡고 싶은 것은 같은 행 전체를
* 두 번 가져오는 낭비다.
*
* @param array<int, string> $queries 수집된 SQL 목록
* @param string $table 테이블명 (프리픽스 제외)
* @return int 해당 테이블 전체 컬럼 SELECT 수
*/
private function countFullRowSelectsOn(array $queries, string $table): int
{
$prefixed = DB::getTablePrefix().$table;
return count(array_filter(
$queries,
fn (string $sql) => str_starts_with(strtolower(trim($sql)), 'select *')
&& str_contains($sql, '`'.$prefixed.'`')
));
}
/**
* 글 행을 한 번만 읽는지 확인
*
* 권한 판정과 응답 조립이 같은 인스턴스를 공유해야 한다.
*
* @effects post_detail_reads_post_row_once
*/
public function test_post_row_is_read_once(): void
{
$queries = $this->captureDetailQueries();
$postSelects = $this->countFullRowSelectsOn($queries, 'board_posts');
// 본문 1회 + 답글 트리 탐색 1회. 권한 판정용으로 같은 행을 한 번 더 읽던
// 종전 동작(3회)을 차단한다.
$this->assertLessThanOrEqual(
2,
$postSelects,
'게시글 상세에서 글 전체 조회가 '.$postSelects.'회 — 같은 행을 중복으로 읽고 있다.'
);
}
/**
* 게시판 행을 한 번만 읽는지 확인
*
* 컨트롤러가 이미 조회한 게시판을 서비스·저장소가 slug 로 다시 찾지 않아야 한다.
*
* @effects post_detail_reads_board_row_once
*/
public function test_board_row_is_read_once(): void
{
$queries = $this->captureDetailQueries();
$boardSelects = $this->countFullRowSelectsOn($queries, 'boards');
$this->assertLessThanOrEqual(
1,
$boardSelects,
'게시글 상세에서 게시판 테이블 SELECT 가 '.$boardSelects.'회 — 이미 조회한 게시판을 다시 찾고 있다.'
);
}
/**
* 딸린 데이터가 늘어도 상세 화면의 쿼리 수가 늘지 않는지 확인
*
* 앞의 두 단언은 "같은 행을 두 번 읽지 않는다" 만 본다. 그것만으로는 댓글처럼
* 개수가 변하는 자식 데이터에 항목별 쿼리가 붙는 형태를 잡지 못한다 — 그 형태는
* 글 행을 한 번만 읽으면서도 댓글 수만큼 쿼리를 낸다.
*
* 그래서 댓글을 3배로 늘려 놓고 같은 화면을 다시 재, 쿼리 수가 그대로인지 본다.
*
* @effects post_detail_query_count_does_not_grow_with_comment_count
*/
public function test_query_count_does_not_grow_with_comment_count(): void
{
$seedComments = function (int $count, string $prefix): void {
for ($i = 0; $i < $count; $i++) {
Comment::create([
'board_id' => $this->board->id,
'post_id' => $this->post->id,
'content' => $prefix.' '.$i,
'author_name' => '댓글쓴이',
'ip_address' => '127.0.0.1',
]);
}
};
$seedComments(3, '초기');
// 모집단 확인 — 상세 응답이 댓글을 싣지 않게 되면 아래 단언은 "댓글을 늘려도
// 쿼리가 안 는다" 를 아무 근거 없이 통과시킨다. 댓글을 실제로 읽는지 먼저 고정한다.
$commentReads = array_filter(
$this->captureDetailQueries(),
fn (string $sql) => str_contains($sql, '`'.DB::getTablePrefix().'board_comments`')
);
$this->assertNotEmpty($commentReads, '상세 응답이 댓글을 조회하지 않는다 — 측정 전제가 무너졌다');
$this->assertQueryCountStableAsDataGrows(
measure: fn () => $this->captureDetailQueries(),
grow: fn () => $seedComments(9, '추가'),
context: '게시글 상세(댓글 3건 → 12건)'
);
}
}