fix(core): SEO 봇 캐시·확장 번들 캐시의 정합 결함 5건 수정
KVE-2026-2191 대응에서 계획 안 세부를 자율 결정한 지점을 회귀 전수조사해 정합 결함 5건을 고쳤다. 넷은 같은 미출시 버전이 만든 자기교정이고, 마지막 하나는 7.0.10 및 그 이전부터 있던 결함이라 공개 CHANGELOG 에 항목을 넣었다. - 적중 통계의 레이아웃명이 항상 비어 화면별 표가 성립하지 않던 것 → 페이지 캐시 값에 레이아웃명을 함께 담고 적중 경로가 그것으로 귀속한다. 이전 버전이 문자열로만 저장한 항목은 그대로 읽어 배포 직후 살아 있는 캐시를 버리지 않는다. 인터페이스는 불변이고 코어 구현일 때만 항목 전체를 읽는다. - 통계 url 컬럼(255)이 캐시 키 URL(정규화 쿼리 최대 512바이트)보다 짧아 긴 주소의 기록이 엄격 모드에서 실패하고 그 예외를 서비스가 삼키던 것 → 768 로 확장(utf8mb4 인덱스 키 상한 3072바이트). down 은 잘리는 행을 먼저 지운다. - 경로당 변종 상한이 언어를 합산해 다국어 사이트의 실효 상한이 언어 수만큼 줄던 것 → 인덱스 항목이 url|locale 별이므로 경로·언어 조합 단위로 판정한다. - 그릴 게 없는 요청(미라우트 404·SEO 비활성)이 렌더 예산을 소모해, 캐시에 남지 않는 죽은 주소 재크롤이 정상 페이지의 예산을 태우던 것 → null 렌더는 차감을 환급한다. 렌더 도중 예외는 비용을 이미 치른 것이라 환급하지 않는다. - 병합 단계에서 건너뛴 확장이 있는 결과가 캐시로 굳어 버전 bump 전까지 그 확장 자산이 사라진 채 고정되던 것 → 병합 루프를 mergeJs/mergeCss 로 분리해 건너뛴 확장을 함께 돌려주고, 하나라도 있으면 캐시하지 않고 Log::error 로 남긴다. 캐시 없이 돌면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 즉시 회복한다. 기본 로그 수준이 error 라 종전 per-extension warning 은 기록되지 않아 흔적이 없었다. 회귀 테스트 8건은 red 확인 후 green 으로 전환했고, 변경 심볼의 소비자 모집단(테스트 클래스 9종)을 프로세스 분할로 재실행해 189건 통과를 확인했다.
This commit is contained in:
@@ -1434,7 +1434,7 @@ lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱
|
|||||||
- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다.
|
- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다.
|
||||||
- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat.
|
- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat.
|
||||||
- 프로덕션은 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 키는 `(type, kind, version)` 만으로 계산되는데 빌드를 앞세우면 캐시 적중에도 매 요청 활성 확장 열거·파일 읽기가 일어나고, 원본이 소실되면 멀쩡한 캐시를 두고 503 이 된다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴하고 잠금 뒤 캐시를 재확인하며, 잠금 대기 초과·저장소 장애는 실패가 아니라 각자 빌드로 폴백한다.
|
- 프로덕션은 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 키는 `(type, kind, version)` 만으로 계산되는데 빌드를 앞세우면 캐시 적중에도 매 요청 활성 확장 열거·파일 읽기가 일어나고, 원본이 소실되면 멀쩡한 캐시를 두고 503 이 된다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴하고 잠금 뒤 캐시를 재확인하며, 잠금 대기 초과·저장소 장애는 실패가 아니라 각자 빌드로 폴백한다.
|
||||||
- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이다.
|
- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)·병합 단계에서 건너뛴 확장이 있는 결과(굳지 않도록 매 요청 재시도)·디스크 쓰기 실패뿐이다.
|
||||||
|
|
||||||
### 빌드 명령어 (Artisan)
|
### 빌드 명령어 (Artisan)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
- 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
|
- 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
|
||||||
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
|
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
|
||||||
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
|
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
|
||||||
|
- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다.
|
||||||
|
|
||||||
## [7.0.10] - 2026-09-06
|
## [7.0.10] - 2026-09-06
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,21 @@ final class SeoCacheBounds
|
|||||||
RateLimiter::hit('seo-render:'.$ip, 60);
|
RateLimiter::hit('seo-render:'.$ip, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차감한 미스 렌더 1건을 예산에 되돌립니다.
|
||||||
|
*
|
||||||
|
* 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청 — 미라우트 404, SEO 비활성 화면 — 은
|
||||||
|
* 캐시에 남지 않아 올 때마다 다시 예산을 쓴다. 봇은 예전에 있던 죽은 주소를 오래 다시
|
||||||
|
* 긁으므로, 그 요청까지 세면 정상 페이지의 예산이 죽은 주소에 소진된다. 렌더 도중 예외는
|
||||||
|
* 비용을 이미 치른 것이라 되돌리지 않는다.
|
||||||
|
*
|
||||||
|
* @param string $ip 요청 IP
|
||||||
|
*/
|
||||||
|
public static function refundRender(string $ip): void
|
||||||
|
{
|
||||||
|
RateLimiter::decrement('seo-render:'.$ip, 60);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이 IP 의 요청을 통계로 기록할 수 있는지 판정합니다.
|
* 이 IP 의 요청을 통계로 기록할 수 있는지 판정합니다.
|
||||||
*
|
*
|
||||||
@@ -127,11 +142,15 @@ final class SeoCacheBounds
|
|||||||
* 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가
|
* 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가
|
||||||
* 늘지 않기 때문이다.
|
* 늘지 않기 때문이다.
|
||||||
*
|
*
|
||||||
|
* 경로당 변종은 **언어별로** 센다. 인덱스 항목은 url|locale 별이라 경로만 보고 합산하면
|
||||||
|
* 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 언어마다 캐시에서 빠진다.
|
||||||
|
*
|
||||||
* @param array<string, array<string, mixed>> $index 현재 캐시 인덱스
|
* @param array<string, array<string, mixed>> $index 현재 캐시 인덱스
|
||||||
* @param string $url 저장하려는 URL (경로 + 정규화 쿼리)
|
* @param string $url 저장하려는 URL (경로 + 정규화 쿼리)
|
||||||
|
* @param string $locale 저장하려는 로케일
|
||||||
* @return bool 저장 허용 여부
|
* @return bool 저장 허용 여부
|
||||||
*/
|
*/
|
||||||
public static function canStore(array $index, string $url): bool
|
public static function canStore(array $index, string $url, string $locale): bool
|
||||||
{
|
{
|
||||||
if (count($index) >= self::limit('max_entries', 20000)) {
|
if (count($index) >= self::limit('max_entries', 20000)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -141,6 +160,10 @@ final class SeoCacheBounds
|
|||||||
$variants = 0;
|
$variants = 0;
|
||||||
|
|
||||||
foreach ($index as $entry) {
|
foreach ($index as $entry) {
|
||||||
|
if (($entry['locale'] ?? null) !== $locale) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (self::pathOf((string) ($entry['url'] ?? '')) === $path) {
|
if (self::pathOf((string) ($entry['url'] ?? '')) === $path) {
|
||||||
$variants++;
|
$variants++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,41 @@ class SeoCacheManager implements SeoCacheManagerInterface
|
|||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
public function get(string $url, string $locale): ?string
|
public function get(string $url, string $locale): ?string
|
||||||
|
{
|
||||||
|
return $this->getEntry($url, $locale)['html'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 항목(HTML + 레이아웃명)을 조회합니다.
|
||||||
|
*
|
||||||
|
* 페이지는 레이아웃명과 함께 저장된다 — 캐시 적중 경로는 렌더러를 거치지 않아 요청
|
||||||
|
* 속성에 레이아웃명이 없고, 통계를 화면별로 귀속하려면 항목이 그것을 알아야 한다.
|
||||||
|
* 이전 버전이 문자열로만 저장한 항목은 레이아웃명 없이 그대로 읽힌다 — 배포 직후
|
||||||
|
* 살아 있는 캐시를 버리지 않는다.
|
||||||
|
*
|
||||||
|
* @param string $url URL
|
||||||
|
* @param string $locale 로케일
|
||||||
|
* @return array{html: string, layout: string|null}|null 캐시 항목 (없으면 null)
|
||||||
|
*/
|
||||||
|
public function getEntry(string $url, string $locale): ?array
|
||||||
{
|
{
|
||||||
if (! $this->isEnabled()) {
|
if (! $this->isEnabled()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$key = $this->buildKey($url, $locale);
|
$value = $this->cache->get($this->buildKey($url, $locale));
|
||||||
|
|
||||||
return $this->cache->get($key);
|
if (is_string($value)) {
|
||||||
|
return ['html' => $value, 'layout' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) && is_string($value['html'] ?? null)) {
|
||||||
|
$layout = $value['layout'] ?? null;
|
||||||
|
|
||||||
|
return ['html' => $value['html'], 'layout' => is_string($layout) ? $layout : null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,11 +286,11 @@ class SeoCacheManager implements SeoCacheManagerInterface
|
|||||||
// 스스로 줄지 않는다. 여기서 한 번 정리하지 않으면 상한이 "지금 저장된 양"이
|
// 스스로 줄지 않는다. 여기서 한 번 정리하지 않으면 상한이 "지금 저장된 양"이
|
||||||
// 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 닿은 경로는 실제
|
// 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 닿은 경로는 실제
|
||||||
// 캐시가 비어도 영영 저장이 막힌다(상한이 아니라 일방향 래치가 된다).
|
// 캐시가 비어도 영영 저장이 막힌다(상한이 아니라 일방향 래치가 된다).
|
||||||
if (! SeoCacheBounds::canStore($index, $url) && $this->shouldAttemptPrune()) {
|
if (! SeoCacheBounds::canStore($index, $url, $locale) && $this->shouldAttemptPrune()) {
|
||||||
$index = $this->rebuildIndex();
|
$index = $this->rebuildIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! SeoCacheBounds::canStore($index, $url)) {
|
if (! SeoCacheBounds::canStore($index, $url, $locale)) {
|
||||||
Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [
|
Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [
|
||||||
'url' => $url,
|
'url' => $url,
|
||||||
'locale' => $locale,
|
'locale' => $locale,
|
||||||
@@ -274,7 +301,8 @@ class SeoCacheManager implements SeoCacheManagerInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->cache->put($key, $html, $this->getCacheTtl());
|
// 레이아웃명을 페이지와 함께 둔다 — 적중 경로가 통계를 화면별로 귀속할 유일한 출처다.
|
||||||
|
$this->cache->put($key, ['html' => $html, 'layout' => $layoutName], $this->getCacheTtl());
|
||||||
|
|
||||||
$entry = [
|
$entry = [
|
||||||
'url' => $url,
|
'url' => $url,
|
||||||
|
|||||||
@@ -75,15 +75,15 @@ class SeoMiddleware
|
|||||||
$cacheUrl = $this->buildCacheUrl($request, $normalizedQuery);
|
$cacheUrl = $this->buildCacheUrl($request, $normalizedQuery);
|
||||||
|
|
||||||
// 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다
|
// 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다
|
||||||
$cachedHtml = $this->cacheManager->get($cacheUrl, $locale);
|
$entry = $this->readEntry($cacheUrl, $locale);
|
||||||
if ($cachedHtml !== null) {
|
if ($entry !== null) {
|
||||||
$this->recordStat($ip, fn () => $this->statsService->recordHit(
|
$this->recordStat($ip, fn () => $this->statsService->recordHit(
|
||||||
$cacheUrl,
|
$cacheUrl,
|
||||||
$locale,
|
$locale,
|
||||||
$request->attributes->get('seo_layout_name') ?: null
|
$entry['layout'] ?: null
|
||||||
));
|
));
|
||||||
|
|
||||||
return response($cachedHtml, 200, [
|
return response($entry['html'], 200, [
|
||||||
'Content-Type' => 'text/html; charset=utf-8',
|
'Content-Type' => 'text/html; charset=utf-8',
|
||||||
'X-SEO-Cache' => 'HIT',
|
'X-SEO-Cache' => 'HIT',
|
||||||
]);
|
]);
|
||||||
@@ -134,6 +134,10 @@ class SeoMiddleware
|
|||||||
|
|
||||||
// 렌더링 실패 시 SPA fallback
|
// 렌더링 실패 시 SPA fallback
|
||||||
if ($html === null) {
|
if ($html === null) {
|
||||||
|
// "그릴 게 없음" 은 비용을 치르지 않았다 — 되돌리지 않으면 캐시에 남지 않는 죽은
|
||||||
|
// 주소 재크롤이 올 때마다 정상 페이지의 예산을 태운다.
|
||||||
|
SeoCacheBounds::refundRender($ip);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +159,29 @@ class SeoMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 항목(HTML + 레이아웃명)을 읽습니다.
|
||||||
|
*
|
||||||
|
* 적중 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없다 — 통계를 화면별로
|
||||||
|
* 귀속하려면 캐시 항목이 그것을 알아야 한다. 인터페이스(`get`)는 HTML 만 돌려주므로
|
||||||
|
* 코어 매니저일 때만 항목 전체를 읽고, 다른 구현이 바인딩된 경우에는 레이아웃명 없이
|
||||||
|
* HTML 만 쓴다(그 통계는 레이아웃 미상으로 귀속된다).
|
||||||
|
*
|
||||||
|
* @param string $cacheUrl 캐시 키용 URL
|
||||||
|
* @param string $locale 로케일
|
||||||
|
* @return array{html: string, layout: string|null}|null 캐시 항목 (없으면 null)
|
||||||
|
*/
|
||||||
|
private function readEntry(string $cacheUrl, string $locale): ?array
|
||||||
|
{
|
||||||
|
if ($this->cacheManager instanceof SeoCacheManager) {
|
||||||
|
return $this->cacheManager->getEntry($cacheUrl, $locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = $this->cacheManager->get($cacheUrl, $locale);
|
||||||
|
|
||||||
|
return $html === null ? null : ['html' => $html, 'layout' => null];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐시·렌더를 건너뛰고 SPA 응답을 돌려줍니다.
|
* 캐시·렌더를 건너뛰고 SPA 응답을 돌려줍니다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -154,39 +154,7 @@ class ExtensionBundleService
|
|||||||
*/
|
*/
|
||||||
public function buildJsBundle(string $type): string
|
public function buildJsBundle(string $type): string
|
||||||
{
|
{
|
||||||
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
return $this->mergeBundle($type, 'js')['content'];
|
||||||
$isProduction = app()->environment('production');
|
|
||||||
$segments = [];
|
|
||||||
|
|
||||||
foreach ($ordered as $identifier => $paths) {
|
|
||||||
if (empty($paths['jsAbsPath'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$content = @file_get_contents($paths['jsAbsPath']);
|
|
||||||
|
|
||||||
if ($content === false) {
|
|
||||||
Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
|
|
||||||
'type' => $type,
|
|
||||||
'identifier' => $identifier,
|
|
||||||
'path' => $paths['jsAbsPath'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [
|
|
||||||
'type' => $type,
|
|
||||||
'identifier' => $identifier,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode("\n;\n", $segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,60 +172,7 @@ class ExtensionBundleService
|
|||||||
*/
|
*/
|
||||||
public function buildCssBundle(string $type): string
|
public function buildCssBundle(string $type): string
|
||||||
{
|
{
|
||||||
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
return $this->mergeBundle($type, 'css')['content'];
|
||||||
$isProduction = app()->environment('production');
|
|
||||||
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
|
|
||||||
$version = $this->getCurrentVersion();
|
|
||||||
$segments = [];
|
|
||||||
|
|
||||||
foreach ($ordered as $identifier => $paths) {
|
|
||||||
if (empty($paths['cssAbsPath'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$content = @file_get_contents($paths['cssAbsPath']);
|
|
||||||
|
|
||||||
if ($content === false) {
|
|
||||||
Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
|
|
||||||
'type' => $type,
|
|
||||||
'identifier' => $identifier,
|
|
||||||
'path' => $paths['cssAbsPath'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 상대 참조는 **치환**한다. 병합본의 주소(`/api/{type}/bundle.css` 또는 정적
|
|
||||||
// 게시본)는 어느 확장의 dist 디렉토리도 아니므로 상대 해석이 반드시 어긋나는데,
|
|
||||||
// 그 실패는 404 하나로만 나타나 서버 로그에 흔적이 없다.
|
|
||||||
//
|
|
||||||
// 종전에는 그런 CSS 를 가진 확장을 번들에서 통째로 제외했다. 그러나 번들 URL 이
|
|
||||||
// 내려오면 프론트는 개별 로딩을 아예 타지 않으므로(TemplateApp.loadExtensionAssets)
|
|
||||||
// 제외 = 그 확장의 스타일이 **하나도 적용되지 않음** 이었다. 주석이 말하던
|
|
||||||
// "개별 폴백" 은 bundleUrls 부재(구버전 blade) 경로에만 있다.
|
|
||||||
$content = AssetCssUrlRewriter::rewrite(
|
|
||||||
$content,
|
|
||||||
(string) ($paths['cssRelPath'] ?? ''),
|
|
||||||
fn (string $path): string => AssetUrl::extensionApiAsset(
|
|
||||||
$typeSegment,
|
|
||||||
$identifier,
|
|
||||||
$path,
|
|
||||||
$version
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$segments[] = $this->processCssSourceMap($content, $isProduction);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [
|
|
||||||
'type' => $type,
|
|
||||||
'identifier' => $identifier,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode("\n", $segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,8 +246,9 @@ class ExtensionBundleService
|
|||||||
*
|
*
|
||||||
* 병합 결과가 비어 있어도 선언한 산출물이 **전부 존재하면**(또는 선언이 0이면) 0바이트
|
* 병합 결과가 비어 있어도 선언한 산출물이 **전부 존재하면**(또는 선언이 0이면) 0바이트
|
||||||
* 캐시 파일을 만든다. 그래야 정적 게시 대상이 되어 방문자가 웹서버에서 직접 받는다 —
|
* 캐시 파일을 만든다. 그래야 정적 게시 대상이 되어 방문자가 웹서버에서 직접 받는다 —
|
||||||
* 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 선언한 산출물이 **소실**된
|
* 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 둘이다 —
|
||||||
* 경우에만 캐시하지 않아, 호출측의 503 판정이 그대로 유지된다.
|
* 선언한 산출물이 **소실**된 경우(호출측의 503 판정을 그대로 유지한다)와 병합 단계에서
|
||||||
|
* 확장을 **건너뛴** 경우(그 상태가 굳지 않도록 매 요청 재시도에 맡긴다).
|
||||||
*
|
*
|
||||||
* @param CoreStorageDriver $storage 번들 디스크 스토리지
|
* @param CoreStorageDriver $storage 번들 디스크 스토리지
|
||||||
* @param string $type 'module' | 'plugin'
|
* @param string $type 'module' | 'plugin'
|
||||||
@@ -379,7 +295,22 @@ class ExtensionBundleService
|
|||||||
return $storage->getBasePath('').'/'.$relativeName;
|
return $storage->getBasePath('').'/'.$relativeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = $this->buildBundleContent($type, $kind);
|
['content' => $content, 'skipped' => $skipped] = $this->mergeBundle($type, $kind);
|
||||||
|
|
||||||
|
// 건너뛴 확장이 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데 읽기·치환이
|
||||||
|
// 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정된다.
|
||||||
|
// 캐시 없이 돌아가면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 순간 회복한다.
|
||||||
|
// 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다 — 이 통지가 유일한 흔적이다.
|
||||||
|
if ($skipped !== []) {
|
||||||
|
Log::error('확장 번들 캐시 보류 — 병합 단계에서 건너뛴 확장이 있어 캐시하지 않습니다', [
|
||||||
|
'type' => $type,
|
||||||
|
'kind' => $kind,
|
||||||
|
'version' => $version,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// 비었는데 선언한 산출물이 소실이면 캐시하지 않는다 — 배포 중 dist 가 잠깐 빈
|
// 비었는데 선언한 산출물이 소실이면 캐시하지 않는다 — 배포 중 dist 가 잠깐 빈
|
||||||
// 장애가 0바이트 캐시로 굳어 정상(빈 200)으로 위장되면 안 된다.
|
// 장애가 0바이트 캐시로 굳어 정상(빈 200)으로 위장되면 안 된다.
|
||||||
@@ -408,9 +339,163 @@ class ExtensionBundleService
|
|||||||
*/
|
*/
|
||||||
public function buildBundleContent(string $type, string $kind): string
|
public function buildBundleContent(string $type, string $kind): string
|
||||||
{
|
{
|
||||||
return $kind === 'css'
|
return $this->mergeBundle($type, $kind)['content'];
|
||||||
? $this->buildCssBundle($type)
|
}
|
||||||
: $this->buildJsBundle($type);
|
|
||||||
|
/**
|
||||||
|
* 병합 결과와 함께 **건너뛴 확장**을 돌려줍니다 (캐시 판정용).
|
||||||
|
*
|
||||||
|
* 캐시할지는 결과 문자열만으로 판정할 수 없다 — 파일은 존재·판독 가능한데 읽기나 치환이
|
||||||
|
* 실패해 건너뛴 확장은 결과에서 조용히 빠질 뿐이다. 그 상태가 캐시로 굳으면 버전 bump
|
||||||
|
* 전까지 그 확장 자산이 사라진 채 고정되므로, 캐시 경로는 건너뜀 여부를 함께 받는다.
|
||||||
|
*
|
||||||
|
* @param string $type 'module' | 'plugin'
|
||||||
|
* @param string $kind 'js' | 'css'
|
||||||
|
* @return array{content: string, skipped: list<string>} 병합 결과와 건너뛴 확장 식별자
|
||||||
|
*/
|
||||||
|
private function mergeBundle(string $type, string $kind): array
|
||||||
|
{
|
||||||
|
$merged = $kind === 'css' ? $this->mergeCss($type) : $this->mergeJs($type);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content' => implode($kind === 'css' ? "\n" : "\n;\n", $merged['segments']),
|
||||||
|
'skipped' => $merged['skipped'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS 세그먼트를 priority 순으로 모읍니다.
|
||||||
|
*
|
||||||
|
* 확장별 fine-grained try/catch — 읽기 실패·처리 예외는 그 확장만 건너뛰고(`skipped`
|
||||||
|
* 에 기록) 나머지 병합을 지속한다. 한 확장의 실패가 번들 전체를 붕괴시키지 않는다.
|
||||||
|
*
|
||||||
|
* @param string $type 'module' | 'plugin'
|
||||||
|
* @return array{segments: list<string>, skipped: list<string>} 세그먼트와 건너뛴 확장 식별자
|
||||||
|
*/
|
||||||
|
private function mergeJs(string $type): array
|
||||||
|
{
|
||||||
|
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
||||||
|
$isProduction = app()->environment('production');
|
||||||
|
$segments = [];
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
|
foreach ($ordered as $identifier => $paths) {
|
||||||
|
if (empty($paths['jsAbsPath'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $this->readAssetSource($paths['jsAbsPath']);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
|
||||||
|
'type' => $type,
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'path' => $paths['jsAbsPath'],
|
||||||
|
]);
|
||||||
|
$skipped[] = (string) $identifier;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [
|
||||||
|
'type' => $type,
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$skipped[] = (string) $identifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['segments' => $segments, 'skipped' => $skipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS 세그먼트를 priority 순으로 모읍니다.
|
||||||
|
*
|
||||||
|
* CSS 안의 상대 `url(...)`·`@import` 참조는 그 확장의 절대 자산 URL 로 치환한다 —
|
||||||
|
* 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋나기 때문이다.
|
||||||
|
* 치환은 개별 자산 서빙(ServesRewritableCssAssets)과 같은 규칙(AssetCssUrlRewriter)을 쓴다.
|
||||||
|
*
|
||||||
|
* @param string $type 'module' | 'plugin'
|
||||||
|
* @return array{segments: list<string>, skipped: list<string>} 세그먼트와 건너뛴 확장 식별자
|
||||||
|
*/
|
||||||
|
private function mergeCss(string $type): array
|
||||||
|
{
|
||||||
|
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
||||||
|
$isProduction = app()->environment('production');
|
||||||
|
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
|
||||||
|
$version = $this->getCurrentVersion();
|
||||||
|
$segments = [];
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
|
foreach ($ordered as $identifier => $paths) {
|
||||||
|
if (empty($paths['cssAbsPath'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $this->readAssetSource($paths['cssAbsPath']);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
|
||||||
|
'type' => $type,
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'path' => $paths['cssAbsPath'],
|
||||||
|
]);
|
||||||
|
$skipped[] = (string) $identifier;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 참조는 **치환**한다. 병합본의 주소(`/api/{type}/bundle.css` 또는 정적
|
||||||
|
// 게시본)는 어느 확장의 dist 디렉토리도 아니므로 상대 해석이 반드시 어긋나는데,
|
||||||
|
// 그 실패는 404 하나로만 나타나 서버 로그에 흔적이 없다.
|
||||||
|
//
|
||||||
|
// 종전에는 그런 CSS 를 가진 확장을 번들에서 통째로 제외했다. 그러나 번들 URL 이
|
||||||
|
// 내려오면 프론트는 개별 로딩을 아예 타지 않으므로(TemplateApp.loadExtensionAssets)
|
||||||
|
// 제외 = 그 확장의 스타일이 **하나도 적용되지 않음** 이었다. 주석이 말하던
|
||||||
|
// "개별 폴백" 은 bundleUrls 부재(구버전 blade) 경로에만 있다.
|
||||||
|
$content = AssetCssUrlRewriter::rewrite(
|
||||||
|
$content,
|
||||||
|
(string) ($paths['cssRelPath'] ?? ''),
|
||||||
|
fn (string $path): string => AssetUrl::extensionApiAsset(
|
||||||
|
$typeSegment,
|
||||||
|
$identifier,
|
||||||
|
$path,
|
||||||
|
$version
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$segments[] = $this->processCssSourceMap($content, $isProduction);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [
|
||||||
|
'type' => $type,
|
||||||
|
'identifier' => $identifier,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$skipped[] = (string) $identifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['segments' => $segments, 'skipped' => $skipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확장 자산 원본을 읽습니다.
|
||||||
|
*
|
||||||
|
* 실패는 `false` 로 돌아오고 호출측이 그 확장을 건너뛴다. 별도 메서드인 이유는
|
||||||
|
* "존재·판독 가능한데 읽기가 실패하는" 상태를 테스트가 재현할 수 있어야 하기 때문이다 —
|
||||||
|
* 그 상태가 캐시로 굳는 것이 이 서비스가 막아야 할 결함이다.
|
||||||
|
*
|
||||||
|
* @param string $path 절대 경로
|
||||||
|
* @return string|false 파일 내용 (실패 시 false)
|
||||||
|
*/
|
||||||
|
protected function readAssetSource(string $path): string|false
|
||||||
|
{
|
||||||
|
return @file_get_contents($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ return [
|
|||||||
// 정규화된 쿼리 문자열 길이 상한 (바이트)
|
// 정규화된 쿼리 문자열 길이 상한 (바이트)
|
||||||
'max_query_length' => (int) env('G7_SEO_CACHE_MAX_QUERY_LENGTH', 512),
|
'max_query_length' => (int) env('G7_SEO_CACHE_MAX_QUERY_LENGTH', 512),
|
||||||
|
|
||||||
// 같은 경로에 대해 저장하는 쿼리 변종 수 상한
|
// 같은 경로·언어에 대해 저장하는 쿼리 변종 수 상한 (언어별로 따로 센다)
|
||||||
'max_variants_per_path' => (int) env('G7_SEO_CACHE_MAX_VARIANTS_PER_PATH', 50),
|
'max_variants_per_path' => (int) env('G7_SEO_CACHE_MAX_VARIANTS_PER_PATH', 50),
|
||||||
|
|
||||||
// 캐시 인덱스 전체 항목 수 상한
|
// 캐시 인덱스 전체 항목 수 상한
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* SEO 캐시 통계의 url 컬럼을 캐시 키 URL 길이에 맞춥니다.
|
||||||
|
*
|
||||||
|
* 캐시 키 URL 은 경로 + 정규화 쿼리(기본 최대 512바이트)라 255자를 넘을 수 있는데,
|
||||||
|
* 컬럼이 255자면 그 기록이 엄격 모드에서 실패하고 통계 서비스는 그 예외를 삼킨다 —
|
||||||
|
* 긴 주소의 봇 요청은 통계에서 빠지고 요청마다 실패하는 INSERT 만 남는다.
|
||||||
|
* 768 은 utf8mb4 인덱스 키 상한(3072바이트)에 맞춘 값이다 — `idx_seo_cache_stats_url`
|
||||||
|
* 이 이 컬럼에 걸려 있다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('seo_cache_stats', function (Blueprint $table) {
|
||||||
|
$table->string('url', 768)->comment('요청 URL (경로 + 정규화 쿼리)')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* url 컬럼을 원래 길이(255)로 되돌립니다.
|
||||||
|
*
|
||||||
|
* 되돌리기 전에 255자를 넘는 행을 지운다 — 엄격 모드에서는 잘리는 값이 하나라도 있으면
|
||||||
|
* ALTER 자체가 실패한다. 통계 행은 파생 데이터라 손실이 복구 대상이 아니다.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('seo_cache_stats')->whereRaw('CHAR_LENGTH(url) > 255')->delete();
|
||||||
|
|
||||||
|
Schema::table('seo_cache_stats', function (Blueprint $table) {
|
||||||
|
$table->string('url', 255)->comment('요청 URL')->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -106,9 +106,9 @@ Request → web.php catch-all → SeoMiddleware (봇 감지)
|
|||||||
- 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조)
|
- 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조)
|
||||||
- 렌더링 실패 시: SPA fallback (기존 응답 통과)
|
- 렌더링 실패 시: SPA fallback (기존 응답 통과)
|
||||||
- 캐시 키: 경로 + **정규화된** 쿼리(`locale`·`_escaped_fragment_` 제외, 키 순서 정렬, 개수·길이 상한)
|
- 캐시 키: 경로 + **정규화된** 쿼리(`locale`·`_escaped_fragment_` 제외, 키 순서 정렬, 개수·길이 상한)
|
||||||
- 미스 렌더는 IP 당 분당 상한 안에서만 — 초과분은 SPA + `X-SEO-Cache: BYPASS`(오류가 아니다)
|
- 미스 렌더는 IP 당 분당 상한 안에서만 — 초과분은 SPA + `X-SEO-Cache: BYPASS`(오류가 아니다). 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청은 차감을 되돌린다
|
||||||
- 저장 상한: 경로당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수
|
- 저장 상한: 경로·언어당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수
|
||||||
- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한)
|
- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한). HIT 의 레이아웃명은 캐시 항목에 함께 저장된 값으로 귀속한다
|
||||||
|
|
||||||
## 봇 감지 구조
|
## 봇 감지 구조
|
||||||
|
|
||||||
@@ -769,9 +769,9 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
|
|||||||
|----|-----|-------|------|
|
|----|-----|-------|------|
|
||||||
| max_query_params | `G7_SEO_CACHE_MAX_QUERY_PARAMS` | 10 | 캐시 키에 허용하는 쿼리 파라미터 수. 초과 시 캐시·렌더 안 함 |
|
| max_query_params | `G7_SEO_CACHE_MAX_QUERY_PARAMS` | 10 | 캐시 키에 허용하는 쿼리 파라미터 수. 초과 시 캐시·렌더 안 함 |
|
||||||
| max_query_length | `G7_SEO_CACHE_MAX_QUERY_LENGTH` | 512 | 정규화된 쿼리 문자열 길이 상한(바이트) |
|
| max_query_length | `G7_SEO_CACHE_MAX_QUERY_LENGTH` | 512 | 정규화된 쿼리 문자열 길이 상한(바이트) |
|
||||||
| max_variants_per_path | `G7_SEO_CACHE_MAX_VARIANTS_PER_PATH` | 50 | 같은 경로에 저장하는 쿼리 변종 수 |
|
| max_variants_per_path | `G7_SEO_CACHE_MAX_VARIANTS_PER_PATH` | 50 | 같은 경로·언어에 저장하는 쿼리 변종 수 (언어별로 따로 센다) |
|
||||||
| max_entries | `G7_SEO_CACHE_MAX_ENTRIES` | 20000 | 캐시 인덱스 전체 항목 수 |
|
| max_entries | `G7_SEO_CACHE_MAX_ENTRIES` | 20000 | 캐시 인덱스 전체 항목 수 |
|
||||||
| render_misses_per_minute | `G7_SEO_RENDER_MISSES_PER_MINUTE` | 60 | IP 당 분당 미스 렌더 수 |
|
| render_misses_per_minute | `G7_SEO_RENDER_MISSES_PER_MINUTE` | 60 | IP 당 분당 미스 렌더 수. 렌더러가 그릴 게 없다고 돌아온 요청(미라우트 404·SEO 비활성 화면)은 세지 않는다 |
|
||||||
| stats_records_per_minute | `G7_SEO_STATS_RECORDS_PER_MINUTE` | 300 | IP 당 분당 통계 기록 수 |
|
| stats_records_per_minute | `G7_SEO_STATS_RECORDS_PER_MINUTE` | 300 | IP 당 분당 통계 기록 수 |
|
||||||
|
|
||||||
상한을 넘긴 요청은 **차단되지 않고** 일반 SPA 응답을 받습니다. 봇에게 오류를 돌려주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해입니다. 판정 결과는 응답 헤더 `X-SEO-Cache`(`HIT`/`MISS`/`BYPASS`)로 드러나며, 그것이 운영 진단의 통로입니다.
|
상한을 넘긴 요청은 **차단되지 않고** 일반 SPA 응답을 받습니다. 봇에게 오류를 돌려주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해입니다. 판정 결과는 응답 헤더 `X-SEO-Cache`(`HIT`/`MISS`/`BYPASS`)로 드러나며, 그것이 운영 진단의 통로입니다.
|
||||||
@@ -784,6 +784,20 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
|
|||||||
|
|
||||||
프록시 뒤에 두는 설치본은 `TRUSTED_PROXIES` 를 반드시 지정합니다 — 설정 방법과 진단은 [리버스 프록시 환경](reverse-proxy.md)에 있습니다. 진단이 어려우면 응답 헤더 `X-SEO-Cache` 가 `BYPASS` 로 몰리는지를 먼저 봅니다.
|
프록시 뒤에 두는 설치본은 `TRUSTED_PROXIES` 를 반드시 지정합니다 — 설정 방법과 진단은 [리버스 프록시 환경](reverse-proxy.md)에 있습니다. 진단이 어려우면 응답 헤더 `X-SEO-Cache` 가 `BYPASS` 로 몰리는지를 먼저 봅니다.
|
||||||
|
|
||||||
|
### 경로당 변종 상한은 언어별로 셉니다
|
||||||
|
|
||||||
|
캐시 인덱스 항목은 URL 과 로케일의 조합마다 하나입니다. 경로만 보고 변종을 합산하면 언어 수만큼 실효 상한이 줄어, 언어가 셋인 사이트는 한 경로에 언어당 16개만 저장되고 목록 17페이지부터는 봇이 올 때마다 새로 그리되 저장하지 않게 됩니다. 그래서 상한은 경로와 언어의 조합 단위로 판정합니다.
|
||||||
|
|
||||||
|
### 그릴 게 없는 요청은 렌더 예산을 쓰지 않습니다
|
||||||
|
|
||||||
|
렌더 예산은 새로 그리기 직전에 1을 차감합니다. 그런데 미라우트 주소(404)나 SEO 를 끈 화면은 렌더러가 "그릴 게 없음"으로 바로 돌아오고 캐시에도 남지 않아, 올 때마다 다시 차감됩니다. 봇은 예전에 있던 죽은 주소를 오래 다시 긁으므로 그 요청까지 세면 정상 페이지의 예산이 죽은 주소에 소진됩니다. 렌더러가 null 을 돌려주면 그 차감을 되돌리고, 렌더 도중 예외는 비용을 이미 치른 것이라 되돌리지 않습니다.
|
||||||
|
|
||||||
|
### 캐시 항목은 레이아웃명을 함께 담습니다
|
||||||
|
|
||||||
|
페이지 캐시 값은 HTML 과 레이아웃명의 쌍입니다. 캐시 적중(HIT) 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없으므로, 통계를 화면별로 귀속하려면 캐시 항목이 그것을 알아야 합니다. `SeoCacheManagerInterface::get()` 은 종전대로 HTML 만 돌려주고, 코어 구현 `SeoCacheManager::getEntry()` 가 쌍 전체를 돌려줍니다. 미들웨어는 코어 구현일 때만 쌍을 읽고, 다른 구현이 바인딩된 경우에는 레이아웃명 없이 기록합니다. 이전 버전이 문자열로만 저장한 항목은 레이아웃명 없이 그대로 읽힙니다 — 배포 직후 살아 있는 캐시를 버리지 않습니다.
|
||||||
|
|
||||||
|
통계 테이블(`seo_cache_stats`)의 `url` 컬럼은 캐시 키 URL 상한(경로 + 정규화 쿼리 최대 512바이트)을 담도록 768자입니다. 컬럼이 짧으면 긴 주소의 기록이 엄격 모드에서 실패하고, 통계 서비스는 그 예외를 삼키므로 흔적이 남지 않습니다.
|
||||||
|
|
||||||
### 저장 상한이 세는 것은 살아 있는 항목입니다
|
### 저장 상한이 세는 것은 살아 있는 항목입니다
|
||||||
|
|
||||||
캐시 인덱스 항목은 페이지보다 오래 삽니다(기본 30일 vs 2시간). 그래서 저장 상한에 닿으면 **먼저 만료된 항목을 인덱스에서 걷어내고 다시 판정**합니다. 그러지 않으면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 상한에 닿은 경로는 실제 캐시가 비어 있어도 다시는 저장되지 않습니다.
|
캐시 인덱스 항목은 페이지보다 오래 삽니다(기본 30일 vs 2시간). 그래서 저장 상한에 닿으면 **먼저 만료된 항목을 인덱스에서 걷어내고 다시 판정**합니다. 그러지 않으면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 상한에 닿은 경로는 실제 캐시가 비어 있어도 다시는 저장되지 않습니다.
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ GET /api/plugins/bundle.css?v={version}
|
|||||||
| CSS url() | 상대 `url()`·`@import` 참조는 그 확장의 절대 자산 URL 로 **치환**해 병합. 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋난다 | (계약 테스트) |
|
| CSS url() | 상대 `url()`·`@import` 참조는 그 확장의 절대 자산 URL 로 **치환**해 병합. 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋난다 | (계약 테스트) |
|
||||||
| 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) |
|
| 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) |
|
||||||
| 빈 번들 판정 | 선언한 산출물이 소실·판독 불가면 **503**, 존재하되 비었으면 빈 200 (선언 0 도 빈 200) | (계약 테스트) |
|
| 빈 번들 판정 | 선언한 산출물이 소실·판독 불가면 **503**, 존재하되 비었으면 빈 200 (선언 0 도 빈 200) | (계약 테스트) |
|
||||||
| 캐시 우선 | 프로덕션은 `(type, kind, version)` 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴(잠금 실패는 각자 빌드) | (계약 테스트) |
|
| 캐시 우선 | 프로덕션은 `(type, kind, version)` 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴(잠금 실패는 각자 빌드). 병합 단계에서 건너뛴 확장이 있는 결과는 캐시하지 않는다 | (계약 테스트) |
|
||||||
|
|
||||||
### 병합 CSS 의 상대 참조
|
### 병합 CSS 의 상대 참조
|
||||||
|
|
||||||
@@ -553,7 +553,7 @@ GET /api/plugins/bundle.css?v={version}
|
|||||||
|
|
||||||
선언 산출물이 전부 존재하는 빈 번들은 **0바이트 파일로 캐시·정적 게시**되어 브라우저가 웹서버에서 직접 받는다. 게시하지 않으면 `AssetUrl::extensionBundle()` 이 API URL 을 방출해 그 구성의 **모든 페이지 로드**가 PHP 를 거치고, 그 요청마다 컨트롤러가 활성 확장 열거를 세 번 반복한다(경로 조회 → 재빌드 → 소실 판정). 오류도 로그도 남지 않아 드러나지 않는 경로다.
|
선언 산출물이 전부 존재하는 빈 번들은 **0바이트 파일로 캐시·정적 게시**되어 브라우저가 웹서버에서 직접 받는다. 게시하지 않으면 `AssetUrl::extensionBundle()` 이 API URL 을 방출해 그 구성의 **모든 페이지 로드**가 PHP 를 거치고, 그 요청마다 컨트롤러가 활성 확장 열거를 세 번 반복한다(경로 조회 → 재빌드 → 소실 판정). 오류도 로그도 남지 않아 드러나지 않는 경로다.
|
||||||
|
|
||||||
캐시하지 않는 것은 둘뿐이다 — 선언 산출물이 **소실**된 경우(503 판정을 그대로 유지해야 한다)와 디스크 쓰기 자체가 실패한 경우다.
|
캐시하지 않는 것은 셋뿐이다 — 선언 산출물이 **소실**된 경우(503 판정을 그대로 유지해야 한다), 병합 단계에서 확장을 **건너뛴** 경우(파일은 있는데 읽기·치환이 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정되므로, 종전처럼 매 요청 재시도에 맡긴다), 그리고 디스크 쓰기 자체가 실패한 경우다.
|
||||||
|
|
||||||
두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다.
|
두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Database;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `seo_cache_stats.url` 확장 마이그레이션의 왕복(up → down → up) 안전성.
|
||||||
|
*
|
||||||
|
* 캐시 키 URL(경로 + 정규화 쿼리 최대 512바이트)이 255자 컬럼에 들어가지 않아 긴 주소의
|
||||||
|
* 통계 기록이 조용히 실패하던 것을 768자로 넓힌 마이그레이션이다. 되돌릴 때는 잘리는 행을
|
||||||
|
* 먼저 지워야 엄격 모드에서 ALTER 가 성공한다.
|
||||||
|
*/
|
||||||
|
class ModifyUrlInSeoCacheStatsMigrationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const MIGRATION = 'database/migrations/2026_09_08_000001_modify_url_in_seo_cache_stats_table.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* up → down → up 왕복 뒤 컬럼 길이가 복원되고 url 인덱스가 보존된다.
|
||||||
|
*
|
||||||
|
* @scenario bot_state=bot, cache_state=hit, ip_budget=within, query_shape=normal, store_state=under_caps
|
||||||
|
*
|
||||||
|
* @effects stats_url_column_fits_normalized_cache_url
|
||||||
|
*/
|
||||||
|
public function test_round_trip_restores_column_length_and_keeps_index(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(768, $this->urlLength());
|
||||||
|
|
||||||
|
Artisan::call('migrate:rollback', ['--path' => [self::MIGRATION], '--force' => true]);
|
||||||
|
$this->assertSame(255, $this->urlLength());
|
||||||
|
|
||||||
|
Artisan::call('migrate', ['--path' => [self::MIGRATION], '--force' => true]);
|
||||||
|
$this->assertSame(768, $this->urlLength());
|
||||||
|
|
||||||
|
$this->assertContains(
|
||||||
|
'idx_seo_cache_stats_url',
|
||||||
|
array_column(Schema::getIndexes('seo_cache_stats'), 'name'),
|
||||||
|
'url 인덱스는 컬럼 변경 뒤에도 남아야 한다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* down 은 255자를 넘는 행을 먼저 지운다 — 엄격 모드에서는 잘리는 값이 하나라도 있으면
|
||||||
|
* ALTER 자체가 실패해 되돌리기가 막힌다.
|
||||||
|
*
|
||||||
|
* @scenario bot_state=bot, cache_state=hit, ip_budget=within, query_shape=normal, store_state=under_caps
|
||||||
|
*
|
||||||
|
* @effects stats_url_column_fits_normalized_cache_url
|
||||||
|
*/
|
||||||
|
public function test_down_removes_rows_that_would_not_fit_before_shrinking(): void
|
||||||
|
{
|
||||||
|
$long = '/shop?'.str_repeat('a=0123456789&', 30);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(255, strlen($long));
|
||||||
|
|
||||||
|
DB::table('seo_cache_stats')->insert([
|
||||||
|
['url' => $long, 'locale' => 'ko', 'type' => 'hit'],
|
||||||
|
['url' => '/short', 'locale' => 'ko', 'type' => 'hit'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Artisan::call('migrate:rollback', ['--path' => [self::MIGRATION], '--force' => true]);
|
||||||
|
|
||||||
|
$this->assertSame(255, $this->urlLength());
|
||||||
|
$this->assertDatabaseMissing('seo_cache_stats', ['url' => $long]);
|
||||||
|
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/short']);
|
||||||
|
} finally {
|
||||||
|
// DDL 이 테스트 트랜잭션을 암묵 커밋하므로 남는 행은 직접 치운다.
|
||||||
|
Artisan::call('migrate', ['--path' => [self::MIGRATION], '--force' => true]);
|
||||||
|
DB::table('seo_cache_stats')->where('url', '/short')->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* url 컬럼의 선언 길이를 읽습니다.
|
||||||
|
*
|
||||||
|
* @return int 문자 수 (varchar(N) 의 N)
|
||||||
|
*/
|
||||||
|
private function urlLength(): int
|
||||||
|
{
|
||||||
|
foreach (Schema::getColumns('seo_cache_stats') as $column) {
|
||||||
|
if ($column['name'] === 'url') {
|
||||||
|
preg_match('/\((\d+)\)/', (string) $column['type'], $matches);
|
||||||
|
|
||||||
|
return (int) ($matches[1] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fail('seo_cache_stats.url 컬럼이 없다');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,12 +111,13 @@ class ExtensionAssetCssRewriteContractTest extends TestCase
|
|||||||
'병합 CSS 번들 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다.'
|
'병합 CSS 번들 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다.'
|
||||||
);
|
);
|
||||||
|
|
||||||
$source = $this->methodSource(ExtensionBundleService::class, 'buildCssBundle');
|
// 병합 루프는 mergeCss() 에 있다 — buildCssBundle()/buildBundleContent()/캐시 경로가 모두 이 루프를 거친다.
|
||||||
|
$source = $this->methodSource(ExtensionBundleService::class, 'mergeCss');
|
||||||
|
|
||||||
$this->assertStringContainsString(
|
$this->assertStringContainsString(
|
||||||
self::REWRITER,
|
self::REWRITER,
|
||||||
$source,
|
$source,
|
||||||
'ExtensionBundleService::buildCssBundle() 이 '.self::REWRITER.' 를 거치지 않습니다. '
|
'ExtensionBundleService::mergeCss() 이 '.self::REWRITER.' 를 거치지 않습니다. '
|
||||||
.'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.'
|
.'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Seo;
|
|||||||
use App\Seo\BotDetector;
|
use App\Seo\BotDetector;
|
||||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||||
use App\Seo\Contracts\SeoRendererInterface;
|
use App\Seo\Contracts\SeoRendererInterface;
|
||||||
|
use App\Seo\SeoCacheManager;
|
||||||
use App\Seo\SeoCacheStatsService;
|
use App\Seo\SeoCacheStatsService;
|
||||||
use App\Seo\SeoMiddleware;
|
use App\Seo\SeoMiddleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -702,4 +703,69 @@ class SeoMiddlewareTest extends TestCase
|
|||||||
|
|
||||||
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
|
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 적중(HIT)은 렌더러를 거치지 않으므로 레이아웃명을 요청 속성에서 얻을 수 없다 —
|
||||||
|
* 캐시 항목에 함께 저장된 레이아웃명으로 통계에 귀속한다. 없으면 화면별 표에서 모든
|
||||||
|
* 화면의 적중이 0 이 되고 'N/A' 행에만 쌓인다.
|
||||||
|
*
|
||||||
|
* @effects cache_hit_carries_layout_name_from_cache_entry
|
||||||
|
*/
|
||||||
|
public function test_cache_hit_records_stat_hit_with_layout_from_cache_entry(): void
|
||||||
|
{
|
||||||
|
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
|
||||||
|
|
||||||
|
$cacheManager = $this->createMock(SeoCacheManager::class);
|
||||||
|
$cacheManager->method('getEntry')->willReturn(['html' => '<html>cached</html>', 'layout' => 'shop/show']);
|
||||||
|
|
||||||
|
$middleware = new SeoMiddleware($this->botDetector, $cacheManager, $this->renderer, $this->statsService);
|
||||||
|
|
||||||
|
$this->botDetector->method('isBot')->willReturn(true);
|
||||||
|
$this->statsService->expects($this->once())
|
||||||
|
->method('recordHit')
|
||||||
|
->with('/products', config('app.locale'), 'shop/show');
|
||||||
|
|
||||||
|
$response = $middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
|
||||||
|
|
||||||
|
$this->assertSame('<html>cached</html>', $response->getContent());
|
||||||
|
$this->assertSame('HIT', $response->headers->get('X-SEO-Cache'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더러가 "그릴 게 없음"(null)을 돌려주면 방금 뺀 렌더 예산을 되돌린다 — 미라우트 404 나
|
||||||
|
* SEO 비활성 화면은 캐시에 남지 않아 올 때마다 다시 예산을 쓰므로, 죽은 주소 재크롤이
|
||||||
|
* 정상 페이지의 예산을 태운다.
|
||||||
|
*
|
||||||
|
* @effects null_render_refunds_render_budget
|
||||||
|
*/
|
||||||
|
public function test_null_render_refunds_render_budget(): void
|
||||||
|
{
|
||||||
|
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
|
||||||
|
|
||||||
|
$this->botDetector->method('isBot')->willReturn(true);
|
||||||
|
$this->cacheManager->method('get')->willReturn(null);
|
||||||
|
$this->renderer->method('render')->willReturn(null);
|
||||||
|
|
||||||
|
$this->middleware->handle($this->createRequest('/gone', 'Googlebot/2.1'), $this->spaNext());
|
||||||
|
|
||||||
|
$this->assertSame(0, (int) RateLimiter::attempts('seo-render:127.0.0.1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더 중 예외는 비용을 이미 치른 것이므로 예산을 되돌리지 않는다 (회귀 가드).
|
||||||
|
*
|
||||||
|
* @effects null_render_refunds_render_budget
|
||||||
|
*/
|
||||||
|
public function test_render_exception_keeps_render_budget_charged(): void
|
||||||
|
{
|
||||||
|
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
|
||||||
|
|
||||||
|
$this->botDetector->method('isBot')->willReturn(true);
|
||||||
|
$this->cacheManager->method('get')->willReturn(null);
|
||||||
|
$this->renderer->method('render')->willThrowException(new \RuntimeException('boom'));
|
||||||
|
|
||||||
|
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
|
||||||
|
|
||||||
|
$this->assertSame(1, (int) RateLimiter::attempts('seo-render:127.0.0.1'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ class SeoCacheTest extends TestCase
|
|||||||
$this->manager->put('/board/notice/123', 'ko', '<html>Hello</html>');
|
$this->manager->put('/board/notice/123', 'ko', '<html>Hello</html>');
|
||||||
|
|
||||||
$expectedKey = 'seo.page.'.md5('/board/notice/123|ko');
|
$expectedKey = 'seo.page.'.md5('/board/notice/123|ko');
|
||||||
$this->assertSame('<html>Hello</html>', $this->cache->get($expectedKey));
|
// 페이지는 레이아웃명과 함께 저장된다 — 적중 경로가 통계를 화면별로 귀속할 출처다.
|
||||||
|
$this->assertSame('<html>Hello</html>', $this->cache->get($expectedKey)['html'] ?? null);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'g7:core:'.$expectedKey,
|
'g7:core:'.$expectedKey,
|
||||||
$this->cache->resolveKey($expectedKey)
|
$this->cache->resolveKey($expectedKey)
|
||||||
|
|||||||
@@ -99,14 +99,14 @@ class SeoCacheBoundsTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$index = [
|
$index = [
|
||||||
'k1' => ['url' => '/shop?page=1'],
|
'k1' => ['url' => '/shop?page=1', 'locale' => 'ko'],
|
||||||
'k2' => ['url' => '/shop?page=2'],
|
'k2' => ['url' => '/shop?page=2', 'locale' => 'ko'],
|
||||||
'k3' => ['url' => '/shop?page=3'],
|
'k3' => ['url' => '/shop?page=3', 'locale' => 'ko'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=4'));
|
$this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=4', 'ko'));
|
||||||
// 다른 경로는 자기 예산을 따로 쓴다
|
// 다른 경로는 자기 예산을 따로 쓴다
|
||||||
$this->assertTrue(SeoCacheBounds::canStore($index, '/board?page=1'));
|
$this->assertTrue(SeoCacheBounds::canStore($index, '/board?page=1', 'ko'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,11 +122,11 @@ class SeoCacheBoundsTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$index = [
|
$index = [
|
||||||
'k1' => ['url' => '/a'],
|
'k1' => ['url' => '/a', 'locale' => 'ko'],
|
||||||
'k2' => ['url' => '/b'],
|
'k2' => ['url' => '/b', 'locale' => 'ko'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->assertFalse(SeoCacheBounds::canStore($index, '/c'));
|
$this->assertFalse(SeoCacheBounds::canStore($index, '/c', 'ko'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,4 +162,27 @@ class SeoCacheBoundsTest extends TestCase
|
|||||||
|
|
||||||
$this->assertFalse(SeoCacheBounds::statsAllowed('10.0.0.3'));
|
$this->assertFalse(SeoCacheBounds::statsAllowed('10.0.0.3'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경로당 변종 상한은 언어별로 따로 센다 — 인덱스 항목은 url|locale 별이므로 경로만 보고
|
||||||
|
* 합산하면 언어 수만큼 실효 상한이 줄어든다.
|
||||||
|
*
|
||||||
|
* @effects store_counts_path_variants_per_locale
|
||||||
|
*/
|
||||||
|
public function test_can_store_counts_path_variants_per_locale(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'core.seo_cache_limits.max_variants_per_path' => 2,
|
||||||
|
'core.seo_cache_limits.max_entries' => 20000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$index = [
|
||||||
|
'k1' => ['url' => '/shop?page=1', 'locale' => 'ko'],
|
||||||
|
'k2' => ['url' => '/shop?page=2', 'locale' => 'ko'],
|
||||||
|
'k3' => ['url' => '/shop?page=1', 'locale' => 'en'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=3', 'ko'));
|
||||||
|
$this->assertTrue(SeoCacheBounds::canStore($index, '/shop?page=2', 'en'), '다른 언어는 자기 예산을 따로 쓴다');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,4 +423,63 @@ class SeoCacheManagerTest extends TestCase
|
|||||||
$manager->putWithLayout('/shop?page=6', 'ko', '<html>6</html>', 'shop');
|
$manager->putWithLayout('/shop?page=6', 'ko', '<html>6</html>', 'shop');
|
||||||
$this->assertSame('<html>6</html>', $manager->get('/shop?page=6', 'ko'));
|
$this->assertSame('<html>6</html>', $manager->get('/shop?page=6', 'ko'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지와 함께 저장한 레이아웃명을 꺼낼 수 있다 — 캐시 적중(HIT)은 렌더러를 거치지
|
||||||
|
* 않으므로 요청 속성에 레이아웃명이 없고, 캐시 항목이 그것을 알아야 통계가 화면별로 귀속된다.
|
||||||
|
*
|
||||||
|
* @effects cache_hit_carries_layout_name_from_cache_entry
|
||||||
|
*/
|
||||||
|
public function test_get_entry_returns_layout_stored_with_page(): void
|
||||||
|
{
|
||||||
|
$this->cacheManager->putWithLayout('/products/1', 'ko', '<html>p</html>', 'shop/show');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['html' => '<html>p</html>', 'layout' => 'shop/show'],
|
||||||
|
$this->cacheManager->getEntry('/products/1', 'ko')
|
||||||
|
);
|
||||||
|
$this->assertSame('<html>p</html>', $this->cacheManager->get('/products/1', 'ko'));
|
||||||
|
$this->assertNull($this->cacheManager->getEntry('/nope', 'ko'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 버전이 문자열로만 저장한 항목도 그대로 읽힌다 — 배포 직후 살아 있는 캐시를 버리지 않는다.
|
||||||
|
*
|
||||||
|
* @effects cache_hit_carries_layout_name_from_cache_entry
|
||||||
|
*/
|
||||||
|
public function test_get_reads_legacy_string_entries(): void
|
||||||
|
{
|
||||||
|
$driver = new CoreCacheDriver('array');
|
||||||
|
$manager = new SeoCacheManager($driver);
|
||||||
|
|
||||||
|
$driver->put('seo.page.'.md5('/legacy|ko'), '<html>legacy</html>', 3600);
|
||||||
|
|
||||||
|
$this->assertSame('<html>legacy</html>', $manager->get('/legacy', 'ko'));
|
||||||
|
$this->assertSame(['html' => '<html>legacy</html>', 'layout' => null], $manager->getEntry('/legacy', 'ko'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경로당 변종 상한은 언어별로 따로 센다 — 인덱스 항목은 url|locale 별인데 경로만 보고
|
||||||
|
* 합산하면 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 캐시에서 빠진다.
|
||||||
|
*
|
||||||
|
* @effects store_counts_path_variants_per_locale
|
||||||
|
*/
|
||||||
|
public function test_path_variant_cap_is_counted_per_locale(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'core.seo_cache_limits.max_variants_per_path' => 3,
|
||||||
|
'core.seo_cache_limits.max_entries' => 20000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for ($i = 1; $i <= 3; $i++) {
|
||||||
|
$this->cacheManager->putWithLayout('/shop?page='.$i, 'ko', '<html>ko'.$i.'</html>', 'shop');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ko 는 상한 도달, en 은 자기 예산을 따로 쓴다
|
||||||
|
$this->cacheManager->putWithLayout('/shop?page=4', 'ko', '<html>ko4</html>', 'shop');
|
||||||
|
$this->cacheManager->putWithLayout('/shop?page=1', 'en', '<html>en1</html>', 'shop');
|
||||||
|
|
||||||
|
$this->assertNull($this->cacheManager->get('/shop?page=4', 'ko'));
|
||||||
|
$this->assertSame('<html>en1</html>', $this->cacheManager->get('/shop?page=1', 'en'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,4 +281,22 @@ class SeoCacheStatsServiceTest extends TestCase
|
|||||||
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/recent/1']);
|
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/recent/1']);
|
||||||
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/today/1']);
|
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/today/1']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계의 url 컬럼은 캐시 키 URL(경로 + 정규화 쿼리 최대 512바이트)을 그대로 담을 수 있어야
|
||||||
|
* 한다 — 짧으면 긴 주소의 기록이 조용히 실패한다(예외는 서비스가 삼키고 warning 만 남긴다).
|
||||||
|
*
|
||||||
|
* @effects stats_url_column_fits_normalized_cache_url
|
||||||
|
*/
|
||||||
|
public function test_record_persists_url_as_long_as_the_cache_url_bound(): void
|
||||||
|
{
|
||||||
|
$url = '/shop/products?'.str_repeat('filters[a]=0123456789&', 24).'page=2';
|
||||||
|
|
||||||
|
$this->assertGreaterThan(255, strlen($url));
|
||||||
|
$this->assertLessThanOrEqual(768, strlen($url));
|
||||||
|
|
||||||
|
$this->statsService->recordHit($url, 'ko', 'shop/index');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('seo_cache_stats', ['url' => $url, 'type' => 'hit']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
int $priority,
|
int $priority,
|
||||||
array $assets,
|
array $assets,
|
||||||
string $strategy = 'global',
|
string $strategy = 'global',
|
||||||
?array $declaredPaths = null
|
?array $declaredPaths = null,
|
||||||
|
?array $builtPaths = null
|
||||||
): object {
|
): object {
|
||||||
$ext = Mockery::mock();
|
$ext = Mockery::mock();
|
||||||
$ext->shouldReceive('hasAssets')->andReturn($assets !== []);
|
$ext->shouldReceive('hasAssets')->andReturn($assets !== []);
|
||||||
@@ -142,8 +143,10 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
'dependencies' => [],
|
'dependencies' => [],
|
||||||
]);
|
]);
|
||||||
$ext->shouldReceive('getAssets')->andReturn($assets);
|
$ext->shouldReceive('getAssets')->andReturn($assets);
|
||||||
|
// 실제 확장은 존재하는 산출물만 built 경로로 돌려준다(getBuiltAssetPaths 가 file_exists 로 거른다).
|
||||||
|
// 기본값은 "산출물 없음" 상태를 흉내 내는 부재 경로다 — 존재하는 산출물을 흉내 내려면 $builtPaths 로 지정한다.
|
||||||
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn(
|
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn(
|
||||||
array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets)
|
$builtPaths ?? array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets)
|
||||||
);
|
);
|
||||||
$ext->shouldReceive('getBuiltAssetPaths')->andReturn(
|
$ext->shouldReceive('getBuiltAssetPaths')->andReturn(
|
||||||
array_map(fn () => 'dist/css/module.css', $assets)
|
array_map(fn () => 'dist/css/module.css', $assets)
|
||||||
@@ -914,6 +917,7 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
100,
|
100,
|
||||||
['css' => ['output' => 'dist/css/module.css']],
|
['css' => ['output' => 'dist/css/module.css']],
|
||||||
'global',
|
'global',
|
||||||
|
['css' => $cssPath],
|
||||||
['css' => $cssPath]
|
['css' => $cssPath]
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -982,4 +986,44 @@ class ExtensionBundleServiceTest extends TestCase
|
|||||||
$this->assertFileExists($path);
|
$this->assertFileExists($path);
|
||||||
$this->assertSame(0, filesize($path));
|
$this->assertSame(0, filesize($path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 병합 단계에서 건너뛴 확장이 하나라도 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데
|
||||||
|
* 읽기·치환이 실패한 상태가 0바이트(또는 일부 빠진) 캐시로 굳으면 버전 bump 전까지 그
|
||||||
|
* 확장 스타일이 사라진 채 고정된다. 종전처럼 매 요청 재시도해 원인이 사라지면 회복한다.
|
||||||
|
*
|
||||||
|
* @effects prod_build_with_skipped_extension_is_not_cached
|
||||||
|
*/
|
||||||
|
public function test_prod_does_not_cache_when_an_extension_was_skipped_during_merge(): void
|
||||||
|
{
|
||||||
|
$this->app['env'] = 'production';
|
||||||
|
app()->detectEnvironment(fn () => 'production');
|
||||||
|
|
||||||
|
$cssPath = $this->writeFixture('unreadable.css', '.a{color:red}');
|
||||||
|
|
||||||
|
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||||
|
'ext-unreadable' => $this->fakeExtension('ext-unreadable', 100, null, $cssPath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$svc = new class($this->moduleManager, $this->pluginManager) extends ExtensionBundleService
|
||||||
|
{
|
||||||
|
public string $failOn = '';
|
||||||
|
|
||||||
|
protected function readAssetSource(string $path): string|false
|
||||||
|
{
|
||||||
|
return $path === $this->failOn ? false : parent::readAssetSource($path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$svc->failOn = $cssPath;
|
||||||
|
|
||||||
|
$this->assertSame('', $svc->getBundleFilePath('module', 'css', 424250));
|
||||||
|
$this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424250.css'));
|
||||||
|
|
||||||
|
// 원인이 사라지면 다음 요청이 정상 캐시한다 (매 요청 재시도)
|
||||||
|
$svc->failOn = '';
|
||||||
|
$path = $svc->getBundleFilePath('module', 'css', 424250);
|
||||||
|
|
||||||
|
$this->assertNotSame('', $path);
|
||||||
|
$this->assertStringEqualsFile($path, '.a{color:red}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ effects:
|
|||||||
- prod_build_lock_failure_falls_back_to_unlocked_build
|
- prod_build_lock_failure_falls_back_to_unlocked_build
|
||||||
- prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
|
- prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
|
||||||
- prod_empty_result_with_missing_artifact_is_not_cached
|
- prod_empty_result_with_missing_artifact_is_not_cached
|
||||||
|
- prod_build_with_skipped_extension_is_not_cached
|
||||||
- zero_byte_bundle_is_statically_published
|
- zero_byte_bundle_is_statically_published
|
||||||
|
|
||||||
test_files:
|
test_files:
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ description: |
|
|||||||
- 렌더 예산: IP 당 분당 미스 렌더 상한, 초과분은 SPA + X-SEO-Cache: BYPASS (429 아님)
|
- 렌더 예산: IP 당 분당 미스 렌더 상한, 초과분은 SPA + X-SEO-Cache: BYPASS (429 아님)
|
||||||
- 저장 규모: 경로당 변종 수 · 전체 항목 수 상한, 기존 키 갱신은 상한과 무관
|
- 저장 규모: 경로당 변종 수 · 전체 항목 수 상한, 기존 키 갱신은 상한과 무관
|
||||||
- 통계: 미들웨어가 HIT/MISS 를 기록하되 IP 당 분당 상한 안에서만
|
- 통계: 미들웨어가 HIT/MISS 를 기록하되 IP 당 분당 상한 안에서만
|
||||||
|
- 캐시 항목은 HTML + 레이아웃명 — HIT 통계가 화면별로 귀속된다 (이전 문자열 항목 호환)
|
||||||
|
- 경로당 변종 상한은 언어별로 센다 (인덱스 항목이 url|locale 별이므로)
|
||||||
|
- 렌더러가 null 을 돌려준 요청은 렌더 예산을 되돌린다 (예외는 되돌리지 않는다)
|
||||||
|
- 통계 url 컬럼(768)은 캐시 키 URL 상한을 담는다 — 마이그레이션 왕복 안전
|
||||||
|
|
||||||
axes:
|
axes:
|
||||||
bot_state: [bot, browser]
|
bot_state: [bot, browser]
|
||||||
@@ -39,8 +43,14 @@ effects:
|
|||||||
- expired_index_entries_are_pruned_before_cap_verdict
|
- expired_index_entries_are_pruned_before_cap_verdict
|
||||||
- put_and_put_with_layout_share_the_storage_cap
|
- put_and_put_with_layout_share_the_storage_cap
|
||||||
- index_prune_is_throttled_to_one_scan_per_interval
|
- index_prune_is_throttled_to_one_scan_per_interval
|
||||||
|
- cache_hit_carries_layout_name_from_cache_entry
|
||||||
|
- store_counts_path_variants_per_locale
|
||||||
|
- null_render_refunds_render_budget
|
||||||
|
- stats_url_column_fits_normalized_cache_url
|
||||||
|
|
||||||
test_files:
|
test_files:
|
||||||
- tests/Unit/Seo/SeoCacheBoundsTest.php
|
- tests/Unit/Seo/SeoCacheBoundsTest.php
|
||||||
- tests/Unit/Seo/SeoCacheManagerTest.php
|
- tests/Unit/Seo/SeoCacheManagerTest.php
|
||||||
- tests/Feature/Seo/SeoMiddlewareTest.php
|
- tests/Feature/Seo/SeoMiddlewareTest.php
|
||||||
|
- tests/Unit/Seo/SeoCacheStatsServiceTest.php
|
||||||
|
- tests/Feature/Database/ModifyUrlInSeoCacheStatsMigrationTest.php
|
||||||
|
|||||||
Reference in New Issue
Block a user