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
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Base;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -20,11 +21,11 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function success(
|
||||
string $messageKey = 'common.success',
|
||||
@@ -38,11 +39,11 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* 실패 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param int $statusCode HTTP 상태 코드
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param int $statusCode HTTP 상태 코드
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function error(
|
||||
string $messageKey = 'common.failed',
|
||||
@@ -56,11 +57,11 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* 리소스와 함께 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param mixed $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param mixed $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function successWithResource(
|
||||
string $messageKey = 'common.success',
|
||||
@@ -74,7 +75,7 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* 현재 인증된 사용자를 반환합니다.
|
||||
*
|
||||
* @return \App\Models\User|null
|
||||
* @return User|null
|
||||
*/
|
||||
protected function getCurrentUser()
|
||||
{
|
||||
@@ -84,9 +85,9 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* Not Found 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function notFound(
|
||||
string $messageKey = 'common.not_found',
|
||||
@@ -98,9 +99,9 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* Unauthorized 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function unauthorized(
|
||||
string $messageKey = 'common.unauthorized',
|
||||
@@ -112,9 +113,9 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* Forbidden 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function forbidden(
|
||||
string $messageKey = 'common.forbidden',
|
||||
@@ -126,10 +127,10 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* Validation Error 응답을 생성합니다.
|
||||
*
|
||||
* @param mixed $errors 검증 오류
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @param mixed $errors 검증 오류
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function validationError(
|
||||
mixed $errors,
|
||||
@@ -142,9 +143,9 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* 파일 응답을 반환합니다 (ETag 및 캐싱 헤더 포함).
|
||||
*
|
||||
* @param string $filePath 파일 경로
|
||||
* @param string $mimeType MIME 타입
|
||||
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1년)
|
||||
* @param string $filePath 파일 경로
|
||||
* @param string $mimeType MIME 타입
|
||||
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1년)
|
||||
*/
|
||||
protected function fileResponse(string $filePath, string $mimeType, int $maxAge = 31536000): BinaryFileResponse|Response
|
||||
{
|
||||
@@ -176,35 +177,35 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
|
||||
*
|
||||
* @param mixed $data JSON으로 변환할 데이터
|
||||
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
|
||||
* @param int $status HTTP 상태 코드
|
||||
* @param mixed $data JSON으로 변환할 데이터
|
||||
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
|
||||
* @param int $status HTTP 상태 코드
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json($data, $status, [
|
||||
'Cache-Control' => "public, max-age={$maxAge}",
|
||||
]);
|
||||
], ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* ETag를 생성합니다.
|
||||
*
|
||||
* @param mixed $data 해시할 데이터
|
||||
* @param mixed $data 해시할 데이터
|
||||
* @return string ETag 값 (따옴표 포함)
|
||||
*/
|
||||
protected function generateETag(mixed $data): string
|
||||
{
|
||||
$content = is_string($data) ? $data : json_encode($data);
|
||||
|
||||
return '"' . md5($content) . '"';
|
||||
return '"'.md5($content).'"';
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 캐시가 유효한지 확인합니다.
|
||||
*
|
||||
* @param string $etag 현재 ETag 값
|
||||
* @param string $etag 현재 ETag 값
|
||||
* @return bool 캐시가 유효하면 true
|
||||
*/
|
||||
protected function isNotModified(string $etag): bool
|
||||
@@ -217,8 +218,8 @@ abstract class BaseApiController extends Controller
|
||||
/**
|
||||
* 304 Not Modified 응답을 반환합니다.
|
||||
*
|
||||
* @param string $etag ETag 값
|
||||
* @param int $maxAge 캐시 TTL (초)
|
||||
* @param string $etag ETag 값
|
||||
* @param int $maxAge 캐시 TTL (초)
|
||||
* @return Response 304 응답
|
||||
*/
|
||||
protected function notModifiedResponse(string $etag, int $maxAge = 3600): Response
|
||||
@@ -233,10 +234,10 @@ abstract class BaseApiController extends Controller
|
||||
*
|
||||
* 클라이언트의 ETag가 일치하면 304 Not Modified를 반환합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $maxAge 캐시 TTL (초, 기본: 1시간)
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $messageKey 메시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $maxAge 캐시 TTL (초, 기본: 1시간)
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @return JsonResponse|Response JSON 응답 또는 304 응답
|
||||
*/
|
||||
protected function successWithCache(
|
||||
@@ -256,7 +257,7 @@ abstract class BaseApiController extends Controller
|
||||
'success' => true,
|
||||
'message' => __($messageKey, $messageParams),
|
||||
'data' => $data,
|
||||
])
|
||||
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
|
||||
->header('ETag', $etag)
|
||||
->header('Cache-Control', "public, max-age={$maxAge}")
|
||||
->header('Vary', 'Accept-Encoding, Accept-Language');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,15 @@ class GzipEncodeResponse
|
||||
}
|
||||
|
||||
// 최소 크기 이상인지 확인
|
||||
// BinaryFileResponse 는 getContent() 가 false 를 반환하므로 파일 크기로 판정한다.
|
||||
// (번들 JS/CSS 서빙은 response()->file() 로 BinaryFileResponse 를 반환하며,
|
||||
// 이 분기가 없으면 미들웨어가 압축 없이 통과시켜 비압축 전송된다.)
|
||||
if ($response instanceof BinaryFileResponse) {
|
||||
$file = $response->getFile();
|
||||
|
||||
return $file !== null && $file->getSize() >= self::MIN_COMPRESS_SIZE;
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
if ($content === false || strlen($content) < self::MIN_COMPRESS_SIZE) {
|
||||
return false;
|
||||
@@ -158,6 +168,12 @@ class GzipEncodeResponse
|
||||
*/
|
||||
private function compressResponse(Response $response): Response
|
||||
{
|
||||
// BinaryFileResponse 는 본문을 setContent() 로 교체할 수 없으므로
|
||||
// 파일 내용을 읽어 일반 Response 로 치환한 뒤 압축한다 (헤더는 승계).
|
||||
if ($response instanceof BinaryFileResponse) {
|
||||
return $this->compressBinaryFileResponse($response);
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
|
||||
// gzip 압축 적용
|
||||
@@ -173,14 +189,70 @@ class GzipEncodeResponse
|
||||
$response->headers->set('Content-Encoding', 'gzip');
|
||||
$response->headers->set('Content-Length', (string) strlen($compressed));
|
||||
|
||||
// Vary 헤더가 없으면 추가 (캐시 프록시 지원)
|
||||
$this->ensureVaryAcceptEncoding($response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* BinaryFileResponse 를 gzip 압축된 일반 Response 로 치환합니다.
|
||||
*
|
||||
* BinaryFileResponse 는 파일 스트림 기반이라 setContent() 로 본문을 교체할 수 없으므로,
|
||||
* 파일 내용을 읽어 gzip 압축한 새 Response 를 생성하고 기존 헤더(Content-Type,
|
||||
* ETag, Cache-Control, Expires 등)를 승계한다. Content-Encoding/Content-Length 는
|
||||
* 압축 결과로 재설정한다.
|
||||
*
|
||||
* @param BinaryFileResponse $response 파일 응답
|
||||
* @return Response gzip 압축된 응답 (실패 시 원본 반환)
|
||||
*/
|
||||
private function compressBinaryFileResponse(BinaryFileResponse $response): Response
|
||||
{
|
||||
$file = $response->getFile();
|
||||
|
||||
if ($file === null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($file->getPathname());
|
||||
|
||||
if ($content === false) {
|
||||
// 파일 읽기 실패 시 원본(비압축) 반환
|
||||
return $response;
|
||||
}
|
||||
|
||||
$compressed = gzencode($content, self::COMPRESSION_LEVEL);
|
||||
|
||||
if ($compressed === false) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// 기존 헤더를 승계한 새 Response 생성 (본문만 압축된 내용으로 교체)
|
||||
$compressedResponse = new Response(
|
||||
$compressed,
|
||||
$response->getStatusCode(),
|
||||
$response->headers->all()
|
||||
);
|
||||
|
||||
$compressedResponse->headers->set('Content-Encoding', 'gzip');
|
||||
$compressedResponse->headers->set('Content-Length', (string) strlen($compressed));
|
||||
|
||||
$this->ensureVaryAcceptEncoding($compressedResponse);
|
||||
|
||||
return $compressedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답에 `Vary: Accept-Encoding` 헤더를 보장합니다 (캐시 프록시 지원).
|
||||
*
|
||||
* @param Response $response HTTP 응답
|
||||
*/
|
||||
private function ensureVaryAcceptEncoding(Response $response): void
|
||||
{
|
||||
if (! $response->headers->has('Vary')) {
|
||||
$response->headers->set('Vary', 'Accept-Encoding');
|
||||
} elseif (! str_contains($response->headers->get('Vary', ''), 'Accept-Encoding')) {
|
||||
$currentVary = $response->headers->get('Vary');
|
||||
$response->headers->set('Vary', $currentVary.', Accept-Encoding');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user