fix(core,ecommerce,board,page,admin_basic,basic): 목록 응답의 하위 컬렉션 전량 직렬화 해소

관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다.
같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다.

근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서
관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을
정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면
정상이라 오류도 경고도 없이 페이로드만 불어난다.

목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB
집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의
limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다.

뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와
무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다.
배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어
제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다.

재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
This commit is contained in:
HeuJung
2026-08-05 21:20:05 +09:00
parent f5b411d9a0
commit b5c0ec1178
265 changed files with 22679 additions and 39095 deletions
+37
View File
@@ -832,6 +832,43 @@ public function getAll(): Collection
}
```
### 목록의 eager load 는 목록이 직렬화하는 관계만
eager load 는 N+1 을 막지만, **목록에 필요 없는 관계까지 로드하면** 이번에는 페이로드가 커진다. 한 페이지를 여는 것만으로 그 페이지 전 행의 하위 컬렉션이 통째로 메모리에 올라오고 응답에 실린다.
```php
// ❌ Resource 는 whenLoaded 로 방어하는데 Repository 가 목록에서 무조건 로드
// → 가드가 항상 참이 되어 무력화된다
$listRelations = ['categories', 'images', 'brand', 'options'];
// ✅ 목록이 실제로 직렬화하는 관계만. 개수·합계는 집계로
$listRelations = ['categories', 'images', 'brand'];
// withCount: ['options as options_count'], outerUsing: fn ($q) => $q->withSum(...)
```
로드 여부를 정하는 곳은 Repository 다. Resource 의 `whenLoaded` 는 그 결정을 **따르는** 장치이지, 그 자체가 페이로드를 줄이지는 않는다.
목록에서 뺀 관계는 단건 조회가 공급해야 한다. 이때 **단건이 목록용 조회 메서드를 재사용하지 않도록** 한다 — 목록 조회는 컬럼을 좁히고 건수 상한을 두므로, 재사용하면 단건이 그 제약을 그대로 물려받아 값이 비거나 상한 밖 행을 찾지 못한다.
```php
// ❌ 단건이 목록용 조회를 재사용 → content 소실 + 상한 밖 행은 못 찾음
$version = $this->repository->getVersions($id)->firstWhere('version', $version);
// ✅ 단건 전용 조회 (전 컬럼 + 키로 직접 조회)
$version = $this->repository->findVersionByNumber($id, $version);
```
### 대표 1건만 필요하면 관계를 1건으로 좁힌다
```php
// ❌ eager load 의 limit 은 부모별이 아니라 배치 전체에 걸린다
// → 첫 주문만 옵션을 받고 나머지는 빈 값
->with(['options' => fn ($q) => $q->limit(1)])
// ✅ 관계 자체를 "가장 오래된 1건" 으로 정의해 부모별 1건 보장
->with('firstOption') // hasOne(...)->oldestOfMany()
```
---
## 목록 조회 컬럼 프루닝과 지연 조인