검색 — 이 코어에 추가한 BOOLEAN MODE 정제기가 게시판에는 도달하지 않아 원문 키워드가 그대로 바인딩되고 있었다. + - * " < > 를 넣으면 오류 화면이 떴다. 코어 정제기 경유로 옮겼고, 같은 술어를 count 와 get 으로 두 번 실행하던 것을 페이지네이터 한 번으로 합쳤다. 구체 엔진 직접 호출도 해석기 경유로 바꿔 플러그인 검색엔진이 게시글·신고 검색에 적용된다. 상세 — 같은 글을 3회, 같은 게시판을 5회 읽고 있었다. 조회한 인스턴스를 넘겨 글 1회·게시판 1회로 줄였고, 조회수 증가를 권한 확인 뒤로 옮겨 열람 권한이 없는 요청이 조회수를 올리지 않게 했다. 목록 — 답변 트리의 무제한 조회에 상한과 순환 가드를 두고, O(n²) 병합을 한 패스로 바꿨다. 댓글도 무제한 조회에 상한을 두되 잘렸다는 사실과 전체 건수를 함께 알린다. 조용히 잘리면 "댓글이 그만큼뿐" 으로 보인다. 원댓글 단위 페이지네이션을 더해 답글이 부모와 떨어지지 않게 했다. 관리자 화면 — 총 건수가 잘린 목록에서 페이저가 "1페이지뿐" 으로 접히던 자리를 고쳤다. 저장소가 상한을 붙인 목록인데 화면이 그 값을 1 로 채우고 있어, 기록이 상한을 넘으면 2페이지에 도달할 방법이 없었다. 같은 파일에서 드러난 사전 결함도 함께 처리했다 — 반복 렌더 안의 정적 HTML id, Icon 의 박스 크기 지정, 단색 버튼의 다크 모드 대비.
126 lines
4.3 KiB
PHP
126 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Feature;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
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.'회 — 이미 조회한 게시판을 다시 찾고 있다.'
|
|
);
|
|
}
|
|
}
|