perf(core): JSON 서빙 네트워크 오버헤드 최적화
실측(g7.dev) 결과 줄바꿈 제거·gzip 은 이미 적용된 상태였고, 남은 실질 개선 3종을 구현: - 모든 API 응답에 JSON_UNESCAPED_UNICODE 적용 — 한글이 \uXXXX(6B)로 팽창하던 것을 raw UTF-8(3B)로 직렬화 (lang pack gzip -9.1%) - 공개 레이아웃 서빙에서 개발자용 comment/_comment 재귀 제거, 편집 모드(with_source_meta)는 보존 (layout gzip -34.9%) - 번들 JS/CSS(BinaryFileResponse) gzip 압축 사각지대 해소 — 기존 미들웨어가 getContent==false 로 압축을 건너뛰던 문제 (-73~77%)
This commit is contained in:
1 parent
a958443d69
commit
c819820573
11 files changed
+638
-145
No files matched your search
@@ -787,4 +787,123 @@ class LayoutServingTest extends TestCase
|
||||
// Assert: 압축 후 크기가 50% 이상 감소
|
||||
$this->assertLessThan($originalSize * 0.5, $compressedSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 서빙 응답에서 개발자용 comment / _comment 필드가 제거되어야 합니다.
|
||||
*
|
||||
* 레이아웃 JSON 의 comment/_comment 는 편집기·개발자용 주석으로 런타임 렌더러가
|
||||
* 사용하지 않으며, 공개 응답 크기를 키우므로 서빙 시 재귀적으로 제거된다.
|
||||
*/
|
||||
public function test_strips_developer_comments_from_public_serving(): void
|
||||
{
|
||||
$template = Template::create([
|
||||
'identifier' => 'sirsoft-admin_basic',
|
||||
'vendor' => 'sirsoft',
|
||||
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
'version' => '1.0.0',
|
||||
'type' => 'admin',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
]);
|
||||
|
||||
$layout = TemplateLayout::create([
|
||||
'template_id' => $template->id,
|
||||
'name' => 'commented',
|
||||
'content' => [
|
||||
'comment' => '레이아웃 최상단 개발자 주석',
|
||||
'meta' => ['title' => 'Commented Layout'],
|
||||
'data_sources' => [
|
||||
[
|
||||
'_comment' => '데이터소스 설명 주석',
|
||||
'id' => 'stats',
|
||||
'type' => 'api',
|
||||
'endpoint' => '/api/stats',
|
||||
],
|
||||
],
|
||||
'components' => [
|
||||
[
|
||||
'comment' => '컴포넌트 설명 주석',
|
||||
'type' => 'div',
|
||||
'props' => ['class' => 'container'],
|
||||
'children' => [
|
||||
[
|
||||
'_comment' => '중첩 자식 주석',
|
||||
'type' => 'span',
|
||||
'props' => [],
|
||||
'children' => ['Hello'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 응답 본문 어디에도 comment / _comment 키가 없어야 함
|
||||
$body = $response->getContent();
|
||||
$this->assertStringNotContainsString('"comment"', $body);
|
||||
$this->assertStringNotContainsString('"_comment"', $body);
|
||||
|
||||
// 실제 콘텐츠(컴포넌트 구조)는 보존
|
||||
$data = $response->json('data');
|
||||
$this->assertArrayNotHasKey('comment', $data);
|
||||
$this->assertSame('div', $data['components'][0]['type']);
|
||||
$this->assertArrayNotHasKey('comment', $data['components'][0]);
|
||||
$this->assertArrayNotHasKey('_comment', $data['components'][0]['children'][0]);
|
||||
$this->assertArrayNotHasKey('_comment', $data['data_sources'][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글이 포함된 응답이 \uXXXX 이스케이프 없이 raw UTF-8 로 직렬화되어야 합니다.
|
||||
*
|
||||
* JSON_UNESCAPED_UNICODE 적용으로 멀티바이트 문자의 전송 크기를 줄인다.
|
||||
*/
|
||||
public function test_serves_korean_as_unescaped_utf8(): void
|
||||
{
|
||||
$template = Template::create([
|
||||
'identifier' => 'sirsoft-admin_basic',
|
||||
'vendor' => 'sirsoft',
|
||||
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
'version' => '1.0.0',
|
||||
'type' => 'admin',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
]);
|
||||
|
||||
$layout = TemplateLayout::create([
|
||||
'template_id' => $template->id,
|
||||
'name' => 'korean',
|
||||
'content' => [
|
||||
'meta' => ['title' => '한글 제목'],
|
||||
'data_sources' => [],
|
||||
'components' => [
|
||||
[
|
||||
'type' => 'span',
|
||||
'props' => [],
|
||||
'children' => ['안녕하세요'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// 압축 없이 raw 본문 검사 (Accept-Encoding 미지정)
|
||||
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$body = $response->getContent();
|
||||
|
||||
// raw UTF-8 한글이 그대로 실려야 함
|
||||
$this->assertStringContainsString('한글 제목', $body);
|
||||
$this->assertStringContainsString('안녕하세요', $body);
|
||||
|
||||
// \uXXXX 이스케이프 형태가 아니어야 함 (예: '한' = 한)
|
||||
$this->assertStringNotContainsString('\\ud55c', $body);
|
||||
|
||||
// 메시지도 raw UTF-8 (레이아웃 제공 성공 메시지)
|
||||
$this->assertMatchesRegularExpression('/[가-힣]/u', $body);
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,90 @@ class LayoutSourceMetaServingTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* comment / _comment 필드를 가진 레이아웃을 생성합니다.
|
||||
*
|
||||
* @param Template $template 대상 템플릿
|
||||
* @return TemplateLayout 생성된 레이아웃
|
||||
*/
|
||||
private function createCommentedLayout(Template $template): TemplateLayout
|
||||
{
|
||||
return TemplateLayout::create([
|
||||
'template_id' => $template->id,
|
||||
'name' => 'commented',
|
||||
'content' => [
|
||||
'comment' => '레이아웃 최상단 개발자 주석',
|
||||
'meta' => ['title' => 'Commented'],
|
||||
'components' => [
|
||||
[
|
||||
'comment' => '컴포넌트 설명 주석',
|
||||
'type' => 'basic',
|
||||
'name' => 'Div',
|
||||
'props' => ['className' => 'container'],
|
||||
'children' => [
|
||||
[
|
||||
'_comment' => '중첩 자식 주석',
|
||||
'type' => 'basic',
|
||||
'name' => 'Span',
|
||||
'text' => 'Hello',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 모드(with_source_meta) 서빙에서는 comment 필드가 보존되어야 합니다.
|
||||
*
|
||||
* 편집기는 comment 를 편집 가능한 속성으로 노출하므로 편집 경로에서는 제거하지 않는다.
|
||||
* 공개 서빙과 별도 캐시 키로 분리되어 조건부 제거가 안전함을 함께 검증한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function serve_with_source_meta_preserves_developer_comments(): void
|
||||
{
|
||||
$template = $this->createActiveTemplate();
|
||||
$this->createCommentedLayout($template);
|
||||
|
||||
$admin = $this->makeEditor();
|
||||
Sanctum::actingAs($admin, ['*'], 'sanctum');
|
||||
|
||||
$response = $this->getJson("/api/layouts/{$template->identifier}/commented.json?with_source_meta=1");
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 편집 모드에서는 comment / _comment 가 보존됨
|
||||
$data = $response->json('data');
|
||||
$this->assertSame('레이아웃 최상단 개발자 주석', $data['comment'] ?? null);
|
||||
$this->assertSame('컴포넌트 설명 주석', $data['components'][0]['comment'] ?? null);
|
||||
$this->assertSame('중첩 자식 주석', $data['components'][0]['children'][0]['_comment'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 서빙(옵션 미전달)에서는 comment 필드가 제거되어야 합니다.
|
||||
*
|
||||
* 편집 경로와 동일 레이아웃 소스를 사용해 공개/편집 캐시 키 분리를 함께 검증한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function serve_without_source_meta_strips_developer_comments(): void
|
||||
{
|
||||
$template = $this->createActiveTemplate();
|
||||
$this->createCommentedLayout($template);
|
||||
|
||||
$response = $this->getJson("/api/layouts/{$template->identifier}/commented.json");
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$body = $response->getContent();
|
||||
$this->assertStringNotContainsString('"comment"', $body);
|
||||
$this->assertStringNotContainsString('"_comment"', $body);
|
||||
|
||||
// 실제 콘텐츠는 보존
|
||||
$data = $response->json('data');
|
||||
$this->assertSame('Div', $data['components'][0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 트리에서 특정 키가 존재하는지 재귀 탐색
|
||||
*/
|
||||
|
||||
@@ -251,4 +251,131 @@ class GzipEncodeResponseTest extends TestCase
|
||||
// 압축 후 크기가 50% 이상 감소해야 함
|
||||
$this->assertLessThan($originalSize * 0.5, $compressedSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 파일을 생성하고 테스트 종료 시 정리되도록 등록합니다.
|
||||
*
|
||||
* @param string $content 파일 내용
|
||||
* @return string 생성된 임시 파일 경로
|
||||
*/
|
||||
private function makeTempFile(string $content): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'gzip_test_');
|
||||
file_put_contents($path, $content);
|
||||
$this->beforeApplicationDestroyed(function () use ($path) {
|
||||
@unlink($path);
|
||||
});
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* BinaryFileResponse(번들 JS/CSS 서빙)도 gzip 압축되어야 합니다.
|
||||
*
|
||||
* 기존에는 BinaryFileResponse.getContent() 가 false 를 반환해 미들웨어가
|
||||
* 압축 없이 통과시켜 번들이 비압축 전송되던 사각지대를 해소한 회귀 테스트입니다.
|
||||
*/
|
||||
public function test_compresses_binary_file_response(): void
|
||||
{
|
||||
// 1KB 이상의 JS 번들 파일 (압축 대상 크기 충족)
|
||||
$jsContent = str_repeat("console.log('bundle chunk');\n", 200);
|
||||
$path = $this->makeTempFile($jsContent);
|
||||
|
||||
$middleware = new GzipEncodeResponse;
|
||||
|
||||
$request = Request::create('/api/modules/bundle.js', 'GET');
|
||||
$request->headers->set('Accept-Encoding', 'gzip');
|
||||
|
||||
$response = $middleware->handle($request, function () use ($path) {
|
||||
return response()->file($path, ['Content-Type' => 'text/javascript']);
|
||||
});
|
||||
|
||||
// 압축 적용 확인
|
||||
$this->assertEquals('gzip', $response->headers->get('Content-Encoding'));
|
||||
|
||||
// 압축 해제 시 원본 파일 내용 복원
|
||||
$decompressed = gzdecode($response->getContent());
|
||||
$this->assertNotFalse($decompressed);
|
||||
$this->assertEquals($jsContent, $decompressed);
|
||||
|
||||
// Content-Length 는 압축된 크기와 일치
|
||||
$this->assertEquals(
|
||||
strlen($response->getContent()),
|
||||
(int) $response->headers->get('Content-Length')
|
||||
);
|
||||
|
||||
// Vary: Accept-Encoding 헤더 설정
|
||||
$this->assertStringContainsString(
|
||||
'Accept-Encoding',
|
||||
$response->headers->get('Vary', '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BinaryFileResponse 압축 시 원본 응답의 캐싱 헤더가 승계되어야 합니다.
|
||||
*/
|
||||
public function test_binary_file_response_preserves_headers(): void
|
||||
{
|
||||
$cssContent = str_repeat('.selector{color:red}', 100);
|
||||
$path = $this->makeTempFile($cssContent);
|
||||
|
||||
$middleware = new GzipEncodeResponse;
|
||||
|
||||
$request = Request::create('/api/plugins/bundle.css', 'GET');
|
||||
$request->headers->set('Accept-Encoding', 'gzip');
|
||||
|
||||
$response = $middleware->handle($request, function () use ($path) {
|
||||
$fileResponse = response()->file($path, [
|
||||
'Content-Type' => 'text/css',
|
||||
'ETag' => '"test-etag"',
|
||||
]);
|
||||
$fileResponse->headers->set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
|
||||
return $fileResponse;
|
||||
});
|
||||
|
||||
$this->assertEquals('gzip', $response->headers->get('Content-Encoding'));
|
||||
$this->assertEquals('"test-etag"', $response->headers->get('ETag'));
|
||||
$this->assertStringContainsString('immutable', $response->headers->get('Cache-Control', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작은 BinaryFileResponse(1KB 미만)는 압축하지 않아야 합니다.
|
||||
*/
|
||||
public function test_does_not_compress_small_binary_file_response(): void
|
||||
{
|
||||
$path = $this->makeTempFile('small');
|
||||
|
||||
$middleware = new GzipEncodeResponse;
|
||||
|
||||
$request = Request::create('/api/modules/bundle.js', 'GET');
|
||||
$request->headers->set('Accept-Encoding', 'gzip');
|
||||
|
||||
$response = $middleware->handle($request, function () use ($path) {
|
||||
return response()->file($path, ['Content-Type' => 'text/javascript']);
|
||||
});
|
||||
|
||||
// 1KB 미만은 압축 생략 — 원본 BinaryFileResponse 그대로 통과
|
||||
$this->assertNull($response->headers->get('Content-Encoding'));
|
||||
}
|
||||
|
||||
/**
|
||||
* gzip 미지원 클라이언트에는 BinaryFileResponse 를 압축하지 않아야 합니다.
|
||||
*/
|
||||
public function test_does_not_compress_binary_file_response_without_gzip_support(): void
|
||||
{
|
||||
$jsContent = str_repeat("console.log('x');\n", 200);
|
||||
$path = $this->makeTempFile($jsContent);
|
||||
|
||||
$middleware = new GzipEncodeResponse;
|
||||
|
||||
// Accept-Encoding 헤더 없음
|
||||
$request = Request::create('/api/modules/bundle.js', 'GET');
|
||||
|
||||
$response = $middleware->handle($request, function () use ($path) {
|
||||
return response()->file($path, ['Content-Type' => 'text/javascript']);
|
||||
});
|
||||
|
||||
$this->assertNull($response->headers->get('Content-Encoding'));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user