Files
HeuJung b5c0ec1178 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종과 규정 문서 항목을 함께 넣었다.
2026-08-05 21:20:05 +09:00

397 lines
12 KiB
PHP

<?php
namespace App\Http\Resources;
use App\Helpers\TimezoneHelper;
use App\Http\Resources\Traits\HasAbilityCheck;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
use Illuminate\Support\Facades\App;
class BaseApiResource extends JsonResource
{
use HasAbilityCheck;
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, mixed> 변환된 배열 데이터
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
/**
* 배열 데이터를 객체로 변환하여 접근 가능하게 합니다.
*
* @return object|mixed 객체로 변환된 리소스
*/
protected function asObject()
{
if (is_array($this->resource)) {
return (object) $this->resource;
}
return $this->resource;
}
/**
* 배열이나 객체에서 안전하게 값을 가져옵니다.
*
* @param string $key 가져올 키
* @param mixed $default 기본값 (키가 없을 경우)
* @return mixed 값 또는 기본값
*/
protected function getValue(string $key, $default = null)
{
if (is_array($this->resource)) {
return $this->resource[$key] ?? $default;
}
return $this->resource->{$key} ?? $default;
}
/**
* 리소스가 배열인지 확인합니다.
*
* @return bool 배열 여부
*/
protected function isArray(): bool
{
return is_array($this->resource);
}
/**
* 중첩된 배열을 재귀적으로 객체로 변환합니다.
*
* @param mixed $data 변환할 데이터
* @return mixed 객체로 변환된 데이터
*/
protected function arrayToObjectRecursive($data)
{
if (is_array($data)) {
return (object) array_map([$this, 'arrayToObjectRecursive'], $data);
}
return $data;
}
/**
* 매직 메서드 오버라이드: 배열일 때도 객체처럼 접근 가능하게 합니다.
*
* @param string $key 속성 키
* @return mixed 속성 값
*/
public function __get($key)
{
if (is_array($this->resource)) {
return $this->resource[$key] ?? null;
}
return parent::__get($key);
}
/**
* 배열/객체 상관없이 isset 체크를 수행합니다.
*
* @param string $key 확인할 키
* @return bool 존재 여부
*/
public function __isset($key)
{
if (is_array($this->resource)) {
return isset($this->resource[$key]);
}
return parent::__isset($key);
}
/**
* 공통 타임스탬프 형식을 반환합니다.
* 사용자 timezone으로 변환하여 Y-m-d H:i:s 형식으로 반환합니다.
*
* @return array<string, string|null> 포맷된 타임스탬프 배열
*/
protected function formatTimestamps(): array
{
return [
'created_at' => $this->when($this->created_at, fn () => $this->formatDateTimeStringForUser($this->created_at)),
'updated_at' => $this->when($this->updated_at, fn () => $this->formatDateTimeStringForUser($this->updated_at)),
];
}
/**
* 조건부 필드(`when()`)가 걸러지지 않은 배열에서 미충족 항목을 제거합니다.
*
* `toArray()` 결과는 Laravel 이 응답 직전에 `filter()` 로 `MissingValue` 를 걷어내지만,
* `toListArray()` 처럼 **배열을 그대로 컨트롤러에 넘기는 경로**는 그 단계를 거치지 않는다.
* 그대로 두면 조건 미충족 필드가 `"updated_at": {}` 같은 빈 객체로 응답에 실린다
* (예: `UPDATED_AT = null` 인 모델의 타임스탬프).
*
* @param array<string, mixed> $data 직렬화 결과
* @return array<string, mixed> 미충족 항목이 제거된 배열
*/
protected function withoutMissing(array $data): array
{
return array_filter($data, fn ($value) => ! $value instanceof MissingValue);
}
/**
* 쿼리에서 계산된 집계 별칭(`withCount`/`withSum` 등)이 실제로 실려 있는지 판정합니다.
*
* 값 검사(`!== null`, `> 0`)로는 판정할 수 없다. SUM 은 대상 0건에서 NULL 을 돌려주고
* COUNT 는 0 을 돌려주므로, "집계하지 않았다" 와 "집계했더니 비어 있다" 가 구분되지 않는다.
* 속성 키의 존재 여부만이 두 상황을 가른다.
*
* @param string $alias 집계 별칭 (예: `options_count`)
* @return bool 집계가 실려 있는지 여부
*/
protected function hasAggregate(string $alias): bool
{
$model = $this->unwrapResource();
return $model instanceof Model && array_key_exists($alias, $model->getAttributes());
}
/**
* 집계 별칭 값을 반환합니다. 집계가 실려 있지 않으면 기본값을 돌려줍니다.
*
* @param string $alias 집계 별칭
* @param mixed $default 집계 부재 시 사용할 값
* @return mixed 집계 값 또는 기본값
*/
protected function aggregate(string $alias, mixed $default = null): mixed
{
$model = $this->unwrapResource();
if (! $model instanceof Model || ! array_key_exists($alias, $model->getAttributes())) {
return $default;
}
return $model->getAttributes()[$alias];
}
/**
* 리소스 래핑을 벗겨 원본 모델을 반환합니다.
*
* Resource 가 Resource 를 감싸는 경우(컬렉션 매핑 등)가 있어 한 겹만 벗기면 모델에
* 닿지 못한다.
*
* @return mixed 래핑이 제거된 리소스 본체
*/
private function unwrapResource(): mixed
{
$model = $this->resource;
while ($model instanceof JsonResource) {
$model = $model->resource;
}
return $model;
}
/**
* datetime 값을 사용자 timezone으로 변환하여 ISO8601 문자열로 반환합니다.
*
* @param Carbon|null $datetime 변환할 datetime
* @return string|null ISO8601 형식의 datetime 문자열
*/
protected function formatDateTimeForUser(?Carbon $datetime): ?string
{
return TimezoneHelper::toUserTimezone($datetime);
}
/**
* datetime 값을 사용자 timezone으로 변환하여 날짜만 반환합니다 (Y-m-d 형식).
*
* @param Carbon|null $datetime 변환할 datetime
* @return string|null Y-m-d 형식의 날짜 문자열
*/
protected function formatDateForUser(?Carbon $datetime): ?string
{
return TimezoneHelper::toUserDateString($datetime);
}
/**
* datetime 값을 사용자 timezone으로 변환하여 Y-m-d H:i:s 형식으로 반환합니다.
*
* @param Carbon|null $datetime 변환할 datetime
* @return string|null Y-m-d H:i:s 형식의 datetime 문자열
*/
protected function formatDateTimeStringForUser(?Carbon $datetime): ?string
{
return TimezoneHelper::toUserDateTimeString($datetime);
}
/**
* datetime 값을 사이트 기본 timezone으로 변환하여 Y-m-d 형식으로 반환합니다.
*
* HTML <input type="date"> 호환용 (datepicker에 바인딩하는 날짜 필드에 사용).
*
* @param Carbon|null $datetime 변환할 datetime
* @return string|null Y-m-d 형식의 날짜 문자열
*/
protected function formatDateStringForSite(?Carbon $datetime): ?string
{
return TimezoneHelper::toSiteDateString($datetime);
}
/**
* datetime 값을 사이트 기본 timezone으로 변환하여 datetime-local 호환 형식으로 반환합니다.
*
* HTML <input type="datetime-local"> 호환용 (Y-m-d\TH:i 형식).
*
* @param Carbon|null $datetime 변환할 datetime
* @return string|null Y-m-d\TH:i 형식의 datetime 문자열
*/
protected function formatDateTimeLocalStringForSite(?Carbon $datetime): ?string
{
return TimezoneHelper::toSiteDateTimeLocalString($datetime);
}
/**
* 현재 요청의 타임존을 가져옵니다.
* SetTimezone 미들웨어에서 설정한 값을 사용하며, 미설정 시 기본값을 반환합니다.
*/
protected function getUserTimezone(): string
{
return TimezoneHelper::getUserTimezone();
}
/**
* 다국어 메시지와 함께 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 파라미터
* @param string $domain 메시지 도메인
* @return $this
*/
public function withMessage(string $messageKey, array $messageParams = [], string $domain = 'core')
{
return $this->additional([
'message_key' => $messageKey,
'message_params' => $messageParams,
'message_domain' => $domain,
]);
}
/**
* 다국어 필드 값을 현재 로케일에 맞게 반환합니다.
*
* @param string $field 필드명
* @return string|array|null 다국어 값
*/
protected function getLocalizedField(string $field)
{
$value = $this->getValue($field);
if ($value === null) {
return null;
}
// 이미 문자열이면 그대로 반환
if (is_string($value)) {
return $value;
}
// 배열(다국어)이면 현재 로케일에 맞는 값 반환
if (is_array($value)) {
$locale = App::getLocale();
return $value[$locale] ?? $value[config('app.fallback_locale', 'ko')] ?? reset($value) ?: null;
}
// Model의 getLocalizedName() 등의 메서드가 있으면 사용
$methodName = 'getLocalized'.ucfirst($field);
if (is_object($this->resource) && method_exists($this->resource, $methodName)) {
return $this->resource->{$methodName}();
}
return $value;
}
/**
* 소유자 필드명을 반환합니다.
* null 반환 시 is_owner 필드가 응답에서 제외됩니다.
* 자식 클래스에서 오버라이드합니다.
*
* @return string|null 소유자 필드명
*/
protected function ownerField(): ?string
{
return null;
}
/**
* 리소스별 능력(can_*) 매핑을 반환합니다.
* 자식 클래스에서 오버라이드합니다.
*
* @return array<string, string> ['can_update' => 'module.entity.update', ...]
*/
protected function abilityMap(): array
{
return [];
}
/**
* 소유자 여부를 계산합니다.
*
* @param Request $request HTTP 요청 객체
* @return bool|null 소유자 여부 (ownerField 미정의 시 null)
*/
protected function resolveIsOwner(Request $request): ?bool
{
$field = $this->ownerField();
if ($field === null) {
return null;
}
$ownerId = $this->resource->{$field};
if ($ownerId === null) {
return false;
}
return $request->user()?->id === $ownerId;
}
/**
* 능력 맵을 불리언으로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, bool> 능력 불리언 맵
*/
protected function resolveAbilities(Request $request): array
{
return $this->resolveAbilitiesFromMap($this->abilityMap(), $request->user());
}
/**
* is_owner + abilities 표준 필드를 반환합니다.
* 자식 클래스의 toArray()에서 spread 연산자로 합침.
*
* @param Request $request HTTP 요청 객체
* @return array<string, mixed> 메타 데이터
*/
protected function resourceMeta(Request $request): array
{
$meta = [];
$isOwner = $this->resolveIsOwner($request);
if ($isOwner !== null) {
$meta['is_owner'] = $isOwner;
}
$abilities = $this->resolveAbilities($request);
if (! empty($abilities)) {
$meta['abilities'] = $abilities;
}
return $meta;
}
}