관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다. 같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다. 근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서 관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을 정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면 정상이라 오류도 경고도 없이 페이로드만 불어난다. 목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB 집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의 limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다. 뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와 무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다. 배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어 제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다. 재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
18 KiB
API 리소스
목적: API 응답 데이터 변환을 위한 리소스 클래스 규칙
TL;DR (5초 요약)
1. Resource: BaseApiResource 상속 필수 / Collection: BaseApiCollection 상속 필수
2. 민감 정보 제외: created_by, updated_by, deleted_at
3. 관계 데이터: whenLoaded() 사용
4. when() 주의: 커스텀 메서드에서는 삼항 연산자 사용 (MissingValue 누출 방지)
5. 권한 메타 (Resource): ...$this->resourceMeta($request) 스프레드로 is_owner + abilities(can_*) 표준화
6. 권한 메타 (Collection): abilityMap() 오버라이드 + resolveCollectionAbilities($request) — 페이지 버튼 제어
목차
기본 규칙
BaseApiResource / BaseApiCollection 상속 필수
모든 API 리소스는 BaseApiResource를, 모든 API 컬렉션은 BaseApiCollection을 상속해야 합니다.
// 단건 리소스
use App\Http\Resources\BaseApiResource;
class ProductResource extends BaseApiResource
{
// ...
}
// 목록 컬렉션
use App\Http\Resources\BaseApiCollection;
class ProductCollection extends BaseApiCollection
{
// ...
}
BaseApiCollection은 HasRowNumber trait과 abilityMap() + resolveCollectionAbilities() 메서드를 제공합니다. 컬렉션 레벨 abilities가 필요하면 abilityMap()을 오버라이드합니다.
민감한 정보 제외
보안을 위해 다음 필드는 API 응답에서 제외합니다:
created_by- 생성자 IDupdated_by- 수정자 IDdeleted_at- 삭제 시간- 비밀번호, 토큰 등 인증 정보
관계 데이터 처리
관계 데이터는 whenLoaded를 사용하여 조건부로 포함합니다:
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// ✅ 관계가 로드된 경우에만 포함
'category' => new CategoryResource($this->whenLoaded('category')),
'images' => ProductImageResource::collection($this->whenLoaded('images')),
];
}
권한 메타 표준화 (is_owner + abilities)
모든 API 리소스 응답에 is_owner와 abilities (can_*) 필드를 표준화합니다.
기본 사용법
toArray() 마지막에 ...$this->resourceMeta($request)를 스프레드합니다:
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// ... 기타 필드
// ✅ 표준 권한 메타 (is_owner + abilities)
...$this->resourceMeta($request),
];
}
응답 형식
{
"id": 1,
"name": "상품명",
"is_owner": true,
"abilities": {
"can_update": true,
"can_delete": false
}
}
is_owner: 현재 요청 사용자가 리소스 소유자인지 여부 (ownerField()미정의 시 생략)abilities:can_*키로 통합된 권한 플래그 (abilityMap()미정의 시 생략)
ownerField() — 소유자 판단
리소스의 소유자 필드명을 반환합니다:
// 기본값: null (is_owner 생략)
protected function ownerField(): ?string
{
return 'user_id'; // 또는 'created_by' 등
}
abilityMap() — 정적 권한 매핑
권한 식별자를 can_* 키로 매핑합니다. 대부분의 리소스에서 사용합니다:
protected function abilityMap(): array
{
return [
'can_create' => 'sirsoft-ecommerce.products.create',
'can_update' => 'sirsoft-ecommerce.products.update',
'can_delete' => 'sirsoft-ecommerce.products.delete',
];
}
resolveAbilities() — 동적 권한 (고급)
slug 기반 동적 식별자가 필요한 경우 resolveAbilities()를 오버라이드합니다:
// Board 모듈 PostResource 패턴
protected function resolveAbilities(Request $request): array
{
$slug = $this->getSlug($request);
if (! $slug) {
return [];
}
$abilityMap = $this->isAdminRequest($request)
? [
'can_read' => "sirsoft-board.{$slug}.admin.posts.read",
'can_write' => "sirsoft-board.{$slug}.admin.posts.write",
]
: [
'can_read' => "sirsoft-board.{$slug}.posts.read",
'can_write' => "sirsoft-board.{$slug}.posts.write",
];
return $this->resolveAbilitiesFromMap($abilityMap, $request->user());
}
컬렉션 레벨 abilities (BaseApiCollection)
페이지 레벨 버튼 제어용 abilities는 BaseApiCollection의 abilityMap()을 오버라이드합니다. Controller가 아닌 Collection에서 담당합니다:
use App\Http\Resources\BaseApiCollection;
class BrandCollection extends BaseApiCollection
{
protected function abilityMap(): array
{
return [
'can_create' => 'sirsoft-ecommerce.brands.create',
'can_delete' => 'sirsoft-ecommerce.brands.delete',
];
}
public function toArray(Request $request): array
{
$abilities = $this->resolveCollectionAbilities($request);
return [
'data' => $this->mapWithRowNumber(function ($brand) {
return (new BrandResource($brand))->toArray(request());
}),
'pagination' => [ /* ... */ ],
...($abilities ? ['abilities' => $abilities] : []),
];
}
}
abilities가 불필요한 컬렉션은 abilityMap()을 오버라이드하지 않습니다 (기본값 빈 배열).
커스텀 메서드에서의 사용
toListArray() 등 커스텀 메서드에서도 동일하게 스프레드합니다:
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ✅ 커스텀 메서드에서도 동일 패턴
...$this->resourceMeta(request()),
];
}
권한 체크 및 scope 기반 abilities
resolveAbilitiesFromMap()은 각 ability에 대해 다음 순서로 확인합니다:
- 권한 체크 (
PermissionHelper::check()) - scope 기반 접근 체크 (
PermissionHelper::checkScopeAccess()) — 리소스 모델이 있는 경우
scope_type에 따라 리소스 소유자 기반으로 ability가 자동 제한됩니다. 별도의 바이패스 맵 정의가 불필요합니다.
상세: permissions.md scope_type 시스템 참조
$this->when() 사용 주의사항
필수: 커스텀 메서드에서는 삼항 연산자 사용 ($this->when() 금지)
필수: 커스텀 메서드에서는 삼항 연산자 사용
문제 원인
Laravel의 $this->when() 메서드는 조건이 false일 때 MissingValue 객체를 반환합니다. 이 객체는 Laravel이 toArray()를 처리할 때만 자동으로 필터링됩니다. toListArray(), withAdminInfo() 등 커스텀 메서드에서는 필터링되지 않아 빈 객체 {}가 JSON 응답에 포함됩니다.
발생하는 문제
- React Error #31: "Objects are not valid as a React child"
- 프론트엔드에서 빈 화면 표시
- 데이터 바인딩 실패
잘못된 예시 (❌ DON'T)
// ❌ 커스텀 메서드에서 $this->when() 사용 - MissingValue 객체 누출
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ❌ email_verified_at이 null이면 빈 객체 {} 반환
'email_verified_at' => $this->when(
$this->getValue('email_verified_at'),
fn () => $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
),
];
}
올바른 예시 (✅ DO)
// ✅ 커스텀 메서드에서는 삼항 연산자 사용
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ✅ null 반환으로 안전하게 처리
'email_verified_at' => $this->getValue('email_verified_at')
? $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
: null,
];
}
사용 가능/금지 장소
| 장소 | $this->when() 사용 |
|---|---|
toArray() 메서드 내부 |
✅ 가능 (Laravel이 MissingValue 자동 필터링) |
toListArray() 등 커스텀 메서드 |
❌ 금지 |
toProfileArray() 등 커스텀 메서드 |
❌ 금지 |
withAdminInfo() 등 추가 정보 메서드 |
❌ 금지 |
toArray() 외부에서 수동 호출되는 모든 메서드 |
❌ 금지 |
PHPDoc 작성 권장
커스텀 메서드임을 명시하여 주의사항을 알립니다:
/**
* 목록용 간단한 형태의 배열을 반환합니다.
*
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
*/
public function toListArray(): array
목록 응답의 하위 컬렉션
목록은 화면이 그 행에서 실제로 그리는 것만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다.
가짜 가드
// ❌ 가드처럼 보이지만 가드가 아니다
'options' => $this->relationLoaded('options')
? ProductOptionResource::collection($this->options)
: $this->whenLoaded('activeOptions'),
Repository 가 목록 쿼리에서 options 를 무조건 eager load 하면 이 조건은 항상 참이 되어, 방어하는 것처럼 보이는 코드가 실제로는 아무것도 막지 못한다.
// ✅ whenLoaded 하나만 — 로드 여부는 Repository 가 결정한다
'options' => $this->whenLoaded('options', fn () => ProductOptionResource::collection($this->options)),
개수·합계는 DB 집계로
배열을 실어 보내고 화면에서 세지 않는다. PHP 컬렉션 연산($this->options->where(...)->sum(...))도 같은 문제다 — 세려면 이미 전부 메모리에 올라와 있어야 한다.
// ✅ Repository 의 withCount / withSum 결과를 그대로 노출
'options_count' => $this->whenHas('options_count', fn () => (int) $this->options_count),
집계 별칭의 존재 여부는 값 검사(!== null)로 판정할 수 없다. SUM 은 대상 0건에서 NULL 을 돌려주므로 "집계하지 않았다" 와 "집계했더니 비어 있다" 가 구분되지 않는다. 속성 키의 존재 여부(array_key_exists)만이 두 상황을 가른다.
대표 1건이 필요할 때
목록이 하위 컬렉션의 첫 1건만 그린다면 관계 자체를 1건으로 좁힌다. eager load 의 limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸리므로 첫 행만 값을 받고 나머지는 빈 값이 된다.
// 모델 — 관계를 "가장 오래된 1건" 으로 정의
public function firstOption(): HasOne
{
return $this->hasOne(OrderOption::class, 'order_id')->oldestOfMany();
}
목록 표현은 명시 호출
toListArray() 를 정의해 두고 컬렉션이 toArray() 를 호출하면 경량 표현은 쓰이지 않는다. 컨트롤러나 컬렉션이 목록 표현을 명시적으로 부르는지 확인한다.
// ✅ 컬렉션이 목록 표현을 명시 호출
'data' => $this->collection->map(fn ($row) => (new SampleResource($row))->toListArray($request))->all(),
이 형태는 Laravel 의 MissingValue 제거 단계를 거치지 않으므로, 조건부 필드를 직접 걸러내야 한다(위 "커스텀 메서드" 절 참조). 걸러내지 않으면 미충족 필드가 {} 로 응답에 남는다.
목록 Resource 안에서 관계를 재쿼리하지 않는다
// ❌ eager load 된 컬렉션을 무시하고 행마다 쿼리
'thumbnail' => $this->images()->first()?->url,
// ✅ 로드된 컬렉션에서 고른다
'thumbnail' => $this->relationLoaded('images')
? $this->images->firstWhere('is_thumbnail', true)?->url
: null,
뺀 값에는 대체 경로가 있어야 한다
목록에서 제거한 값은 (a) 집계로 대체되거나 (b) 단건 조회에 그대로 남아 있어야 한다. 어느 쪽도 아니면 그것은 성능 개선이 아니라 기능 삭제다.
착수 전 소비처를 실측한다. 화면이 그 값을 실제로 순회·렌더하면 제거는 기능 축소다 — 계획에 "안 쓴다" 고 적혀 있어도 레이아웃 JSON 을 열어 확인한다.
단건 조회가 대체 경로라면, 단건이 목록용 조회를 재사용하지 않는지 확인한다. 목록 조회는 컬럼을 좁히고 건수 상한을 두므로, 단건이 그것을 재사용하면 목록 최적화가 그대로 단건의 기능 삭제가 된다.
패턴 예시
기본 리소스 클래스
<?php
namespace Modules\Sirsoft\Ecommerce\Resources;
use App\Http\Resources\BaseApiResource;
class ProductResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'price' => $this->price,
'is_active' => $this->is_active,
'category' => new CategoryResource($this->whenLoaded('category')),
'images' => ProductImageResource::collection($this->whenLoaded('images')),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
// created_by, updated_by는 제외 (보안)
];
}
}
커스텀 메서드가 있는 리소스
<?php
namespace App\Http\Resources;
class UserResource extends BaseApiResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// ✅ toArray() 내부에서는 when() 사용 가능
'role' => $this->when($this->role, fn () => $this->role->name),
];
}
/**
* 목록용 간단한 배열 반환
*
* 주의: $this->when() 대신 삼항 연산자 사용
*/
public function toListArray(): array
{
return [
'id' => $this->getValue('id'),
'name' => $this->getValue('name'),
// ✅ 삼항 연산자 사용
'email_verified' => $this->getValue('email_verified_at') ? true : false,
];
}
}
ResourceCollection 커스텀 메서드
ResourceCollection에 커스텀 메서드를 추가하여 다양한 응답 형식을 지원할 수 있습니다.
패턴
class UserCollection extends ResourceCollection
{
/**
* 통계 정보를 추가하여 반환
*
* @param array $statistics 통계 데이터
* @return array
*/
public function withStatistics(array $statistics): array
{
return [
'data' => $this->collection,
'statistics' => $statistics,
];
}
/**
* 관리자용 추가 정보를 포함하여 반환
*
* @return array
*/
public function withAdminInfo(): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'admin_info' => true,
],
];
}
}
컨트롤러에서 사용
public function index(UserListRequest $request): JsonResponse
{
$users = $this->userService->getPaginatedUsers($request->validated());
$statistics = $this->userService->getStatistics();
$collection = new UserCollection($users);
return $this->success(
'user.fetch_success',
$collection->withStatistics($statistics)
);
}
주요 커스텀 메서드 유형
| 메서드 | 용도 | 반환 데이터 |
|---|---|---|
withStatistics() |
통계 데이터 추가 | data + statistics |
withAdminInfo() |
관리자 전용 메타 추가 | data + admin meta |
toListArray() |
목록 전용 간소화 응답 | 축소된 필드 |
toSimpleArray() |
최소 필드 응답 | id + name만 |
Resource에서 다중 응답 형식
Resource 클래스에서도 메서드별로 다른 응답 형식을 제공할 수 있습니다:
class UserResource extends BaseApiResource
{
/**
* 목록용 간소화 응답
*/
public function toListArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status,
];
}
/**
* 프로필용 응답
*/
public function toProfileArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'avatar' => $this->avatar_url,
'created_at' => $this->created_at?->toISOString(),
];
}
}
API 리소스 개발 체크리스트
□ BaseApiResource를 상속했는가?
□ 민감한 정보(created_by, updated_by, deleted_at)를 제외했는가?
□ 관계 데이터에 whenLoaded()를 사용했는가?
□ 커스텀 메서드에서 $this->when() 사용을 피했는가?
□ 커스텀 메서드에서 삼항 연산자를 사용했는가?
□ 날짜 필드에 toISOString()을 사용했는가?
□ ...$this->resourceMeta($request) 스프레드를 추가했는가?
□ ownerField()를 정의했는가? (소유자 판단 필요 시)
□ abilityMap()을 정의했는가? (권한 플래그 필요 시)
관련 문서
- index.md - 백엔드 가이드 인덱스
- controllers.md - 컨트롤러에서 리소스 사용
- response-helper.md - API 응답 규칙
- service-repository.md - Eager Loading으로 N+1 방지
- dto.md - Eloquent 모델이 아닌 값 묶음 (DTO) — ApiResource 와의 경계