목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친 감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다. 검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다. 옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던 공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글 목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가 되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는 접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다. ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로 로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에 사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다. 하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서 통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가 요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다. 정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다. 탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
112 lines
5.3 KiB
PHP
112 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Repositories\Concerns;
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
/**
|
|
* 관계 테이블 컬럼 기준 정렬 Trait
|
|
*
|
|
* 목록의 정렬 기준이 원 테이블에 없고 관계 테이블에 있는 경우가 있다.
|
|
* (예: 주문 목록의 "발송일" — `shipped_at` 은 주문이 아니라 배송 테이블에 있고, 한 주문에
|
|
* 배송 행이 여러 건일 수 있다.)
|
|
*
|
|
* 이때 화면이 정렬 옵션을 제공하는데 백엔드가 그 컬럼을 모르면 422 로 조용히 실패하거나,
|
|
* 임시로 조인을 붙이면 다음 두 문제가 생긴다.
|
|
*
|
|
* 1. `JOIN` + `GROUP BY` 로 집계하면 1:N 조인이 원 행을 부풀려 총 건수와 페이지 경계가 깨진다
|
|
* 2. inner 쿼리(지연 조인)에 집계를 붙이면 건너뛸 행 전체에 대해 집계가 돌아 OFFSET 이
|
|
* 깊어질수록 비용이 그대로 누적된다 — 지연 조인을 도입한 이유가 사라진다
|
|
*
|
|
* 본 Trait 은 **상관 서브쿼리 정렬**로 두 문제를 함께 피한다. 원 행 수를 바꾸지 않으므로
|
|
* 총 건수·페이지 경계가 그대로 유지되고, `(외래키, 정렬컬럼)` 복합 인덱스가 있으면 각 행당
|
|
* 인덱스 첫 항목 한 건만 읽는다.
|
|
*
|
|
* 방향과 집계를 분리해 지정하지 않는 이유: 서브쿼리를 정렬 방향과 같은 방향으로 정렬해
|
|
* 한 건만 취하면 `desc` 는 자연히 최댓값(가장 늦은 발송), `asc` 는 최솟값(가장 이른 발송)이
|
|
* 된다. 운영자가 "최근 발송순" 을 골랐을 때 기대하는 값과 일치한다.
|
|
*
|
|
* 사용 계약:
|
|
* - 정렬 키 → 관계 스펙 맵을 리포지토리 상수(`RELATED_SORTABLE_COLUMNS`)로 선언한다
|
|
* - 그 키들은 FormRequest 의 `sort_by` 허용 목록에도 함께 들어가야 한다
|
|
* (게이트 ⊆ 저장소 불변식 — `SortWhitelistGateParityTest` 가 검사한다)
|
|
* - 정렬 대상 관계 테이블에는 `(외래키, 정렬컬럼)` 복합 인덱스를 반드시 둔다
|
|
*
|
|
* @see ResolvesSortSpec 원 테이블 컬럼 정렬 해석 (본 Trait 과 함께 사용)
|
|
* @see PaginatesWithDeferredJoin 서브쿼리 정렬 스펙을 그대로 받는다
|
|
*/
|
|
trait SortsByRelatedColumn
|
|
{
|
|
/**
|
|
* 관계 테이블 컬럼 정렬을 포함해 정렬 스펙을 해석합니다.
|
|
*
|
|
* 요청 정렬 키가 `$relatedMap` 에 있으면 상관 서브쿼리 스펙을, 그렇지 않으면
|
|
* `ResolvesSortSpec::resolveSortSpec()` 의 일반 컬럼 스펙을 돌려준다.
|
|
*
|
|
* @param array $filters 요청 유래 필터 배열
|
|
* @param array<int, string> $allowedColumns 원 테이블 허용 정렬 컬럼
|
|
* @param array<string, array{model: class-string<Model>, foreign_key: string, column: string}> $relatedMap 정렬 키 → 관계 스펙
|
|
* @param Model $parent 정렬 대상 원 모델 (상관 조건의 좌변)
|
|
* @param string $defaultColumn 기본 정렬 컬럼
|
|
* @param string $defaultDirection 기본 정렬 방향
|
|
* @param string $columnKey 정렬 컬럼이 담긴 필터 키
|
|
* @param string $directionKey 정렬 방향이 담긴 필터 키
|
|
* @return array<int, array{column: string|Builder, direction: string}> 정렬 스펙 배열
|
|
*/
|
|
protected function resolveSortSpecWithRelated(
|
|
array $filters,
|
|
array $allowedColumns,
|
|
array $relatedMap,
|
|
Model $parent,
|
|
string $defaultColumn,
|
|
string $defaultDirection = 'desc',
|
|
string $columnKey = 'sort_by',
|
|
string $directionKey = 'sort_order',
|
|
): array {
|
|
$requested = $filters[$columnKey] ?? null;
|
|
$requested = is_string($requested) ? trim($requested) : '';
|
|
|
|
if ($requested === '' || ! isset($relatedMap[$requested])) {
|
|
return $this->resolveSortSpec(
|
|
$filters,
|
|
$allowedColumns,
|
|
$defaultColumn,
|
|
$defaultDirection,
|
|
columnKey: $columnKey,
|
|
directionKey: $directionKey,
|
|
);
|
|
}
|
|
|
|
$direction = $this->normalizeSortDirection($filters[$directionKey] ?? null, $defaultDirection);
|
|
|
|
return [[
|
|
'column' => $this->relatedSortSubquery($relatedMap[$requested], $parent, $direction),
|
|
'direction' => $direction,
|
|
]];
|
|
}
|
|
|
|
/**
|
|
* 관계 테이블에서 정렬 기준값 한 건을 뽑는 상관 서브쿼리를 만듭니다.
|
|
*
|
|
* 정렬 방향과 같은 방향으로 관계 행을 정렬해 첫 건만 취한다 —
|
|
* `desc` 면 가장 늦은 값, `asc` 면 가장 이른 값이 기준이 된다.
|
|
*
|
|
* @param array{model: class-string<Model>, foreign_key: string, column: string} $spec 관계 스펙
|
|
* @param Model $parent 원 모델
|
|
* @param string $direction 정렬 방향 (asc|desc)
|
|
* @return Builder 상관 서브쿼리
|
|
*/
|
|
protected function relatedSortSubquery(array $spec, Model $parent, string $direction): Builder
|
|
{
|
|
/** @var Model $related */
|
|
$related = new $spec['model'];
|
|
|
|
return $related->newQuery()
|
|
->select($related->qualifyColumn($spec['column']))
|
|
->whereColumn($related->qualifyColumn($spec['foreign_key']), $parent->qualifyColumn($parent->getKeyName()))
|
|
->orderBy($related->qualifyColumn($spec['column']), $direction)
|
|
->limit(1);
|
|
}
|
|
}
|