Files
HeuJung b10e89bef0 fix(core,board,ecommerce,page,gdpr,ckeditor5,kginicis,admin_basic,basic): 목록 조회·검증 계층 정비 및 감사 지적 전건 처리
목록 조회 성능 축(깊은 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" 로 보고되던 문제도 런너 사전 검증으로 막았다.

정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다.
탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
2026-08-02 15:29:29 +09:00

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