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:
@@ -10,6 +10,13 @@
|
||||
|
||||
- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.
|
||||
- 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.
|
||||
- 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.
|
||||
- 화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
|
||||
|
||||
+104
-95
@@ -9,14 +9,23 @@ use Illuminate\Support\Facades\App;
|
||||
|
||||
class ResponseHelper
|
||||
{
|
||||
/**
|
||||
* JSON 응답 인코딩 옵션.
|
||||
*
|
||||
* JSON_UNESCAPED_UNICODE: 한글 등 멀티바이트 문자를 `\uXXXX` (6바이트) 로
|
||||
* 이스케이프하지 않고 raw UTF-8 (한글 3바이트) 로 직렬화하여 응답 크기를 줄인다.
|
||||
* 특히 다국어 팩(lang) 응답에서 전송량 절감 효과가 크다.
|
||||
*/
|
||||
public const JSON_ENCODE_OPTIONS = JSON_UNESCAPED_UNICODE;
|
||||
|
||||
/**
|
||||
* 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.success')
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.success')
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function success(
|
||||
@@ -29,18 +38,18 @@ class ResponseHelper
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
'data' => $data
|
||||
], $statusCode);
|
||||
'data' => $data,
|
||||
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.failed')
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.failed')
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function error(
|
||||
@@ -59,28 +68,28 @@ class ResponseHelper
|
||||
if (config('app.debug')) {
|
||||
// messageParams에 이미 에러 메시지가 포함된 경우 중복 방지
|
||||
if (empty($messageParams)) {
|
||||
$response['message'] .= ': ' . $errors->getMessage();
|
||||
$response['message'] .= ': '.$errors->getMessage();
|
||||
}
|
||||
$response['debug'] = self::formatException($errors);
|
||||
}
|
||||
} elseif ($errors !== null) {
|
||||
if ($statusCode >= 500 && is_string($errors) && !config('app.debug')) {
|
||||
if ($statusCode >= 500 && is_string($errors) && ! config('app.debug')) {
|
||||
// 프로덕션 500+ 에러의 string errors 차단 (내부 예외 메시지 노출 방지)
|
||||
} else {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($response, $statusCode);
|
||||
return response()->json($response, $statusCode, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 검증 실패 응답을 생성합니다.
|
||||
*
|
||||
* @param mixed $errors 검증 오류 정보
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed')
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param mixed $errors 검증 오류 정보
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed')
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse 422 상태 코드를 가진 JSON 응답
|
||||
*/
|
||||
public static function validationError(
|
||||
@@ -92,16 +101,16 @@ class ResponseHelper
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
'errors' => $errors
|
||||
], 422);
|
||||
'errors' => $errors,
|
||||
], 422, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 실패 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized')
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized')
|
||||
* @param array $messageParams 메시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse 401 상태 코드를 가진 JSON 응답
|
||||
*/
|
||||
public static function unauthorized(
|
||||
@@ -111,16 +120,16 @@ class ResponseHelper
|
||||
): JsonResponse {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain)
|
||||
], 401);
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
], 401, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 부족 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden')
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden')
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse 403 상태 코드를 가진 JSON 응답
|
||||
*/
|
||||
public static function forbidden(
|
||||
@@ -130,16 +139,16 @@ class ResponseHelper
|
||||
): JsonResponse {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain)
|
||||
], 403);
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
], 403, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스를 찾을 수 없음 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.not_found')
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.not_found')
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse 404 상태 코드를 가진 JSON 응답
|
||||
*/
|
||||
public static function notFound(
|
||||
@@ -149,17 +158,17 @@ class ResponseHelper
|
||||
): JsonResponse {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain)
|
||||
], 404);
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
], 404, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 내부 오류 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred')
|
||||
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred')
|
||||
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse 500 상태 코드를 가진 JSON 응답
|
||||
*/
|
||||
public static function serverError(
|
||||
@@ -175,22 +184,22 @@ class ResponseHelper
|
||||
|
||||
if ($error instanceof \Throwable) {
|
||||
if (config('app.debug')) {
|
||||
$response['message'] .= ': ' . $error->getMessage();
|
||||
$response['message'] .= ': '.$error->getMessage();
|
||||
$response['debug'] = self::formatException($error);
|
||||
}
|
||||
} elseif ($error !== null && config('app.debug')) {
|
||||
$response['error'] = $error;
|
||||
}
|
||||
|
||||
return response()->json($response, 500);
|
||||
return response()->json($response, 500, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 메시지를 변환합니다.
|
||||
*
|
||||
* @param string $key 번역 키
|
||||
* @param array $params 번역 매개변수
|
||||
* @param string $domain 번역 도메인
|
||||
* @param string $key 번역 키
|
||||
* @param array $params 번역 매개변수
|
||||
* @param string $domain 번역 도메인
|
||||
* @return string 번역된 메시지
|
||||
*/
|
||||
private static function trans(
|
||||
@@ -199,10 +208,10 @@ class ResponseHelper
|
||||
string $domain = 'core'
|
||||
): string {
|
||||
$locale = self::getUserLocale();
|
||||
|
||||
|
||||
// 도메인별로 다른 경로에서 번역 파일 로드
|
||||
$translationKey = $domain === 'core' ? $key : "{$domain}::{$key}";
|
||||
|
||||
|
||||
return __($translationKey, $params, $locale);
|
||||
}
|
||||
|
||||
@@ -225,7 +234,7 @@ class ResponseHelper
|
||||
/**
|
||||
* 예외 정보를 디버그용 배열로 변환합니다.
|
||||
*
|
||||
* @param \Throwable $e 예외 인스턴스
|
||||
* @param \Throwable $e 예외 인스턴스
|
||||
* @return array 디버그 정보 배열
|
||||
*/
|
||||
private static function formatException(\Throwable $e): array
|
||||
@@ -238,7 +247,7 @@ class ResponseHelper
|
||||
'trace' => collect($e->getTrace())->take(10)->map(fn ($frame) => [
|
||||
'file' => $frame['file'] ?? null,
|
||||
'line' => $frame['line'] ?? null,
|
||||
'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''),
|
||||
'function' => ($frame['class'] ?? '').($frame['type'] ?? '').($frame['function'] ?? ''),
|
||||
])->toArray(),
|
||||
];
|
||||
}
|
||||
@@ -246,11 +255,11 @@ class ResponseHelper
|
||||
/**
|
||||
* 모듈별 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $module 모듈명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $module 모듈명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function moduleSuccess(
|
||||
@@ -266,11 +275,11 @@ class ResponseHelper
|
||||
/**
|
||||
* 모듈별 실패 응답을 생성합니다.
|
||||
*
|
||||
* @param string $module 모듈명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $module 모듈명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function moduleError(
|
||||
@@ -286,11 +295,11 @@ class ResponseHelper
|
||||
/**
|
||||
* 플러그인별 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $plugin 플러그인명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $plugin 플러그인명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param mixed $data 응답 데이터
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function pluginSuccess(
|
||||
@@ -306,11 +315,11 @@ class ResponseHelper
|
||||
/**
|
||||
* 플러그인별 실패 응답을 생성합니다.
|
||||
*
|
||||
* @param string $plugin 플러그인명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $plugin 플러그인명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
||||
* @param mixed $errors 오류 정보
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function pluginError(
|
||||
@@ -326,11 +335,11 @@ class ResponseHelper
|
||||
/**
|
||||
* JSON Resource를 사용한 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
|
||||
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
|
||||
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function successWithResource(
|
||||
@@ -341,22 +350,22 @@ class ResponseHelper
|
||||
string $domain = 'core'
|
||||
): JsonResponse {
|
||||
$data = $resource ? $resource->resolve() : null;
|
||||
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
'data' => $data
|
||||
], $statusCode);
|
||||
'data' => $data,
|
||||
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Resource를 사용한 모듈 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $module 모듈명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $module 모듈명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function moduleSuccessWithResource(
|
||||
@@ -372,11 +381,11 @@ class ResponseHelper
|
||||
/**
|
||||
* JSON Resource를 사용한 플러그인 성공 응답을 생성합니다.
|
||||
*
|
||||
* @param string $plugin 플러그인명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $plugin 플러그인명
|
||||
* @param string $messageKey 멤시지 키
|
||||
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
||||
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @return JsonResponse JSON 응답
|
||||
*/
|
||||
public static function pluginSuccessWithResource(
|
||||
@@ -392,21 +401,21 @@ class ResponseHelper
|
||||
/**
|
||||
* 페이지네이션된 리소스 응답을 생성합니다.
|
||||
*
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
|
||||
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
|
||||
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
|
||||
* @param array $messageParams 멤시지 매개변수
|
||||
* @param string $domain 번역 도메인 (기본값: 'core')
|
||||
* @return JsonResponse 페이지네이션 메타 데이터를 포함한 JSON 응답
|
||||
*/
|
||||
public static function successWithPagination(
|
||||
string $messageKey = 'messages.success',
|
||||
ResourceCollection|null $collection = null,
|
||||
?ResourceCollection $collection = null,
|
||||
array $messageParams = [],
|
||||
string $domain = 'core'
|
||||
): JsonResponse {
|
||||
$response = [
|
||||
'success' => true,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain)
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
];
|
||||
|
||||
if ($collection) {
|
||||
@@ -424,7 +433,7 @@ class ResponseHelper
|
||||
$response['links'] = $paginationData['links'] ?? null;
|
||||
}
|
||||
|
||||
return response()->json($response);
|
||||
return response()->json($response, 200, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,6 +453,6 @@ class ResponseHelper
|
||||
'error_code' => 'identity_verification_required',
|
||||
'message' => self::trans('identity.errors.verification_required', [], 'core'),
|
||||
'verification' => $verification,
|
||||
], 428);
|
||||
], 428, [], self::JSON_ENCODE_OPTIONS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,6 +807,12 @@ class LayoutService
|
||||
// 캐시 비활성 시 매번 병합 실행
|
||||
if (! $cacheEnabled) {
|
||||
$merged = $this->loadAndMergeLayoutInternal($templateId, $layoutName, $withSourceMeta);
|
||||
|
||||
// 공개 서빙 응답에서 개발자용 comment 필드 제거 (편집 모드는 보존)
|
||||
if (! $withSourceMeta) {
|
||||
$merged = $this->stripDeveloperComments($merged);
|
||||
}
|
||||
|
||||
HookManager::doAction('core.layout.after_load', $merged, $templateId, $layoutName, false);
|
||||
|
||||
return $merged;
|
||||
@@ -844,6 +850,12 @@ class LayoutService
|
||||
// 병합된 레이아웃 생성
|
||||
$mergedLayout = $this->loadAndMergeLayoutInternal($templateId, $layoutName, $withSourceMeta);
|
||||
|
||||
// 공개 서빙 응답에서 개발자용 comment 필드 제거 (편집 모드는 보존).
|
||||
// withSourceMeta 가 캐시 키에 반영되므로 공개/편집 캐시가 분리되어 안전.
|
||||
if (! $withSourceMeta) {
|
||||
$mergedLayout = $this->stripDeveloperComments($mergedLayout);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
$cacheTtl = $this->getCacheTtl();
|
||||
$this->cache->put($cacheKey, $mergedLayout, $cacheTtl);
|
||||
@@ -1601,6 +1613,30 @@ class LayoutService
|
||||
}, $components);
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합된 레이아웃에서 개발자용 comment 필드를 재귀적으로 제거합니다.
|
||||
*
|
||||
* 레이아웃 JSON 의 `comment` / `_comment` 키는 편집기(layout-editor)와 개발자를 위한
|
||||
* 설명 주석으로, 프론트엔드 런타임 렌더러(DynamicRenderer)는 이를 참조하지 않는다.
|
||||
* 공개 서빙 응답에서만 제거하여 전송 크기를 줄이며, 편집 모드(withSourceMeta)에서는
|
||||
* 호출하지 않아 보존한다. 배열/객체 노드를 재귀 순회하며 두 키만 unset 한다.
|
||||
*
|
||||
* @param array $node 정리 대상 레이아웃(또는 하위) 배열
|
||||
* @return array comment 필드가 제거된 배열
|
||||
*/
|
||||
private function stripDeveloperComments(array $node): array
|
||||
{
|
||||
unset($node['comment'], $node['_comment']);
|
||||
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->stripDeveloperComments($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트의 props를 sanitize합니다.
|
||||
*
|
||||
|
||||
@@ -587,6 +587,27 @@ class PublicLayoutController extends PublicBaseController
|
||||
|
||||
---
|
||||
|
||||
## JSON 인코딩 옵션 (JSON_UNESCAPED_UNICODE)
|
||||
|
||||
모든 `response()->json()` 호출은 `ResponseHelper::JSON_ENCODE_OPTIONS`(= `JSON_UNESCAPED_UNICODE`)를 인코딩 옵션으로 전달한다. 한글 등 멀티바이트 문자를 `\uXXXX`(6바이트)로 이스케이프하지 않고 raw UTF-8(한글 3바이트)로 직렬화하여 응답 전송 크기를 줄인다. 다국어 팩(lang) 응답처럼 한글 비중이 큰 페이로드에서 절감 효과가 크다.
|
||||
|
||||
```php
|
||||
// ✅ 올바른 사용 — 네 번째 인자로 옵션 전달
|
||||
return response()->json($payload, $status, [], ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||
|
||||
// 헤더 배열이 필요한 경우 (세 번째 인자)
|
||||
return response()->json($payload, $status, ['Cache-Control' => '...'], ResponseHelper::JSON_ENCODE_OPTIONS);
|
||||
|
||||
// ❌ 옵션 누락 — 한글이 \uXXXX 로 팽창
|
||||
return response()->json($payload, $status);
|
||||
```
|
||||
|
||||
`ResponseHelper` 의 모든 메서드(`success`/`error`/`validationError`/… )와 `BaseApiController::successWithCache`/`cachedJsonResponse` 는 이미 이 옵션을 적용한다. 컨트롤러/서비스는 `ResponseHelper` 를 사용하면 자동으로 적용되므로 별도 조치가 불필요하다. `response()->json()` 을 직접 호출하는 신규 코드에서만 옵션 전달을 잊지 않는다.
|
||||
|
||||
> 슬래시 언이스케이프(`JSON_UNESCAPED_SLASHES`)는 적용하지 않는다 — endpoint 경로의 `\/` 는 크기 영향이 미미하고, 기존 응답 본문 정확 매칭 테스트의 안전성을 우선한다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 백엔드 가이드 인덱스
|
||||
|
||||
@@ -435,6 +435,16 @@ php artisan template:cache-clear # 전체 번들 파일 정리 포함
|
||||
|
||||
> 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다.
|
||||
|
||||
### 전송 압축 (gzip)
|
||||
|
||||
번들 JS/CSS 는 `fileResponse()`(= `response()->file()` → `BinaryFileResponse`)로 서빙되며, `GzipEncodeResponse` 미들웨어가 gzip 압축을 적용한다. `BinaryFileResponse` 는 `getContent()` 가 `false` 를 반환하므로, 미들웨어는 파일 경로(`getFile()->getPathname()`)에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 `Response` 로 치환한다.
|
||||
|
||||
- 1KB 미만 번들(예: 빈 CSS)은 `MIN_COMPRESS_SIZE` 가드로 압축 생략.
|
||||
- `Accept-Encoding: gzip` 미포함 요청, 이미 `Content-Encoding` 이 있는 응답, 304 응답은 압축 대상에서 제외.
|
||||
- 회귀 테스트: `tests/Feature/Middleware/GzipEncodeResponseTest.php` (BinaryFileResponse 압축/헤더 승계/소용량 skip).
|
||||
|
||||
> `BinaryFileResponse` 를 압축 대상에 포함하지 않으면 번들이 비압축 전송되는 사각지대가 생긴다(모듈/플러그인 번들은 크기가 커 압축 이득이 특히 크다).
|
||||
|
||||
---
|
||||
|
||||
## 에셋 로딩 전략
|
||||
|
||||
@@ -554,7 +554,7 @@ Laravel의 `FormRequest::validated()` 메서드는 `rules()`에 정의된 필드
|
||||
| `id` | string | ✅ | 컴포넌트 고유 ID |
|
||||
| `type` | string | ✅ | 컴포넌트 타입 (basic/composite/layout) |
|
||||
| `name` | string | ✅ | 컴포넌트 이름 (components.json에 등록된 이름) |
|
||||
| `comment` | string | ✅ | 시안 추적 코드 및 역할 설명 (예: "F-002: 상품명 검색 필드") |
|
||||
| `comment` | string | ✅ | 시안 추적 코드 및 역할 설명 (예: "F-002: 상품명 검색 필드"). 공개 서빙 응답에서는 제거됨 (아래 참조) |
|
||||
| `props` | object | ❌ | 컴포넌트에 전달할 props |
|
||||
| `text` | string | ❌ | 텍스트 콘텐츠 (최우선 렌더링) |
|
||||
| `children` | array | ❌ | 자식 컴포넌트 배열 (레이아웃/집합 컴포넌트에서 사용) |
|
||||
@@ -575,6 +575,13 @@ Laravel의 `FormRequest::validated()` 메서드는 `rules()`에 정의된 필드
|
||||
> - `actions`: [layout-json-features.md](layout-json-features.md#액션-시스템-actions)
|
||||
> - `isolatedState`: [아래 섹션](#격리된-상태-isolatedstate)
|
||||
|
||||
### `comment` / `_comment` — 공개 서빙 응답에서 제거
|
||||
|
||||
`comment` / `_comment` 는 편집기·개발자용 설명 주석이며 런타임 렌더러는 참조하지 않는다. 공개 레이아웃 서빙(`GET /api/layouts/{template}/{layout}.json`) 응답에서는 전송 크기 절감을 위해 이 두 키가 노드 트리 전체에서 재귀적으로 제거된다(`LayoutService::stripDeveloperComments`).
|
||||
|
||||
- 편집 모드 서빙(`?with_source_meta=1`, 편집 권한 필요)에서는 편집기가 `comment` 를 편집 가능한 속성으로 노출하므로 **보존**된다. 공개/편집 응답은 별도 캐시 키로 분리되어 있어 조건부 제거가 안전하다.
|
||||
- 따라서 `comment` 는 편집기·저장본에서는 유지되고, 일반 사용자에게 서빙되는 응답에서만 사라진다. 레이아웃 로직이 `comment` 값에 의존해서는 안 된다.
|
||||
|
||||
---
|
||||
|
||||
## 격리된 상태 (isolatedState)
|
||||
|
||||
@@ -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