perf(core): 레이아웃 목록 응답에서 편집 대상 아닌 본문 제외

레이아웃 코드 편집 화면이 목록을 부를 때 모든 레이아웃의 본문(content)까지 함께
내려받고 있었다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB 가
브라우저에 쌓여 화면이 크게 느려졌고, 디버그 모드에서는 탭이 메모리 부족으로
종료됐다. 목록 화면은 본문을 쓰지 않고 선택한 파일 하나만 편집한다.

목록 전용 Resource 를 두어 이름·설명·크기·수정일만 싣고, 본문은 상세 조회에서만
읽는다. 크기는 목록에서 표시하므로 저장소가 길이만 계산해 돌려준다.
This commit is contained in:
HeuJung
2026-08-04 09:22:00 +09:00
parent 953616552f
commit 19789433df
9 changed files with 496 additions and 11 deletions
+4
View File
@@ -75,6 +75,10 @@
### Fixed
#### 레이아웃 코드 편집
- 레이아웃 코드 편집 화면이 파일 목록을 불러올 때 편집하지 않는 레이아웃의 본문까지 전부 내려받던 문제를 수정했습니다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB가 브라우저에 쌓여 화면이 크게 느려지거나, 디버그 모드에서는 탭이 메모리 부족으로 종료됐습니다. 이제 목록에는 이름·설명·크기·수정일만 실리고 본문은 선택한 파일만 불러옵니다.
#### 설정 화면 입력
- 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다.
@@ -6,6 +6,7 @@ use App\Enums\LayoutSourceType;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
interface LayoutRepositoryInterface
{
@@ -17,6 +18,17 @@ interface LayoutRepositoryInterface
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 목록 표시용 경량 레이아웃 행 조회
*
* 본문(`content`)을 반환에 포함하지 않는다 — 설명·크기 등 본문 파생값만 계산해 담는다.
* 편집 대상 본문은 상세 조회가 제공한다.
*
* @param int $templateId 템플릿 ID
* @return Collection<int, array> 목록 행 배열 컬렉션
*/
public function getListByTemplateId(int $templateId): SupportCollection;
/**
* 특정 레이아웃 조회 (템플릿 ID와 이름으로)
*
@@ -6,6 +6,7 @@ use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Layout\StoreLayoutPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutContentRequest;
use App\Http\Resources\LayoutListResource;
use App\Http\Resources\LayoutResource;
use App\Http\Resources\LayoutVersionResource;
use App\Services\LayoutPreviewService;
@@ -38,15 +39,19 @@ class LayoutController extends AdminBaseController
return $this->notFound('common.not_found');
}
$layouts = $this->layoutService->getLayoutsByTemplateId($template->id);
// 목록은 본문(`content`)을 담지 않는 경량 조회를 쓴다 — 파일 목록 화면은 이름·설명·
// 크기·수정일만 표시하고, 편집 대상 본문은 상세 엔드포인트가 따로 제공한다.
// 종전에는 목록·상세가 같은 Resource 를 공용해 102개 레이아웃의 본문이 전부 실렸고
// (응답 18.30MB 중 content 17.41MB), 디버그 모드에서 렌더러가 메모리 한계를 넘었다.
$layouts = $this->layoutService->getLayoutListByTemplateId($template->id);
// 레이아웃 이름 → 라우트 path 매핑 — 코드 편집기가 파일 선택 시 ?route= URL
// 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용한다.
$routePathMap = $this->templateService->getLayoutRoutePathMap($templateName);
$collection = LayoutResource::collection($layouts);
$collection = LayoutListResource::collection($layouts);
$collection->collection->transform(
fn (LayoutResource $resource) => $resource->withRoutePathMap($routePathMap)
fn (LayoutListResource $resource) => $resource->withRoutePathMap($routePathMap)
);
return $this->success('common.success', $collection);
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Resources;
use Carbon\Carbon;
use Illuminate\Http\Request;
/**
* 레이아웃 **목록** 전용 리소스
*
* 코드 편집 화면의 파일 목록은 행마다 이름·설명·라우트·크기·수정일·업데이트 표시만 쓴다.
* 편집 대상 본문은 선택 시 상세 엔드포인트(`current_layout`)가 따로 받아온다.
*
* 종전에는 목록·상세가 {@see LayoutResource} 하나를 공용해 목록 응답에도 각 레이아웃의
* `content`(본문 전체) · `components` · `data_sources` · `metadata` 가 실렸다.
* 실측(sirsoft-admin_basic): 응답 18.30MB 중 `content` 가 17.41MB(95%). 화면은 102개 중
* 1개 본문만 필요한데 101개를 더 받아 전역 상태에 상주시켰고, 디버그 모드에서는 그 상태를
* 추적하다 렌더러가 메모리 한계를 넘었다.
*
* 본문에서 파생되는 값(`description` · `size`)은 조회 계층이 미리 계산해 넘긴다 —
* 이 리소스는 본문을 보지 않는다.
*
* @since 7.0.6
*/
class LayoutListResource extends BaseApiResource
{
/** @var array<string, string|null> 레이아웃 이름 → 라우트 path 매핑 */
private array $routePathMap = [];
/**
* 라우트 path 매핑을 주입합니다.
*
* @param array<string, string|null> $routePathMap 레이아웃 이름 → 라우트 path
* @return $this
*/
public function withRoutePathMap(array $routePathMap): self
{
$this->routePathMap = $routePathMap;
return $this;
}
/**
* 목록 행을 배열로 변환합니다.
*
* @param Request $request 요청 객체
* @return array 목록 행 데이터
*/
public function toArray(Request $request): array
{
$name = $this->getValue('name');
$size = (int) $this->getValue('size', 0);
return [
'id' => $this->getValue('id'),
'template_id' => $this->getValue('template_id'),
'name' => $name,
'description' => $this->getValue('description') ?: $name,
// 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 파일 선택 시 ?route=
// 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용.
'route_path' => $this->routePathMap[$name] ?? null,
'size' => $size,
'size_formatted' => $this->formatFileSize($size),
'has_update' => false,
// 낙관적 잠금 — 편집 진입 후 저장 요청에 expected_lock_version 으로 전달
'lock_version' => (int) $this->getValue('lock_version', 0),
// 상세 응답(LayoutResource)의 formatTimestamps() 와 같은 규약 — 사용자 timezone
// 기준 문자열이다. 원시 ISO 를 그대로 내보내면 목록의 수정일 표기만 형식이 달라진다.
'created_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('created_at'))),
'updated_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('updated_at'))),
];
}
/**
* 값을 Carbon 인스턴스로 변환합니다.
*
* 경량 조회는 배열 행을 넘기므로 값이 Carbon 일 수도, 문자열일 수도, null 일 수도 있다.
*
* @param mixed $value 변환할 값
* @return Carbon|null 변환된 Carbon 또는 null
*/
private function toCarbon(mixed $value): ?Carbon
{
if ($value === null || $value === '') {
return null;
}
return $value instanceof Carbon ? $value : Carbon::parse($value);
}
/**
* 파일 크기를 사람이 읽는 단위로 변환합니다.
*
* @param int $bytes 바이트 수
* @return string 변환된 크기 문자열
*/
private function formatFileSize(int $bytes): string
{
if ($bytes < 1024) {
return $bytes.' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1).' KB';
}
return round($bytes / 1048576, 1).' MB';
}
}
+53
View File
@@ -7,9 +7,13 @@ use App\Enums\LayoutSourceType;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
class LayoutRepository implements LayoutRepositoryInterface
{
/** @var int 목록 조회 시 본문을 흘려 읽는 청크 크기 */
private const LIST_CHUNK_SIZE = 25;
/**
* 특정 템플릿의 모든 레이아웃 조회
*
@@ -23,6 +27,55 @@ class LayoutRepository implements LayoutRepositoryInterface
->get();
}
/**
* 목록 표시용 경량 레이아웃 행을 조회합니다.
*
* 목록 화면은 이름·설명·크기·수정일만 쓰고 본문은 상세 엔드포인트가 따로 제공한다.
* 그런데 `getByTemplateId()` 로 전체 컬럼을 읽으면 `content`(레이아웃 본문)까지 전부
* 메모리에 올라간다 — sirsoft-admin_basic 기준 102행 합계 17MB 이상이다.
*
* 여기서는 chunk 로 흘려 읽으며 본문에서 파생되는 값(설명·크기)만 뽑고 본문 자체는
* 즉시 버린다. 결과적으로 반환 컬렉션에 본문이 남지 않고, PHP 피크 메모리도 청크
* 단위로 묶인다.
*
* @param int $templateId 대상 템플릿 ID
* @return Collection<int, array> 목록 행 배열 (id, template_id, name, description, size, lock_version, created_at, updated_at)
*/
public function getListByTemplateId(int $templateId): SupportCollection
{
$rows = collect();
// 순회 커서는 기본키다 — `chunkById` 는 `where id > lastId` 로 다음 페이지를 잡으므로
// 여기에 `orderBy('name')` 을 함께 걸면 정렬 순서와 커서 기준이 어긋나 행이 조용히
// 누락된다(실측: 102행 중 58행만 반환). 표시 정렬은 순회를 마친 뒤에 적용한다.
TemplateLayout::query()
->where('template_id', $templateId)
->select(['id', 'template_id', 'name', 'content', 'lock_version', 'created_at', 'updated_at'])
->chunkById(self::LIST_CHUNK_SIZE, function (Collection $chunk) use ($rows) {
foreach ($chunk as $layout) {
$content = $layout->content;
$contentJson = is_array($content)
? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: '{}';
$rows->push([
'id' => $layout->id,
'template_id' => $layout->template_id,
'name' => $layout->name,
// 본문에서 파생 — 이 값만 남기고 본문은 버린다
'description' => $content['meta']['description'] ?? null,
'size' => strlen($contentJson),
'lock_version' => $layout->lock_version,
'created_at' => $layout->created_at,
'updated_at' => $layout->updated_at,
]);
}
});
// 표시 정렬은 순회와 분리 — 커서 기준(id)과 섞으면 행이 누락된다
return $rows->sortBy('name', SORT_STRING)->values();
}
/**
* 특정 레이아웃 조회 (템플릿 ID와 이름으로)
*
+22
View File
@@ -1350,6 +1350,28 @@ class LayoutService
return $layouts;
}
/**
* 목록 표시용 경량 레이아웃 행을 조회합니다.
*
* 본문을 반환에 담지 않으므로 파일 목록 화면이 편집 대상이 아닌 레이아웃의 본문까지
* 내려받는 일이 없다. 편집 대상 본문은 {@see getLayoutByName()} 이 제공한다.
*
* @param int $templateId 대상 템플릿 ID
* @return \Illuminate\Support\Collection<int, array> 목록 행 배열 컬렉션
*/
public function getLayoutListByTemplateId(int $templateId)
{
// Before 훅 - 레이아웃 목록 조회 전
HookManager::doAction('core.layout.before_index', $templateId);
$rows = $this->layoutRepository->getListByTemplateId($templateId);
// After 훅 - 레이아웃 목록 조회 후
HookManager::doAction('core.layout.after_index', $rows, $templateId);
return $rows;
}
/**
* 특정 레이아웃 조회 (이름으로)
*
+51 -4
View File
@@ -2155,13 +2155,60 @@ Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
**응답 필드** (`data` 내부, 배열의 각 항목)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| id | integer | 레이아웃 ID |
| template_id | integer | 소속 템플릿 ID |
| name | string | 레이아웃 이름 (예: `_admin_base`, `admin_user_list`) |
| description | string | 레이아웃 설명. 본문의 `meta.description` 에서 파생하며 없으면 이름을 그대로 사용 |
| route_path | string\|null | 이 레이아웃을 사용하는 라우트 path (routes.json 기준). 매핑이 없으면 null |
| size | integer | 본문 크기(바이트) |
| size_formatted | string | 사람이 읽는 크기 표기 (예: `176.9 KB`) |
| has_update | boolean | 템플릿 파일 대비 갱신 여부 (현재 항상 false) |
| lock_version | integer | 낙관적 잠금 버전. 편집 저장 시 `expected_lock_version` 으로 전달 |
| created_at | string | 생성 일시 |
| updated_at | string | 최종 수정 일시 |
목록 응답에는 본문(`content`)과 본문에서 파생되는 구조(`components` · `data_sources` · `metadata` · `endpoint`)가 **포함되지 않습니다**. 파일 목록 화면은 위 필드만 사용하고, 편집 대상 본문은 상세 엔드포인트(`GET .../layouts/{name}`)가 제공합니다. 목록에 본문을 함께 실으면 레이아웃 수에 비례해 응답이 커져(실측: 102개 기준 18MB) 브라우저 메모리를 압박합니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "요청이 성공했습니다.",
"data": [
{
"id": 1,
"template_id": 1,
"name": "_admin_base",
"description": "관리자 기본 레이아웃",
"route_path": null,
"size": 181146,
"size_formatted": "176.9 KB",
"has_update": false,
"lock_version": 3,
"created_at": "2026-07-28T09:35:50.000000Z",
"updated_at": "2026-07-28T09:35:50.000000Z"
},
{
"id": 2,
"template_id": 1,
"name": "admin_user_list",
"description": "사용자 관리",
"route_path": "*/admin/users",
"size": 42130,
"size_formatted": "41.1 KB",
"has_update": false,
"lock_version": 0,
"created_at": "2026-07-28T09:35:50.000000Z",
"updated_at": "2026-07-28T09:35:50.000000Z"
}
]
}
```
**에러 응답**
@@ -2173,7 +2220,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 특정 템플릿의 모든 레이아웃 목록을 조회합니다. 각 레이아웃에 이름 → 라우트 path 매핑을 부착해, 코드 편집기가 파일 선택 시 `?route=` URL 동기화나 위지윅에서 넘어온 라우트로 파일을 복원할 수 있게 합니다(LayoutService::getLayoutsByTemplateId). `core.templates.read` 권한이 필요합니다.
**설명** 특정 템플릿의 모든 레이아웃 목록을 조회합니다. 각 레이아웃에 이름 → 라우트 path 매핑을 부착해, 코드 편집기가 파일 선택 시 `?route=` URL 동기화나 위지윅에서 넘어온 라우트로 파일을 복원할 수 있게 합니다(LayoutService::getLayoutListByTemplateId). 목록은 본문을 담지 않는 경량 조회를 사용합니다. `core.templates.read` 권한이 필요합니다.
### GET /api/admin/templates/{templateName}/layouts/{name}
@@ -149,6 +149,9 @@ class LayoutControllerTest extends TestCase
->getJson("/api/admin/templates/{$this->template->identifier}/layouts");
// Assert
// 목록은 파일 목록 표시에 필요한 필드만 담는다 — 본문(content) 및 본문 파생 구조
// (components/data_sources/metadata/endpoint)는 상세 엔드포인트가 제공한다.
// @see LayoutIndexPayloadPruningTest
$response->assertStatus(200)
->assertJsonStructure([
'success',
@@ -158,11 +161,12 @@ class LayoutControllerTest extends TestCase
'id',
'template_id',
'name',
'endpoint',
'description',
'route_path',
'components',
'data_sources',
'metadata',
'size',
'size_formatted',
'lock_version',
'updated_at',
],
],
]);
@@ -0,0 +1,225 @@
<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 레이아웃 목록 API 페이로드 프루닝
*
* `GET /api/admin/templates/{templateName}/layouts` 는 코드 편집 화면의 **파일 목록**을
* 그리는 용도다. 그런데 목록·상세 공용 Resource 를 쓰면서 목록 응답에도 각 레이아웃의
* `content`(본문 전체) · `components` · `data_sources` · `metadata` 를 담아 왔다.
*
* 실측(sirsoft-admin_basic): 응답 **18.30MB**, 그중 `content` 가 **17.41MB(95%)**.
* 화면은 102개 파일 중 편집 중인 1개의 본문만 필요하고 그것은 `current_layout` 이 따로
* 받아오는데도, 나머지 101개 본문까지 매번 내려받아 전역 상태에 상주시켰다.
* 디버그 모드에서는 DevTools 가 이 상태를 추적하다 렌더러가 메모리 한계를 넘었다.
*
* 이 테스트는 목록 응답에 본문 계열 필드가 실리지 않는 것과, 화면이 실제로 쓰는 필드는
* 그대로 유지되는 것을 함께 잠근다.
*/
class LayoutIndexPayloadPruningTest extends TestCase
{
use RefreshDatabase;
protected array $requiredExtensions = [
'plugins/sirsoft-gdpr',
];
private Template $template;
private string $token;
/** 코드 편집 화면이 파일 목록 행에서 실제로 소비하는 필드 */
private const LIST_FIELDS = ['name', 'description', 'route_path', 'size_formatted', 'updated_at', 'has_update'];
/** 목록에 실리면 안 되는 본문 계열 필드 */
private const HEAVY_FIELDS = ['content', 'components', 'data_sources', 'metadata'];
protected function setUp(): void
{
parent::setUp();
$permissionIds = [];
foreach (['core.templates.read', 'core.templates.layouts.edit'] as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
]
);
$permissionIds[] = $permission->id;
}
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'extension_type' => ExtensionOwnerType::Core,
'is_system' => true,
'priority' => 0,
]
);
$adminRole->permissions()->syncWithoutDetaching($permissionIds);
$admin = User::factory()->create();
$admin->roles()->syncWithoutDetaching([$adminRole->id]);
$this->token = $admin->createToken('test-token')->plainTextToken;
$this->template = Template::factory()->create();
// 본문이 큰 레이아웃 3개 — 목록에 본문이 실리면 응답이 그만큼 부풀어야 한다
foreach (['_admin_base', 'admin_settings', 'admin_user_list'] as $i => $name) {
TemplateLayout::factory()->create([
'template_id' => $this->template->id,
'name' => $name,
'content' => [
'version' => '1.0.0',
'layout_name' => $name,
'meta' => ['description' => "설명 {$name}"],
'data_sources' => [['id' => 'ds_'.$i, 'endpoint' => '/api/x']],
'components' => array_fill(0, 200, [
'type' => 'basic',
'name' => 'Div',
'props' => ['className' => str_repeat('padding-filler ', 40)],
]),
],
]);
}
}
#[Test]
public function 목록_응답에_본문_계열_필드가_실리지_않는다(): void
{
$response = $this->withToken($this->token)
->getJson("/api/admin/templates/{$this->template->identifier}/layouts");
$response->assertOk();
$rows = $response->json('data');
$this->assertNotEmpty($rows);
foreach ($rows as $row) {
foreach (self::HEAVY_FIELDS as $field) {
$this->assertArrayNotHasKey(
$field,
$row,
"목록 응답에 본문 계열 필드 '{$field}' 가 실렸다 — 화면은 이 필드를 쓰지 않는다"
);
}
}
}
#[Test]
public function 목록_응답은_화면이_쓰는_필드를_유지한다(): void
{
$response = $this->withToken($this->token)
->getJson("/api/admin/templates/{$this->template->identifier}/layouts");
$response->assertOk();
$rows = $response->json('data');
foreach ($rows as $row) {
foreach (self::LIST_FIELDS as $field) {
$this->assertArrayHasKey($field, $row, "목록 표시에 필요한 '{$field}' 가 빠졌다");
}
// 낙관적 잠금 버전도 목록에서 유지되어야 한다 (편집 진입 시 사용)
$this->assertArrayHasKey('lock_version', $row);
}
$names = array_column($rows, 'name');
$this->assertContains('admin_settings', $names);
// description 은 content.meta.description 에서 파생된다 — 본문을 안 실어도 값은 나와야 한다
$settings = collect($rows)->firstWhere('name', 'admin_settings');
$this->assertSame('설명 admin_settings', $settings['description']);
// size_formatted 는 본문 크기 표기 — 0 바이트로 무너지면 안 된다
$this->assertNotSame('0 B', $settings['size_formatted']);
}
#[Test]
public function 상세_응답은_본문을_그대로_제공한다(): void
{
$response = $this->withToken($this->token)
->getJson("/api/admin/templates/{$this->template->identifier}/layouts/admin_settings");
$response->assertOk();
$row = $response->json('data');
$this->assertArrayHasKey('content', $row, '상세는 편집 대상 본문을 반드시 제공해야 한다');
$this->assertNotEmpty($row['content']);
$this->assertStringContainsString('admin_settings', $row['content']);
}
/**
* 청크 순회가 행을 누락하지 않는지 확인한다.
*
* `chunkById()` 는 키셋(`where id > lastId`)으로 다음 페이지를 잡는다. 여기에 다른
* 컬럼(`name`) 정렬을 함께 걸면 정렬 순서와 커서 기준이 어긋나 **행이 조용히 누락**된다.
* 실측으로 102행 중 58행만 반환된 적이 있다 — 예외도 오류도 없이 목록만 줄어든다.
*/
#[Test]
public function 청크_순회가_행을_누락하지_않는다(): void
{
// id 순서와 name 순서가 어긋나도록 이름을 역순으로 만든다
for ($i = 0; $i < 60; $i++) {
TemplateLayout::factory()->create([
'template_id' => $this->template->id,
'name' => sprintf('zz_layout_%03d', 100 - $i),
'content' => ['version' => '1.0.0', 'meta' => ['description' => "d{$i}"]],
]);
}
$expected = TemplateLayout::where('template_id', $this->template->id)->count();
$response = $this->withToken($this->token)
->getJson("/api/admin/templates/{$this->template->identifier}/layouts");
$response->assertOk();
$rows = $response->json('data');
$this->assertCount(
$expected,
$rows,
'목록 응답 행 수가 DB 행 수와 다르다 — 청크 순회에서 행이 누락됐다'
);
// 이름 오름차순 정렬 계약도 유지되어야 한다
$names = array_column($rows, 'name');
$sorted = $names;
sort($sorted, SORT_STRING);
$this->assertSame($sorted, $names, '목록은 이름 오름차순으로 정렬되어야 한다');
}
#[Test]
public function 목록_응답_크기가_본문_총량에_비례해_커지지_않는다(): void
{
$response = $this->withToken($this->token)
->getJson("/api/admin/templates/{$this->template->identifier}/layouts");
$response->assertOk();
$payloadBytes = strlen($response->getContent());
$contentBytes = TemplateLayout::where('template_id', $this->template->id)
->get()
->sum(fn ($layout) => strlen(json_encode($layout->content)));
$this->assertGreaterThan(0, $contentBytes);
$this->assertLessThan(
$contentBytes,
$payloadBytes,
'목록 응답이 본문 총량보다 크다 — 본문이 그대로 실리고 있다'
);
}
}