Merge pull request from gnuboard:HeuJung/issue659

fix(core): SEO 봇 캐시·확장 번들 캐시의 정합 결함 5건 수정
This commit is contained in:
정정홍
2026-09-08 09:08:02 +09:00
committed by GitHub
26 changed files with 2176 additions and 178 deletions
+10
View File
@@ -143,6 +143,16 @@ G7_UPDATE_PENDING_PATH=
# 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 # 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」
# G7_STATIC_CACHE=true # G7_STATIC_CACHE=true
# SEO 봇 캐시 상한 (config/core.php seo_cache_limits). 봇 판정은 User-Agent 뿐이라 위장이
# 가능하고, 캐시 키에 쿼리가 들어가므로 값만 바꾼 반복 요청이 매번 렌더·저장을 유발한다.
# 렌더 예산을 넘긴 요청은 오류가 아니라 일반 SPA 응답(X-SEO-Cache: BYPASS)을 받는다.
# G7_SEO_CACHE_MAX_QUERY_PARAMS=10
# G7_SEO_CACHE_MAX_QUERY_LENGTH=512
# G7_SEO_CACHE_MAX_VARIANTS_PER_PATH=50
# G7_SEO_CACHE_MAX_ENTRIES=20000
# G7_SEO_RENDER_MISSES_PER_MINUTE=60
# G7_SEO_STATS_RECORDS_PER_MINUTE=300
# 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을 # 코어 업데이트 경로 목록 (선택). 재정의 시 **전체 목록을 다시 적는다** — 부분값은 기본 항목을
# 통째로 대체한다 (예: excludes 에서 build/ext 가 빠지면 --prune 이 정적 게시본을 지운다). # 통째로 대체한다 (예: excludes 에서 build/ext 가 빠지면 --prune 이 정적 게시본을 지운다).
# 기본값은 config/app.php 의 update 절과 같다. # 기본값은 config/app.php 의 update 절과 같다.
+2
View File
@@ -1433,6 +1433,8 @@ lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱
- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들. - 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들.
- 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회 빌드에 수렴하고 잠금 뒤 캐시를 재확인하며, 잠금 대기 초과·저장소 장애는 실패가 아니라 각자 빌드로 폴백한다.
- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)·병합 단계에서 건너뛴 확장이 있는 결과(굳지 않도록 매 요청 재시도)·디스크 쓰기 실패뿐이다.
### 빌드 명령어 (Artisan) ### 빌드 명령어 (Artisan)
+4
View File
@@ -23,6 +23,10 @@
- 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다. - 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다.
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다. - 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
- 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다. - 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다.
- 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다.
- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다.
- 확장의 스크립트·스타일 파일을 읽지 못하는 상태가 되면 그 확장의 자산이 빠진 결과가 저장되어, 파일 문제가 풀린 뒤에도 확장을 다시 설치하거나 설정을 바꿀 때까지 계속 빠진 채로 남던 문제를 수정했습니다. 이제 읽지 못한 확장이 있으면 그 결과를 저장하지 않아 원인이 사라지는 즉시 정상으로 돌아오며, 어느 확장을 읽지 못했는지 서버 기록에 남습니다.
## [7.0.10] - 2026-09-06 ## [7.0.10] - 2026-09-06
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Seo;
use Illuminate\Support\Facades\RateLimiter;
/**
* SEO 봇 캐시의 상한 판정 단일 출처
*
* 봇 판정은 User-Agent 문자열뿐이라 누구나 위장할 수 있다. 그런데 캐시 키가 경로 + 전체
* 쿼리였으므로 물음표 뒤 값만 바꾸면 매 요청이 미스가 되고, 미스마다 레이아웃 병합 ·
* 표현식 평가 · 자기 API 루프백 HTTP 호출이 일어나고 그 결과가 무제한으로 저장됐다.
* 요청 하나가 워커 여러 개를 묶고 캐시 저장소를 계속 키우는 통로였다.
*
* 상한은 셋으로 나뉜다:
*
* - **키 형태** — 정규화할 수 없을 만큼 큰 쿼리는 애초에 색인 대상이 아니다(캐시하지 않고 SPA).
* - **렌더 예산** — IP 당 분당 미스 렌더 수. 초과분은 SPA 로 돌려보낸다(429 가 아니다 — 봇에게
* 오류를 주면 색인에서 그 URL 이 사라진다).
* - **저장 규모** — 경로당 변종 수와 전체 항목 수.
*
* 값은 `config/core.php` 의 `seo_cache_limits` 가 SSoT 이고 env 로 덮을 수 있다.
*/
final class SeoCacheBounds
{
/**
* 캐시 키에서 제외하는 시스템 파라미터.
*
* `locale` 은 캐시 키의 별도 축이고, `_escaped_fragment_` 는 봇 렌더 요청 표식일 뿐
* 내용에 영향이 없다 — 키에 남기면 같은 페이지가 두 벌 저장된다.
*/
private const SYSTEM_QUERY_PARAMS = ['locale', '_escaped_fragment_'];
/**
* 상한값을 반환합니다.
*
* @param string $key `seo_cache_limits` 하위 키
* @param int $fallback 설정이 없을 때의 기본값
* @return int 상한값
*/
private static function limit(string $key, int $fallback): int
{
return (int) config('core.seo_cache_limits.'.$key, $fallback);
}
/**
* 캐시 키에 쓸 쿼리 파라미터를 정규화합니다.
*
* 시스템 파라미터를 제거하고 키 순서로 정렬한다. 개수·길이 상한을 넘으면 **null** 을
* 돌려주며, 그것은 "이 URL 은 캐시하지도 렌더하지도 않는다" 를 뜻한다.
*
* @param array<string, mixed> $query 요청 쿼리 파라미터
* @return array<string, mixed>|null 정규화된 파라미터 (상한 초과 시 null)
*/
public static function normalizeQuery(array $query): ?array
{
foreach (self::SYSTEM_QUERY_PARAMS as $param) {
unset($query[$param]);
}
if (count($query) > self::limit('max_query_params', 10)) {
return null;
}
ksort($query);
if (strlen(http_build_query($query)) > self::limit('max_query_length', 512)) {
return null;
}
return $query;
}
/**
* 이 IP 가 미스 렌더를 더 수행할 수 있는지 판정합니다.
*
* @param string $ip 요청 IP
* @return bool 렌더 허용 여부
*/
public static function renderAllowed(string $ip): bool
{
return ! RateLimiter::tooManyAttempts(
'seo-render:'.$ip,
self::limit('render_misses_per_minute', 60)
);
}
/**
* 미스 렌더 1건을 예산에서 차감합니다.
*
* @param string $ip 요청 IP
*/
public static function recordRender(string $ip): void
{
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 의 요청을 통계로 기록할 수 있는지 판정합니다.
*
* 통계 테이블이 새로운 증식 축이 되지 않도록 기록 자체에도 상한을 둔다.
*
* @param string $ip 요청 IP
* @return bool 기록 허용 여부
*/
public static function statsAllowed(string $ip): bool
{
return ! RateLimiter::tooManyAttempts(
'seo-stats:'.$ip,
self::limit('stats_records_per_minute', 300)
);
}
/**
* 통계 기록 1건을 예산에서 차감합니다.
*
* @param string $ip 요청 IP
*/
public static function recordStat(string $ip): void
{
RateLimiter::hit('seo-stats:'.$ip, 60);
}
/**
* 새 URL 을 캐시에 저장할 수 있는지 판정합니다.
*
* 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가
* 늘지 않기 때문이다.
*
* 경로당 변종은 **언어별로** 센다. 인덱스 항목은 url|locale 별이라 경로만 보고 합산하면
* 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 언어마다 캐시에서 빠진다.
*
* @param array<string, array<string, mixed>> $index 현재 캐시 인덱스
* @param string $url 저장하려는 URL (경로 + 정규화 쿼리)
* @param string $locale 저장하려는 로케일
* @return bool 저장 허용 여부
*/
public static function canStore(array $index, string $url, string $locale): bool
{
if (count($index) >= self::limit('max_entries', 20000)) {
return false;
}
$path = self::pathOf($url);
$variants = 0;
foreach ($index as $entry) {
if (($entry['locale'] ?? null) !== $locale) {
continue;
}
if (self::pathOf((string) ($entry['url'] ?? '')) === $path) {
$variants++;
}
}
return $variants < self::limit('max_variants_per_path', 50);
}
/**
* URL 에서 경로 부분만 잘라냅니다.
*
* @param string $url 캐시 URL (`/path?a=1` 형태)
* @return string 경로
*/
private static function pathOf(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) ? $path : $url;
}
}
+125 -43
View File
@@ -18,20 +18,57 @@ class SeoCacheManager implements SeoCacheManagerInterface
*/ */
private const INDEX_KEY = 'seo.cached_urls'; private const INDEX_KEY = 'seo.cached_urls';
/**
* 저장 상한에서 인덱스를 정리한 시각을 남기는 표식 키
*/
private const PRUNE_MARK_KEY = 'seo.index_pruned_at';
/**
* 저장 상한에서 인덱스 정리를 다시 시도하기까지의 최소 간격 (초)
*/
private const PRUNE_INTERVAL_SECONDS = 60;
public function __construct(private readonly CacheInterface $cache) {} public function __construct(private readonly CacheInterface $cache) {}
/** /**
* {@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;
} }
/** /**
@@ -39,17 +76,7 @@ class SeoCacheManager implements SeoCacheManagerInterface
*/ */
public function put(string $url, string $locale, string $html): void public function put(string $url, string $locale, string $html): void
{ {
if (! $this->isEnabled()) { $this->storePage($url, $locale, $html, null);
return;
}
$key = $this->buildKey($url, $locale);
$ttl = $this->getCacheTtl();
$this->cache->put($key, $html, $ttl);
// URL 인덱스 업데이트
$this->addToIndex($url, $locale, $key);
} }
/** /**
@@ -148,26 +175,6 @@ class SeoCacheManager implements SeoCacheManagerInterface
return SeoCacheSettings::pageCacheTtl(); return SeoCacheSettings::pageCacheTtl();
} }
/**
* URL 인덱스에 항목을 추가합니다.
*
* @param string $url URL
* @param string $locale 로케일
* @param string $key 캐시 키
*/
private function addToIndex(string $url, string $locale, string $key): void
{
$index = $this->getIndex();
$index[$key] = [
'url' => $url,
'locale' => $locale,
'key' => $key,
'cached_at' => now()->toIso8601String(),
];
$this->cache->put(self::INDEX_KEY, $index, 86400 * 30); // 30일
}
/** /**
* 캐시 인덱스를 조회합니다. * 캐시 인덱스를 조회합니다.
*/ */
@@ -177,9 +184,34 @@ class SeoCacheManager implements SeoCacheManagerInterface
} }
/** /**
* 유효한 캐시만 남겨 인덱스를 재구성합니다. * 저장 상한에서 인덱스 정리를 시도해도 되는지 판정하고, 시도한다면 표식을 남깁니다.
*
* 정리는 인덱스 전체를 훑는다(항목마다 캐시 조회). 살아 있는 항목만으로 상한에 닿은
* 경로는 저장 시도마다 그 스캔을 되풀이하게 되고, 그 빈도는 봇 미스 렌더 예산만큼이다
* — 정리해도 자리가 나지 않는 상태에서 비용만 곱해진다. 그래서 간격으로 묶는다.
*
* @return bool 정리를 수행해도 되면 true
*/ */
private function rebuildIndex(): void private function shouldAttemptPrune(): bool
{
if ($this->cache->has(self::PRUNE_MARK_KEY)) {
return false;
}
$this->cache->put(self::PRUNE_MARK_KEY, true, self::PRUNE_INTERVAL_SECONDS);
return true;
}
/**
* 유효한 캐시만 남겨 인덱스를 재구성하고 그 결과를 반환합니다.
*
* 페이지는 TTL 로 사라지지만 인덱스 항목은 남는다 — 이 메서드가 그 차이를 메우는
* 유일한 지점이므로, 인덱스를 근거로 판정하는 쪽(저장 상한)은 판정 전에 여기를 거친다.
*
* @return array<string, array<string, mixed>> 정리된 인덱스
*/
private function rebuildIndex(): array
{ {
$index = $this->getIndex(); $index = $this->getIndex();
$validIndex = []; $validIndex = [];
@@ -191,6 +223,8 @@ class SeoCacheManager implements SeoCacheManagerInterface
} }
$this->cache->put(self::INDEX_KEY, $validIndex, 86400 * 30); $this->cache->put(self::INDEX_KEY, $validIndex, 86400 * 30);
return $validIndex;
} }
/** /**
@@ -210,32 +244,80 @@ class SeoCacheManager implements SeoCacheManagerInterface
/** /**
* 캐시 저장 시 레이아웃 정보를 함께 저장합니다. * 캐시 저장 시 레이아웃 정보를 함께 저장합니다.
* *
* 인덱스는 단일 캐시 항목에 전체 변종 배열을 담고 저장마다 통째로 다시 쓴다. 항목 수에
* 상한이 없으면 쿼리만 바꾼 반복 요청이 그 배열을 무한히 키운다 — 그래서 **새 URL** 은
* 경로당 변종 수와 전체 항목 수 상한 안에서만 저장한다. 이미 인덱스에 있는 키의 갱신은
* 저장 규모를 늘리지 않으므로 상한과 무관하게 쓴다.
*
* @param string $url URL * @param string $url URL
* @param string $locale 로케일 * @param string $locale 로케일
* @param string $html HTML * @param string $html HTML
* @param string $layoutName 레이아웃명 * @param string $layoutName 레이아웃명
*/ */
public function putWithLayout(string $url, string $locale, string $html, string $layoutName): void public function putWithLayout(string $url, string $locale, string $html, string $layoutName): void
{
$this->storePage($url, $locale, $html, $layoutName);
}
/**
* 페이지와 인덱스 항목을 저장합니다 (`put`/`putWithLayout` 공통 경로).
*
* 두 공개 메서드는 같은 자원(페이지 캐시 + 인덱스)을 쓰므로 저장 규모 상한도 같아야
* 한다 — 한쪽에만 두면 다른 쪽이 우회로가 되고, 인터페이스는 확장에 열려 있어
* "지금 호출부가 없다" 는 방어가 되지 않는다.
*
* @param string $url URL (경로 + 정규화 쿼리)
* @param string $locale 로케일
* @param string $html 저장할 HTML
* @param string|null $layoutName 레이아웃명 (없으면 인덱스에 기록하지 않음)
*/
private function storePage(string $url, string $locale, string $html, ?string $layoutName): void
{ {
if (! $this->isEnabled()) { if (! $this->isEnabled()) {
return; return;
} }
$key = $this->buildKey($url, $locale); $key = $this->buildKey($url, $locale);
$ttl = $this->getCacheTtl();
$this->cache->put($key, $html, $ttl);
// 레이아웃 정보 포함하여 인덱스 업데이트
$index = $this->getIndex(); $index = $this->getIndex();
$index[$key] = [
if (! isset($index[$key])) {
// 상한이 세는 인덱스에는 **페이지가 이미 만료된** 항목이 섞인다 — 인덱스는
// 페이지보다 훨씬 오래 살고(30일 vs 기본 2시간) 저장마다 수명이 갱신되며
// 스스로 줄지 않는다. 여기서 한 번 정리하지 않으면 상한이 "지금 저장된 양"이
// 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 닿은 경로는 실제
// 캐시가 비어도 영영 저장이 막힌다(상한이 아니라 일방향 래치가 된다).
if (! SeoCacheBounds::canStore($index, $url, $locale) && $this->shouldAttemptPrune()) {
$index = $this->rebuildIndex();
}
if (! SeoCacheBounds::canStore($index, $url, $locale)) {
Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [
'url' => $url,
'locale' => $locale,
'entries' => count($index),
]);
return;
}
}
// 레이아웃명을 페이지와 함께 둔다 — 적중 경로가 통계를 화면별로 귀속할 유일한 출처다.
$this->cache->put($key, ['html' => $html, 'layout' => $layoutName], $this->getCacheTtl());
$entry = [
'url' => $url, 'url' => $url,
'locale' => $locale, 'locale' => $locale,
'key' => $key, 'key' => $key,
'layout' => $layoutName,
'cached_at' => now()->toIso8601String(),
]; ];
if ($layoutName !== null) {
$entry['layout'] = $layoutName;
}
$entry['cached_at'] = now()->toIso8601String();
$index[$key] = $entry;
$this->cache->put(self::INDEX_KEY, $index, 86400 * 30); $this->cache->put(self::INDEX_KEY, $index, 86400 * 30);
} }
} }
+116 -19
View File
@@ -15,10 +15,15 @@ class SeoMiddleware
private readonly BotDetector $botDetector, private readonly BotDetector $botDetector,
private readonly SeoCacheManagerInterface $cacheManager, private readonly SeoCacheManagerInterface $cacheManager,
private readonly SeoRendererInterface $renderer, private readonly SeoRendererInterface $renderer,
private readonly SeoCacheStatsService $statsService,
) {} ) {}
/** /**
* 검색 봇 요청 시 SEO HTML을 반환합니다. * 검색 봇 요청 시 SEO HTML을 반환합니다.
*
* @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response SEO HTML(HIT/MISS) 또는 SPA 폴백(BYPASS 포함)
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
@@ -56,18 +61,44 @@ class SeoMiddleware
$request->attributes->set('seo_default_locale', $defaultLocale); $request->attributes->set('seo_default_locale', $defaultLocale);
app()->setLocale($locale); app()->setLocale($locale);
// 캐시 키용 URL 생성 (경로 + 쿼리 파라미터, locale 제외) // 캐시 키용 쿼리 정규화 — 정규화할 수 없을 만큼 큰 쿼리는 색인 대상이 아니다.
$cacheUrl = $this->buildCacheUrl($request); // 그런 URL 까지 렌더·저장하면 물음표 뒤 값만 바꾼 반복 요청이 무한한 미스가 된다.
$normalizedQuery = SeoCacheBounds::normalizeQuery($request->query());
// 캐시 확인 if ($normalizedQuery === null) {
$cachedHtml = $this->cacheManager->get($cacheUrl, $locale); return $this->bypass($request, $next);
if ($cachedHtml !== null) { }
return response($cachedHtml, 200, [
$ip = (string) $request->ip();
// 캐시 키용 URL 생성 (경로 + 정규화된 쿼리)
$cacheUrl = $this->buildCacheUrl($request, $normalizedQuery);
// 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다
$entry = $this->readEntry($cacheUrl, $locale);
if ($entry !== null) {
$this->recordStat($ip, fn () => $this->statsService->recordHit(
$cacheUrl,
$locale,
$entry['layout'] ?: null
));
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',
]); ]);
} }
// 미스 렌더는 IP 당 분당 예산 안에서만 — 초과분은 오류가 아니라 SPA 를 받는다.
// 봇에게 429 를 주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해가 된다.
if (! SeoCacheBounds::renderAllowed($ip)) {
return $this->bypass($request, $next);
}
SeoCacheBounds::recordRender($ip);
$startedAt = microtime(true);
// 렌더링 // 렌더링
try { try {
$html = $this->renderer->render($request); $html = $this->renderer->render($request);
@@ -103,6 +134,10 @@ class SeoMiddleware
// 렌더링 실패 시 SPA fallback // 렌더링 실패 시 SPA fallback
if ($html === null) { if ($html === null) {
// "그릴 게 없음" 은 비용을 치르지 않았다 — 되돌리지 않으면 캐시에 남지 않는 죽은
// 주소 재크롤이 올 때마다 정상 페이지의 예산을 태운다.
SeoCacheBounds::refundRender($ip);
return $next($request); return $next($request);
} }
@@ -110,6 +145,14 @@ class SeoMiddleware
$layoutName = $request->attributes->get('seo_layout_name', ''); $layoutName = $request->attributes->get('seo_layout_name', '');
$this->cacheManager->putWithLayout($cacheUrl, $locale, $html, $layoutName); $this->cacheManager->putWithLayout($cacheUrl, $locale, $html, $layoutName);
$this->recordStat($ip, fn () => $this->statsService->recordMiss(
$cacheUrl,
$locale,
$layoutName ?: null,
null,
(int) round((microtime(true) - $startedAt) * 1000)
));
return response($html, 200, [ return response($html, 200, [
'Content-Type' => 'text/html; charset=utf-8', 'Content-Type' => 'text/html; charset=utf-8',
'X-SEO-Cache' => 'MISS', 'X-SEO-Cache' => 'MISS',
@@ -117,30 +160,84 @@ class SeoMiddleware
} }
/** /**
* 캐시 키용 URL을 생성합니다. * 캐시 항목(HTML + 레이아웃명)을 읽습니다.
* *
* 경로 + 쿼리 파라미터를 포함하되, locale 파라미터는 제외합니다. * 적중 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없다 — 통계를 화면별로
* 쿼리 파라미터를 키 순서로 정렬하여 동일 파라미터 조합이 같은 캐시 키를 생성하도록 합니다. * 귀속하려면 캐시 항목이 그것을 알아야 한다. 인터페이스(`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 폴백과 구분되지 않는다.
* *
* @param Request $request HTTP 요청 * @param Request $request HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response SPA 응답
*/
private function bypass(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-SEO-Cache', 'BYPASS');
return $response;
}
/**
* 통계 기록을 IP 당 상한 안에서만 수행합니다.
*
* 기록 자체에 상한이 없으면 통계 테이블이 새로운 증식 축이 된다.
*
* @param string $ip 요청 IP
* @param callable $record 기록 동작
*/
private function recordStat(string $ip, callable $record): void
{
if (! SeoCacheBounds::statsAllowed($ip)) {
return;
}
SeoCacheBounds::recordStat($ip);
$record();
}
/**
* 캐시 키용 URL을 생성합니다.
*
* 경로 + 정규화된 쿼리 파라미터로 구성한다. 정규화는 시스템 파라미터(`locale`,
* `_escaped_fragment_`)를 제거하고 키 순서로 정렬하므로, 같은 조합은 순서와 무관하게
* 같은 키가 되고 봇 렌더 표식만 다른 두 URL 이 두 벌로 저장되지 않는다.
*
* @param Request $request HTTP 요청
* @param array<string, mixed> $normalizedQuery 정규화된 쿼리 파라미터
* @return string 캐시 키용 URL * @return string 캐시 키용 URL
*/ */
private function buildCacheUrl(Request $request): string private function buildCacheUrl(Request $request, array $normalizedQuery): string
{ {
$path = $request->getPathInfo(); $path = $request->getPathInfo();
// locale을 제외한 쿼리 파라미터 추출 if ($normalizedQuery === []) {
$query = $request->query();
unset($query['locale']);
if (empty($query)) {
return $path; return $path;
} }
// 키 순서 정렬 (동일 파라미터 조합 → 동일 캐시 키 보장) return $path.'?'.http_build_query($normalizedQuery);
ksort($query);
return $path.'?'.http_build_query($query);
} }
/** /**
+283 -99
View File
@@ -9,6 +9,8 @@ use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\View\Composers\TemplateComposer; use App\Http\View\Composers\TemplateComposer;
use App\Support\AssetCssUrlRewriter; use App\Support\AssetCssUrlRewriter;
use App\Support\AssetUrl; use App\Support\AssetUrl;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** /**
@@ -49,6 +51,21 @@ class ExtensionBundleService
*/ */
private const TEMP_BUNDLE_STALE_SECONDS = 600; private const TEMP_BUNDLE_STALE_SECONDS = 600;
/**
* 캐시 미스 빌드를 (type, kind, version) 단위로 수렴시키는 잠금 키 접두사.
*/
private const BUILD_LOCK_PREFIX = 'ext-bundles.build.';
/**
* 빌드 잠금의 보유 상한 (초). 좀비 잠금 방지용이며 실제 빌드는 밀리초 단위다.
*/
private const BUILD_LOCK_TTL_SECONDS = 30;
/**
* 다른 프로세스의 빌드를 기다리는 상한 (초). 초과하면 잠금 없이 각자 빌드한다.
*/
private const BUILD_LOCK_WAIT_SECONDS = 5;
/** /**
* 서비스 주입 * 서비스 주입
* *
@@ -137,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);
} }
/** /**
@@ -186,12 +171,265 @@ class ExtensionBundleService
* @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열) * @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열)
*/ */
public function buildCssBundle(string $type): string public function buildCssBundle(string $type): string
{
return $this->mergeBundle($type, 'css')['content'];
}
/**
* 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write).
*
* 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이
* 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만
* 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해
* rebuild 를 즉시 반영한다.
*
* 프로덕션에서 **캐시 존재 확인이 빌드보다 먼저** 온다. 캐시 키는 인자만으로
* 계산되므로 빌드가 필요 없는데, 빌드를 앞세우면 캐시가 있어도 요청마다 활성 확장을
* 열거하고 산출물을 전부 읽는다. 응답은 정상 200 이라 그 반복은 타이밍 말고는 드러나지
* 않고, 원본이 사라지는 순간에는 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 된다.
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion)
* @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 캐시할 수 없으면 빈 문자열.
*/
public function getBundleFilePath(string $type, string $kind, int $version): string
{
$relativeName = $this->bundleFileName($type, $kind, $version);
// 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
// `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
// `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가
// 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면
// 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙).
try {
$storage = $this->bundleStorage();
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
if (! app()->environment('production')) {
$content = $this->buildBundleContent($type, $kind);
// 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단).
if ($content === '') {
return '';
}
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
}
// 프로덕션: 동일 version 캐시가 있으면 빌드 없이 그대로 사용
if ($storage->exists('', $relativeName)) {
return $storage->getBasePath('').'/'.$relativeName;
}
return $this->buildAndCacheOnce($storage, $type, $kind, $version, $relativeName);
} catch (\Throwable $e) {
Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'error' => $e->getMessage(),
]);
return '';
}
}
/**
* 캐시 미스에서 한 번만 병합해 캐시 파일을 만들고 그 절대 경로를 반환합니다.
*
* 같은 (type, kind, version) 의 동시 요청은 잠금으로 하나에 수렴시킨다 — 버전 교체
* 직후에는 캐시가 없는 상태로 요청이 몰리고, 각자 병합하면 그 비용이 워커 수만큼 곱해진다.
* 잠금은 **최적화**이므로 대기 초과·저장소 장애는 실패로 바꾸지 않고 각자 빌드로 폴백한다
* (정적 게시 잠금 `ext-static.publish.{v}` 와 같은 저장소·같은 규율이며 키가 달라 자기
* 교착이 없다).
*
* 병합 결과가 비어 있어도 선언한 산출물이 **전부 존재하면**(또는 선언이 0이면) 0바이트
* 캐시 파일을 만든다. 그래야 정적 게시 대상이 되어 방문자가 웹서버에서 직접 받는다 —
* 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 둘이다 —
* 선언한 산출물이 **소실**된 경우(호출측의 503 판정을 그대로 유지한다)와 병합 단계에서
* 확장을 **건너뛴** 경우(그 상태가 굳지 않도록 매 요청 재시도에 맡긴다).
*
* @param CoreStorageDriver $storage 번들 디스크 스토리지
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @param int $version 확장 캐시 버전
* @param string $relativeName 캐시 파일명
* @return string 캐시 파일의 절대 경로 (캐시하지 않았으면 빈 문자열)
*/
private function buildAndCacheOnce(
CoreStorageDriver $storage,
string $type,
string $kind,
int $version,
string $relativeName
): string {
$lock = null;
$acquired = false;
try {
$lock = Cache::lock(self::BUILD_LOCK_PREFIX."{$type}.{$kind}.{$version}", self::BUILD_LOCK_TTL_SECONDS);
$acquired = (bool) $lock->block(self::BUILD_LOCK_WAIT_SECONDS);
} catch (LockTimeoutException $e) {
// 대기 초과는 정상적인 경합이다 — 흔적만 남기고 각자 빌드한다.
Log::debug('확장 번들 빌드 잠금 대기 초과 — 잠금 없이 병합합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
]);
} catch (\Throwable $e) {
// 저장소가 잠금을 제공하지 못한다(드라이버 미지원, 캐시 디렉토리 권한 등).
// 번들 서빙 자체를 막지는 않으므로 사유만 남기고 계속한다.
Log::warning('확장 번들 빌드 잠금 획득 불가 — 잠금 없이 병합합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'store' => config('cache.default'),
'error' => $e->getMessage(),
]);
}
try {
// 기다리는 동안 다른 프로세스가 완성했을 수 있다 — 병합 전에 다시 본다.
if ($storage->exists('', $relativeName)) {
return $storage->getBasePath('').'/'.$relativeName;
}
['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 가 잠깐 빈
// 장애가 0바이트 캐시로 굳어 정상(빈 200)으로 위장되면 안 된다.
if ($content === '' && $this->findMissingDeclaredAssets($type, $kind) !== []) {
return '';
}
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
} finally {
if ($acquired && $lock !== null) {
try {
$lock->release();
} catch (\Throwable $e) {
// 해제 실패는 TTL 이 정리한다 — 서빙에는 영향이 없다.
}
}
}
}
/**
* 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용).
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @return string 병합 결과 (없으면 빈 문자열)
*/
public function buildBundleContent(string $type, string $kind): string
{
return $this->mergeBundle($type, $kind)['content'];
}
/**
* 병합 결과와 함께 **건너뛴 확장**을 돌려줍니다 (캐시 판정용).
*
* 캐시할지는 결과 문자열만으로 판정할 수 없다 — 파일은 존재·판독 가능한데 읽기나 치환이
* 실패해 건너뛴 확장은 결과에서 조용히 빠질 뿐이다. 그 상태가 캐시로 굳으면 버전 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); $ordered = $this->getOrderedGlobalAssetPaths($type);
$isProduction = app()->environment('production'); $isProduction = app()->environment('production');
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules'; $typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
$version = $this->getCurrentVersion(); $version = $this->getCurrentVersion();
$segments = []; $segments = [];
$skipped = [];
foreach ($ordered as $identifier => $paths) { foreach ($ordered as $identifier => $paths) {
if (empty($paths['cssAbsPath'])) { if (empty($paths['cssAbsPath'])) {
@@ -199,7 +437,7 @@ class ExtensionBundleService
} }
try { try {
$content = @file_get_contents($paths['cssAbsPath']); $content = $this->readAssetSource($paths['cssAbsPath']);
if ($content === false) { if ($content === false) {
Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
@@ -207,6 +445,7 @@ class ExtensionBundleService
'identifier' => $identifier, 'identifier' => $identifier,
'path' => $paths['cssAbsPath'], 'path' => $paths['cssAbsPath'],
]); ]);
$skipped[] = (string) $identifier;
continue; continue;
} }
@@ -237,81 +476,26 @@ class ExtensionBundleService
'identifier' => $identifier, 'identifier' => $identifier,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
$skipped[] = (string) $identifier;
} }
} }
return implode("\n", $segments); return ['segments' => $segments, 'skipped' => $skipped];
} }
/** /**
* 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write). * 확장 자산 원본을 읽습니다.
* *
* 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이 * 실패는 `false` 로 돌아오고 호출측이 그 확장을 건너뛴다. 별도 메서드인 이유는
* 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만 * "존재·판독 가능한데 읽기가 실패하는" 상태를 테스트가 재현할 수 있어야 하기 때문이다 —
* 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해 * 그 상태가 캐시로 굳는 것이 이 서비스가 막아야 할 결함이다.
* rebuild 를 즉시 반영한다.
* *
* @param string $type 'module' | 'plugin' * @param string $path 절대 경로
* @param string $kind 'js' | 'css' * @return string|false 파일 내용 (실패 시 false)
* @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion)
* @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 병합 결과가 빈 문자열이면 빈 문자열.
*/ */
public function getBundleFilePath(string $type, string $kind, int $version): string protected function readAssetSource(string $path): string|false
{ {
$content = $kind === 'css' return @file_get_contents($path);
? $this->buildCssBundle($type)
: $this->buildJsBundle($type);
// 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단).
if ($content === '') {
return '';
}
$relativeName = $this->bundleFileName($type, $kind, $version);
// 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
// `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
// `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가
// 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면
// 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙).
try {
$storage = $this->bundleStorage();
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
if (! app()->environment('production')) {
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
}
// 프로덕션: 동일 version 캐시가 있으면 그대로 사용
if ($storage->exists('', $relativeName)) {
return $storage->getBasePath('').'/'.$relativeName;
}
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
} catch (\Throwable $e) {
Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [
'type' => $type,
'kind' => $kind,
'version' => $version,
'error' => $e->getMessage(),
]);
return '';
}
}
/**
* 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용).
*
* @param string $type 'module' | 'plugin'
* @param string $kind 'js' | 'css'
* @return string 병합 결과 (없으면 빈 문자열)
*/
public function buildBundleContent(string $type, string $kind): string
{
return $kind === 'css'
? $this->buildCssBundle($type)
: $this->buildJsBundle($type);
} }
/** /**
+36
View File
@@ -120,6 +120,42 @@ return [
'enabled' => env('G7_STATIC_CACHE', true), 'enabled' => env('G7_STATIC_CACHE', true),
], ],
/*
|--------------------------------------------------------------------------
| SEO 봇 캐시 상한
|--------------------------------------------------------------------------
| 봇 판정은 User-Agent 문자열뿐이라 위장이 가능하고, 캐시 키에 쿼리가 들어가므로
| 물음표 뒤 값만 바꾸면 매 요청이 미스가 됩니다. 미스 1건은 레이아웃 병합·표현식
| 평가·자기 API 루프백 호출을 유발하고 그 결과가 캐시에 쌓입니다.
|
| 아래 값이 그 증식을 막는 상한입니다. 렌더 예산을 넘긴 요청은 오류가 아니라
| 일반 SPA 응답을 받습니다(봇에게 오류를 주면 색인에서 URL 이 빠집니다).
|
| IP 단위 상한(render_misses_per_minute, stats_records_per_minute)은 요청 IP 를
| 기준으로 셉니다. 리버스 프록시·CDN 뒤에 두면서 TRUSTED_PROXIES 를 지정하지 않으면
| 모든 요청이 프록시 IP 하나로 보여 사이트 전체가 한 예산을 나눠 쓰게 되고, 정상
| 검색엔진 봇도 예산 초과 시점부터 SPA 를 받습니다. docs/backend/reverse-proxy.md 참조.
*/
'seo_cache_limits' => [
// 캐시 키에 허용하는 쿼리 파라미터 수 (초과 → 캐시·렌더 안 함)
'max_query_params' => (int) env('G7_SEO_CACHE_MAX_QUERY_PARAMS', 10),
// 정규화된 쿼리 문자열 길이 상한 (바이트)
'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_entries' => (int) env('G7_SEO_CACHE_MAX_ENTRIES', 20000),
// IP 당 분당 미스 렌더 수 (초과 → SPA + X-SEO-Cache: BYPASS)
'render_misses_per_minute' => (int) env('G7_SEO_RENDER_MISSES_PER_MINUTE', 60),
// IP 당 분당 통계 기록 수 (통계 테이블이 새 증식 축이 되지 않도록)
'stats_records_per_minute' => (int) env('G7_SEO_STATS_RECORDS_PER_MINUTE', 300),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| 아웃바운드 프록시 연결 테스트 | 아웃바운드 프록시 연결 테스트
@@ -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();
});
}
};
+59 -3
View File
@@ -105,6 +105,10 @@ Request → web.php catch-all → SeoMiddleware (봇 감지)
- 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조) - 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조)
- 렌더링 실패 시: SPA fallback (기존 응답 통과) - 렌더링 실패 시: SPA fallback (기존 응답 통과)
- 캐시 키: 경로 + **정규화된** 쿼리(`locale`·`_escaped_fragment_` 제외, 키 순서 정렬, 개수·길이 상한)
- 미스 렌더는 IP 당 분당 상한 안에서만 — 초과분은 SPA + `X-SEO-Cache: BYPASS`(오류가 아니다). 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청은 차감을 되돌린다
- 저장 상한: 경로·언어당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수
- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한). HIT 의 레이아웃명은 캐시 항목에 함께 저장된 값으로 귀속한다
## 봇 감지 구조 ## 봇 감지 구조
@@ -655,7 +659,7 @@ php artisan seo:warmup # SEO 캐시 워밍업
php artisan seo:warmup --layout=shop/show # 특정 레이아웃만 php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
php artisan seo:clear # 전체 SEO 캐시 삭제 php artisan seo:clear # 전체 SEO 캐시 삭제
php artisan seo:clear --layout=home # 특정 레이아웃만 php artisan seo:clear --layout=home # 특정 레이아웃만
php artisan seo:stats # 캐시 통계 출력 php artisan seo:stats # 캐시 통계 출력 (미들웨어가 HIT/MISS 를 기록 — IP 당 분당 상한)
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto) php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto)
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당) php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당)
@@ -755,6 +759,57 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
| twitter_default_card | string | "summary_large_image" | twitter:card 기본 (summary/summary_large_image/app/player) | | twitter_default_card | string | "summary_large_image" | twitter:card 기본 (summary/summary_large_image/app/player) |
| twitter_default_site | string | "" | twitter:site 핸들 (예: @gnuboard). 비면 출력 생략 | | twitter_default_site | string | "" | twitter:site 핸들 (예: @gnuboard). 비면 출력 생략 |
## 캐시 상한 (config/core.php `seo_cache_limits`)
봇 판정은 User-Agent 문자열뿐이라 위장이 가능하고, 캐시 키에 쿼리가 들어가므로 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 됩니다. 미스 1건은 레이아웃 병합 · 표현식 평가 · 자기 API 루프백 호출을 유발하고 그 결과가 캐시에 쌓이므로, 요청 하나가 워커 여러 개를 묶고 저장소를 계속 키울 수 있습니다.
아래 상한이 그 증식을 막습니다. 관리자 화면에는 노출하지 않고 서버 설정으로만 조정합니다.
| 키 | env | 기본값 | 설명 |
|----|-----|-------|------|
| max_query_params | `G7_SEO_CACHE_MAX_QUERY_PARAMS` | 10 | 캐시 키에 허용하는 쿼리 파라미터 수. 초과 시 캐시·렌더 안 함 |
| max_query_length | `G7_SEO_CACHE_MAX_QUERY_LENGTH` | 512 | 정규화된 쿼리 문자열 길이 상한(바이트) |
| max_variants_per_path | `G7_SEO_CACHE_MAX_VARIANTS_PER_PATH` | 50 | 같은 경로·언어에 저장하는 쿼리 변종 수 (언어별로 따로 센다) |
| max_entries | `G7_SEO_CACHE_MAX_ENTRIES` | 20000 | 캐시 인덱스 전체 항목 수 |
| 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 당 분당 통계 기록 수 |
상한을 넘긴 요청은 **차단되지 않고** 일반 SPA 응답을 받습니다. 봇에게 오류를 돌려주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해입니다. 판정 결과는 응답 헤더 `X-SEO-Cache`(`HIT`/`MISS`/`BYPASS`)로 드러나며, 그것이 운영 진단의 통로입니다.
이미 캐시에 있는 키의 **갱신**은 저장 규모를 늘리지 않으므로 상한과 무관하게 수행됩니다.
### IP 단위 상한은 프록시 신뢰 설정에 의존합니다
`render_misses_per_minute` 와 `stats_records_per_minute` 는 요청 IP 를 기준으로 셉니다. TLS 가 앞단에서 종단되는 구성(리버스 프록시, CDN, 로드밸런서)에서 신뢰할 프록시를 지정하지 않으면 모든 요청의 IP 가 프록시 IP 하나로 보이므로, 그 분당 예산을 사이트 전체 봇 트래픽이 함께 쓰게 됩니다. 예산을 넘긴 시점부터 정상 검색엔진 봇도 SEO HTML 대신 SPA 를 받아 색인 품질이 떨어지고, 응답은 200 이라 서버 로그에 흔적이 남지 않습니다.
프록시 뒤에 두는 설치본은 `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시간). 그래서 저장 상한에 닿으면 **먼저 만료된 항목을 인덱스에서 걷어내고 다시 판정**합니다. 그러지 않으면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 상한에 닿은 경로는 실제 캐시가 비어 있어도 다시는 저장되지 않습니다.
이 정리는 인덱스 전체를 훑으므로 **최소 60초 간격**으로만 수행합니다. 살아 있는 항목만으로 상한에 닿은 경우에는 정리해도 자리가 나지 않는데, 그 상태에서 저장 시도마다 훑으면 비용만 반복되기 때문입니다. 따라서 항목이 만료된 뒤 저장이 다시 열리기까지 최대 그 간격만큼 늦어질 수 있습니다.
### 상한은 봇 요청만이 아니라 모든 저장 경로에 걸립니다
저장 상한은 `SeoCacheManagerInterface` 의 저장 메서드(`put`·`putWithLayout`)에 공통으로 걸립니다. 봇 요청의 미스 렌더뿐 아니라, 게시글·상품을 저장할 때 도는 단건 재생성(`SeoCacheRegenerator`)도 같은 판정을 거칩니다 — 한쪽만 상한 밖이면 그쪽이 증식 우회로가 되기 때문입니다.
상한에 닿아 저장하지 않은 경우 재생성 호출은 **예외를 던지지 않고** 흔적을 `Log::debug` 로만 남깁니다. 콘텐츠 페이지는 서로 다른 경로라 경로당 변종 상한(`max_variants_per_path`)과는 무관하고, 전체 항목 상한(`max_entries`)은 만료 항목을 걷어낸 뒤 판정하므로 실제로는 페이지 TTL 안에 살아 있는 항목만 셉니다. 그래도 대규모 사이트에서 이 상한에 닿는다면 저장이 조용히 넘어가므로, `seo:stats` 의 미스 비율이 지속적으로 오르는지를 신호로 삼고 `G7_SEO_CACHE_MAX_ENTRIES` 를 올립니다.
## SEO Config 동적 확장 시스템 ## SEO Config 동적 확장 시스템
SEO 엔진은 컴포넌트 지식을 갖지 않습니다. 모든 컴포넌트→HTML 매핑, 렌더 모드, 셀프 클로징 태그, 외부 스타일시트는 `seo-config.json`으로 제공됩니다. SEO 엔진은 컴포넌트 지식을 갖지 않습니다. 모든 컴포넌트→HTML 매핑, 렌더 모드, 셀프 클로징 태그, 외부 스타일시트는 `seo-config.json`으로 제공됩니다.
@@ -1653,9 +1708,10 @@ SeoCacheManager는 URL + locale 기반 캐시 키(`md5($cacheUrl.'|'.$locale)`)
SeoMiddleware의 `buildCacheUrl()`이 캐시 키용 URL을 구성합니다: SeoMiddleware의 `buildCacheUrl()`이 캐시 키용 URL을 구성합니다:
- **경로 + 쿼리 파라미터 포함**: `/shop/products?page=2&sort=price` → 페이지별 독립 캐시 - **경로 + 정규화된 쿼리 파라미터**: `/shop/products?page=2&sort=price` → 페이지별 독립 캐시
- **`locale` 파라미터 제외**: locale은 캐시 키의 두 번째 차원(`$locale`)으로 별도 관리 - **시스템 파라미터 제외**: `locale` 은 캐시 키의 두 번째 차원(`$locale`)으로 별도 관리하고, 봇 렌더 표식인 `_escaped_fragment_` 는 내용에 영향이 없어 키에서 뺍니다(남기면 같은 페이지가 두 벌 저장됩니다)
- **쿼리 파라미터 정렬**: `ksort()` — 동일 파라미터 조합 = 동일 캐시 키 보장 - **쿼리 파라미터 정렬**: `ksort()` — 동일 파라미터 조합 = 동일 캐시 키 보장
- **상한 초과 시 캐시 불가**: 파라미터 수·길이가 상한을 넘으면 색인 대상이 아니라고 보고 캐시도 렌더도 하지 않습니다(위 "캐시 상한" 절)
## SEO 변수 시스템 ## SEO 변수 시스템
+2 -2
View File
@@ -33,7 +33,7 @@ public/build/ext/{cache_version}/
│ ├── routes.json ← 병합 결과 + {"success":true,...} 봉투 │ ├── routes.json ← 병합 결과 + {"success":true,...} 봉투
│ └── assets/{dist 이하 경로} ← dist/** 사본 (*.map 제외, 허용 확장자만) │ └── assets/{dist 이하 경로} ← dist/** 사본 (*.map 제외, 허용 확장자만)
└── bundles/ └── bundles/
├── modules.js / modules.css ← 확장 병합 번들 사본 ├── modules.js / modules.css ← 확장 병합 번들 사본 (빈 번들도 0바이트로 게시)
└── plugins.js / plugins.css └── plugins.js / plugins.css
``` ```
@@ -176,7 +176,7 @@ public/build/ext/{cache_version}/
같은 엔드포인트가 대시보드의 「초기 화면 파일 생성 실패」 알림에도 [다시 만들기] 버튼으로 붙는다 — 운영자가 결함을 처음 만나는 곳에서 복구가 끝나도록. 같은 엔드포인트가 대시보드의 「초기 화면 파일 생성 실패」 알림에도 [다시 만들기] 버튼으로 붙는다 — 운영자가 결함을 처음 만나는 곳에서 복구가 끝나도록.
715파일·40MB 복사와 번들 재병합이 **웹 요청 안에서** 돈다. 서버는 게시 락 TTL(300초)만큼 실행 시간을 확보하고 클라이언트가 끊어도 게시를 끝내지만, FPM `request_terminate_timeout`·nginx `fastcgi_read_timeout` 이 그보다 짧은 서버에서는 응답이 먼저 끊길 수 있다. 그 경우에도 게시는 계속되므로 카드를 다시 열어 결과를 확인한다. 715파일·40MB 복사와 번들 재병합이 **웹 요청 안에서** 돈다(번들은 캐시가 있으면 재병합하지 않는다). 서버는 게시 락 TTL(300초)만큼 실행 시간을 확보하고 클라이언트가 끊어도 게시를 끝내지만, FPM `request_terminate_timeout`·nginx `fastcgi_read_timeout` 이 그보다 짧은 서버에서는 응답이 먼저 끊길 수 있다. 그 경우에도 게시는 계속되므로 카드를 다시 열어 결과를 확인한다.
이 통로가 있으므로 kill-switch 를 관리자 UI 로 두지 않는 방침(§5)은 그대로다 — 복구는 화면에서, 끄기는 서버에서. 이 통로가 있으므로 kill-switch 를 관리자 UI 로 두지 않는 방침(§5)은 그대로다 — 복구는 화면에서, 끄기는 서버에서.
+15 -2
View File
@@ -520,6 +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회 빌드에 수렴(잠금 실패는 각자 빌드). 병합 단계에서 건너뛴 확장이 있는 결과는 캐시하지 않는다 | (계약 테스트) |
### 병합 CSS 의 상대 참조 ### 병합 CSS 의 상대 참조
@@ -538,7 +539,7 @@ GET /api/plugins/bundle.css?v={version}
| 상태 | 판정 | 응답 | | 상태 | 판정 | 응답 |
|---|---|---| |---|---|---|
| 에셋을 선언한 활성 확장이 0개 | 정상 | 빈 200 | | 에셋을 선언한 활성 확장이 0개 | 정상 | 빈 200 |
| 선언은 있고 그 산출물이 **전부 존재**하되 비어 있음 | 정상 (스타일이 비어 있는 확장) | 빈 200 | | 선언은 있고 그 산출물이 **전부 존재**하되 비어 있음 | 정상 (스타일이 비어 있는 확장) | 0바이트 캐시 파일 + 정적 게시, 빈 200 |
| 선언한 산출물이 **소실·판독 불가** | 장애 (배포 중 `dist` 가 잠깐 빔, 경로 어긋남) | **503** + `Log::error`(소실 경로 목록) | | 선언한 산출물이 **소실·판독 불가** | 장애 (배포 중 `dist` 가 잠깐 빔, 경로 어긋남) | **503** + `Log::error`(소실 경로 목록) |
장애를 정상으로 흘리면 프론트는 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다. 반대로 정상을 장애로 잡으면 스타일 소스가 자리표시 주석뿐인 확장만 설치된 기본 구성이 통째로 503 이 되어 **사용자 화면마다 실패 안내가 뜬다.** 판정은 **kind 별**이다(js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은 정상). 장애를 정상으로 흘리면 프론트는 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다. 반대로 정상을 장애로 잡으면 스타일 소스가 자리표시 주석뿐인 확장만 설치된 기본 구성이 통째로 503 이 되어 **사용자 화면마다 실패 안내가 뜬다.** 판정은 **kind 별**이다(js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은 정상).
@@ -550,7 +551,9 @@ GET /api/plugins/bundle.css?v={version}
0바이트 산출물은 정당한 상태다. 스타일 규칙이 아직 없는 확장이 CSS 를 선언하는 것은 어긋남이 아니며, 그 상태를 배포 장애로 등치하면 정상 사이트가 서비스 불능으로 보고된다. 0바이트 산출물은 정당한 상태다. 스타일 규칙이 아직 없는 확장이 CSS 를 선언하는 것은 어긋남이 아니며, 그 상태를 배포 장애로 등치하면 정상 사이트가 서비스 불능으로 보고된다.
빈 번들은 정적 게시 대상이 아니다(게시할 사본이 없다). 그 결과 `AssetUrl::extensionBundle()` 이 정적 URL 대신 API URL 을 방출하므로 브라우저에는 정적 404 폴백이 생기지 않고, 그 API 가 위 표대로 빈 200 을 낸다. 선언 산출물이 전부 존재하는 빈 번들은 **0바이트 파일로 캐시·정적 게시**되어 브라우저가 웹서버에서 직접 받는다. 게시하지 않으면 `AssetUrl::extensionBundle()` 이 API URL 을 방출해 그 구성의 **모든 페이지 로드**가 PHP 를 거치고, 그 요청마다 컨트롤러가 활성 확장 열거를 세 번 반복한다(경로 조회 → 재빌드 → 소실 판정). 오류도 로그도 남지 않아 드러나지 않는 경로다.
캐시하지 않는 것은 셋뿐이다 — 선언 산출물이 **소실**된 경우(503 판정을 그대로 유지해야 한다), 병합 단계에서 확장을 **건너뛴** 경우(파일은 있는데 읽기·치환이 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정되므로, 종전처럼 매 요청 재시도에 맡긴다), 그리고 디스크 쓰기 자체가 실패한 경우다.
두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다. 두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다.
@@ -569,10 +572,20 @@ php artisan template:cache-clear # 전체 번들 파일 정리 포함
프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). `_bundled` 수정 후에는 `{type}:update {id} --force` 로 활성 반영 후 version bump 로 번들이 재생성된다. 프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). `_bundled` 수정 후에는 `{type}:update {id} --force` 로 활성 반영 후 version bump 로 번들이 재생성된다.
캐시 적중 시에는 원본 에셋을 읽지 않으므로 원본 교체는 반드시 version bump 로 반영한다 — `{type}:update {id} --force` 가 그 bump 를 수행한다. `dist` 를 손으로 덮어쓰고 bump 를 건너뛰면 같은 version 의 캐시가 계속 서빙된다.
> 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다. > 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다.
> 다만 `*.map` 의 **실제 서빙은 `local` 환경에서만** 허용된다 — 소스맵에는 원본 코드 전문이 > 다만 `*.map` 의 **실제 서빙은 `local` 환경에서만** 허용된다 — 소스맵에는 원본 코드 전문이
> 담기므로 운영에서는 확장자 화이트리스트가 차단한다. 상세: [template-security.md](template-security.md) "소스맵 (`map`) — 로컬 개발 환경 전용". > 담기므로 운영에서는 확장자 화이트리스트가 차단한다. 상세: [template-security.md](template-security.md) "소스맵 (`map`) — 로컬 개발 환경 전용".
### 요청 제한을 두지 않는 이유
공개 번들 라우트(`/api/{modules,plugins}/bundle.{js,css}`)에는 애플리케이션 수준의 요청 제한을 붙이지 않는다.
- 캐시 우선 순서를 갖춘 뒤로 이 엔드포인트의 응답 비용은 **프레임워크 부팅**이 지배한다. 부팅을 마친 뒤에야 판정할 수 있는 앱 throttle 은 그 비용을 이미 치른 다음이라 방어 효과가 제한적이다.
- 정적 자산 성격의 공개 API 는 요청 제한을 두지 않는 것이 이 저장소의 기존 관행이다(공개 컨트롤러 계층 전반).
- 반복 요청을 실제로 줄이는 것은 앞단 캐시다. 응답이 `Cache-Control: immutable, max-age=31536000, public` 을 내보내므로, 리버스 프록시나 CDN 이 그 헤더를 존중하도록 `/api/*/bundle*` 을 캐시 대상에 넣으면 앱까지 도달하는 요청 자체가 사라진다. 파일명에 캐시 버전이 들어 있어 stale 위험도 없다.
### 전송 압축 (gzip) ### 전송 압축 (gzip)
번들 JS/CSS 는 `fileResponse()`(= `response()->file()` → `BinaryFileResponse`)로 서빙되며, `GzipEncodeResponse` 미들웨어가 gzip 압축을 적용한다. `BinaryFileResponse` 는 `getContent()` 가 `false` 를 반환하므로, 미들웨어는 파일 경로(`getFile()->getPathname()`)에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 `Response` 로 치환한다. 번들 JS/CSS 는 `fileResponse()`(= `response()->file()` → `BinaryFileResponse`)로 서빙되며, `GzipEncodeResponse` 미들웨어가 gzip 압축을 적용한다. `BinaryFileResponse` 는 `getContent()` 가 `false` 를 반환하므로, 미들웨어는 파일 경로(`getFile()->getPathname()`)에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 `Response` 로 치환한다.
+1
View File
@@ -132,6 +132,7 @@ UA 를 실제 브라우저 값으로 고정해도 그 검증은 그대로 동작
| 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 | | 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 |
| 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 | | 워커 수 | 관리자 SPA 는 번들이 크고 레이아웃을 여러 번 받아온다. 개발 머신에서 2워커 이상이면 `page.waitForLoadState` 가 30초를 넘겨 **비결정적으로** 실패한다(실측: 같은 스위트가 회차마다 다른 5~7건 실패, 테스트당 8초 → 25초). 판정은 `--workers=1` 결과로 한다 |
| 편집기 spec 만 몰아 실행할 때 | 레이아웃 편집기 spec 만 골라 돌리면 워커 전부가 동시에 편집기 페이지를 연다 — 전체 스위트에서는 가벼운 spec 이 섞여 그 집중이 생기지 않는다. 실측: 편집기 7파일 27건을 7워커로 돌리면 `g7le-preview-frame` 대기가 전부 30초 타임아웃(27/27 실패), 같은 코드로 2워커는 26/27 통과. 편집기만 선택 실행할 때는 `--workers=2` 이하로 둔다 | | 편집기 spec 만 몰아 실행할 때 | 레이아웃 편집기 spec 만 골라 돌리면 워커 전부가 동시에 편집기 페이지를 연다 — 전체 스위트에서는 가벼운 spec 이 섞여 그 집중이 생기지 않는다. 실측: 편집기 7파일 27건을 7워커로 돌리면 `g7le-preview-frame` 대기가 전부 30초 타임아웃(27/27 실패), 같은 코드로 2워커는 26/27 통과. 편집기만 선택 실행할 때는 `--workers=2` 이하로 둔다 |
| 봇 렌더 spec 의 IP 예산 | 검색봇 화면의 미스 렌더는 **IP 당 분당 상한**(`G7_SEO_RENDER_MISSES_PER_MINUTE`, 기본 60)을 쓴다. 자동 검사는 전부 같은 IP 에서 나가므로 봇 경로 spec 을 많이 몰아 돌리면 예산을 넘긴 요청이 SEO HTML 대신 SPA(`X-SEO-Cache: BYPASS`)를 받아 본문 단언이 실패한다. 오류가 아니라 200 이라 원인이 드러나지 않으므로, 봇 spec 이 간헐 실패하면 응답 헤더가 `BYPASS` 인지 먼저 본다 (실측: `seo-bot-rendering` 12건 + `page-og-image` 2건은 기본값에서 여유가 있다) |
### 브라우저 범위 ### 브라우저 범위
@@ -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.' 를 거치지 않습니다. '
.'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.' .'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.'
); );
} }
+267
View File
@@ -5,8 +5,11 @@ 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\SeoMiddleware; use App\Seo\SeoMiddleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase; use Tests\TestCase;
/** /**
@@ -24,6 +27,8 @@ class SeoMiddlewareTest extends TestCase
private SeoRendererInterface $renderer; private SeoRendererInterface $renderer;
private SeoCacheStatsService $statsService;
/** /**
* 테스트 환경 설정 * 테스트 환경 설정
*/ */
@@ -34,12 +39,18 @@ class SeoMiddlewareTest extends TestCase
$this->botDetector = $this->createMock(BotDetector::class); $this->botDetector = $this->createMock(BotDetector::class);
$this->cacheManager = $this->createMock(SeoCacheManagerInterface::class); $this->cacheManager = $this->createMock(SeoCacheManagerInterface::class);
$this->renderer = $this->createMock(SeoRendererInterface::class); $this->renderer = $this->createMock(SeoRendererInterface::class);
$this->statsService = $this->createMock(SeoCacheStatsService::class);
$this->middleware = new SeoMiddleware( $this->middleware = new SeoMiddleware(
$this->botDetector, $this->botDetector,
$this->cacheManager, $this->cacheManager,
$this->renderer, $this->renderer,
$this->statsService,
); );
// 렌더·통계 예산은 IP 단위 카운터다 — 테스트 간 이월되면 순서에 따라 결과가 갈린다.
RateLimiter::clear('seo-render:127.0.0.1');
RateLimiter::clear('seo-stats:127.0.0.1');
} }
/** /**
@@ -403,6 +414,7 @@ class SeoMiddlewareTest extends TestCase
app(BotDetector::class), app(BotDetector::class),
$this->cacheManager, $this->cacheManager,
$this->renderer, $this->renderer,
$this->statsService,
); );
$response = $middleware->handle($request, $this->spaNext()); $response = $middleware->handle($request, $this->spaNext());
@@ -435,6 +447,7 @@ class SeoMiddlewareTest extends TestCase
app(BotDetector::class), app(BotDetector::class),
$this->cacheManager, $this->cacheManager,
$this->renderer, $this->renderer,
$this->statsService,
); );
$response = $middleware->handle($request, $this->spaNext()); $response = $middleware->handle($request, $this->spaNext());
@@ -469,6 +482,7 @@ class SeoMiddlewareTest extends TestCase
app(BotDetector::class), app(BotDetector::class),
$this->cacheManager, $this->cacheManager,
$this->renderer, $this->renderer,
$this->statsService,
); );
$response = $middleware->handle($request, $this->spaNext()); $response = $middleware->handle($request, $this->spaNext());
@@ -501,4 +515,257 @@ class SeoMiddlewareTest extends TestCase
$this->middleware->handle($request, $this->spaNext()); $this->middleware->handle($request, $this->spaNext());
} }
// ========================================
// 캐시 상한 / 렌더 예산 테스트 (KVE-2026-2191 동형)
// ========================================
/**
* IP 당 미스 렌더 예산을 넘기면 렌더도 저장도 하지 않고 SPA 를 돌려준다.
*
* 봇 판정은 User-Agent 문자열뿐이라 위장이 가능하고, 캐시 키에 쿼리가 들어가므로
* 값만 바꾼 반복 요청이 매번 미스가 된다. 미스 1건은 레이아웃 병합·표현식 평가·자기
* API 루프백 호출을 유발하므로 요청 하나가 워커 여러 개를 묶는다.
*
* @effects bot_miss_over_limit_gets_spa_bypass
*/
public function test_bot_miss_over_render_limit_gets_spa_bypass_without_render(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.render_misses_per_minute' => 2,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->expects($this->never())->method('render');
$this->cacheManager->expects($this->never())->method('putWithLayout');
RateLimiter::hit('seo-render:127.0.0.1', 60);
RateLimiter::hit('seo-render:127.0.0.1', 60);
$response = $this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1'),
$this->spaNext()
);
$this->assertSame('SPA Fallback', $response->getContent());
$this->assertSame('BYPASS', $response->headers->get('X-SEO-Cache'));
}
/**
* 예산을 넘긴 IP 라도 **캐시 적중**은 그대로 서빙한다 — 비용이 없기 때문이다.
*
* @effects bot_hit_served_regardless_of_limit
*/
public function test_cache_hit_is_served_even_when_render_limit_exceeded(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.render_misses_per_minute' => 1,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn('<html>cached</html>');
RateLimiter::hit('seo-render:127.0.0.1', 60);
$response = $this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1'),
$this->spaNext()
);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('HIT', $response->headers->get('X-SEO-Cache'));
}
/**
* `_escaped_fragment_` 는 봇 렌더 요청 표식일 뿐이라 캐시 키를 가르지 않는다.
*
* @effects system_query_params_excluded_from_key
*/
public function test_escaped_fragment_param_does_not_change_cache_key(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$seen = [];
$this->cacheManager->method('get')->willReturnCallback(function (string $url) use (&$seen) {
$seen[] = $url;
return '<html>cached</html>';
});
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
$this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1', ['_escaped_fragment_' => '']),
$this->spaNext()
);
$this->assertCount(2, $seen);
$this->assertSame($seen[0], $seen[1]);
}
/**
* 정규화할 수 없을 만큼 큰 쿼리는 색인 대상이 아니다 — 캐시 조회도 렌더도 하지 않는다.
*
* @effects oversized_query_bypasses_cache_and_render
*/
public function test_oversized_query_bypasses_cache_and_render(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.max_query_params' => 10,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->expects($this->never())->method('get');
$this->renderer->expects($this->never())->method('render');
$query = [];
for ($i = 0; $i < 11; $i++) {
$query['p'.$i] = '1';
}
$response = $this->middleware->handle(
$this->createRequest('/products', 'Googlebot/2.1', $query),
$this->spaNext()
);
$this->assertSame('SPA Fallback', $response->getContent());
$this->assertSame('BYPASS', $response->headers->get('X-SEO-Cache'));
}
/**
* 캐시 적중·미적중이 통계에 기록된다.
*
* 기록 호출처가 없으면 `seo:stats` 와 관리자 통계가 항상 0 이라, 공격이 진행돼도
* 운영자 화면은 아무것도 달라지지 않는다.
*
* @effects cache_hit_and_miss_are_recorded_in_stats
*/
public function test_cache_hit_records_stat_hit(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn('<html>cached</html>');
$this->statsService->expects($this->once())->method('recordHit');
$this->statsService->expects($this->never())->method('recordMiss');
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
}
/**
* @effects cache_hit_and_miss_are_recorded_in_stats
*/
public function test_cache_miss_records_stat_miss_with_response_time(): 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('<html>rendered</html>');
$this->statsService->expects($this->once())
->method('recordMiss')
->with(
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
$this->greaterThanOrEqual(0)
);
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
}
/**
* 통계 기록도 IP 당 상한을 넘기면 멈춘다 — 통계 테이블이 새 증식 축이 되면 안 된다.
*
* @effects stats_recording_capped_per_ip
*/
public function test_stats_recording_is_capped_per_ip(): void
{
config([
'g7_settings.core.seo.bot_detection_enabled' => true,
'core.seo_cache_limits.stats_records_per_minute' => 1,
]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn('<html>cached</html>');
RateLimiter::hit('seo-stats:127.0.0.1', 60);
$this->statsService->expects($this->never())->method('recordHit');
$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'));
}
} }
@@ -1461,4 +1461,52 @@ class ExtensionStaticCacheServiceTest extends TestCase
return $next === 1 ? substr($rest, 0, $m[0][1] + 1) : $rest; return $next === 1 ? substr($rest, 0, $m[0][1] + 1) : $rest;
} }
/**
* 병합 결과가 비어 있어도(선언 산출물이 전부 존재하거나 선언이 0) 번들은 게시된다.
*
* 게시하지 않으면 그 구성의 자산 URL 이 API 로 폴백해, 방문자의 **모든 페이지 로드**가
* PHP 를 거친다. 오류도 로그도 남지 않고 화면은 정상이라 드러나지 않는 경로다.
*
* @scenario publish_state=unpublished, artifact_integrity=intact, filesystem_writable=writable, environment=production, trigger=manual_command, process_user=web
*
* @effects zero_byte_bundle_is_statically_published
*/
public function test_publish_includes_zero_byte_bundle_when_declared_artifacts_are_empty(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$this->createActiveTemplate();
// 활성 확장 0건 = 병합 결과가 빈 문자열인 대표 구성(기본 설치)
$service = $this->service();
try {
$this->assertTrue($service->publishCurrent(), $this->publishDiagnostics($service));
$bundleDir = public_path('build/ext/'.self::VERSION.'/bundles');
foreach (['modules.css', 'modules.js', 'plugins.css', 'plugins.js'] as $name) {
$this->assertFileExists($bundleDir.DIRECTORY_SEPARATOR.$name, "빈 번들도 게시되어야 한다: {$name}");
$this->assertSame(0, filesize($bundleDir.DIRECTORY_SEPARATOR.$name));
}
$manifest = json_decode(
(string) file_get_contents(public_path('build/ext/'.self::VERSION.'/manifest.json')),
true
);
$this->assertIsArray($manifest);
$this->assertContains('bundles/modules.css', $manifest['files'] ?? []);
} finally {
// 번들 캐시는 격리된 public 루트 밖(storage/app/ext-bundles)에 쓰이므로 직접 치운다 —
// 남기면 실 설치본의 번들 디렉토리에 테스트 버전 파일이 쌓인다.
foreach (['module', 'plugin'] as $type) {
foreach (['js', 'css'] as $kind) {
@unlink(storage_path("app/ext-bundles/{$type}.".self::VERSION.".{$kind}"));
}
}
}
}
} }
@@ -109,8 +109,12 @@ test.describe('확장 병합 번들 로딩', () => {
* @effects bundle_css_failure_banner_uses_user_vocabulary * @effects bundle_css_failure_banner_uses_user_vocabulary
*/ */
test('번들 CSS 가 503 이면 안내 항목명이 사용자 어휘다', async ({ page }) => { test('번들 CSS 가 503 이면 안내 항목명이 사용자 어휘다', async ({ page }) => {
await page.route(/\/api\/(modules|plugins)\/bundle[./]css/, (route) => // 번들 CSS 는 구성에 따라 정적 게시본(`/build/ext/{v}/bundles/*.css`) 또는 API 로
route.fulfill({ status: 503, contentType: 'text/css', body: '' }), // 나간다 — 빈 번들도 0바이트로 게시되므로 기본 구성은 정적 URL 이다. 한쪽만 가로채면
// 그 구성에서 이 시나리오가 발화하지 않은 채 통과한다.
await page.route(
/(\/api\/(modules|plugins)\/bundle[./]css|\/build\/ext\/\d+\/bundles\/(modules|plugins)\.css)/,
(route) => route.fulfill({ status: 503, contentType: 'text/css', body: '' }),
); );
await page.goto('/'); await page.goto('/');
+2 -1
View File
@@ -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)
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\SeoCacheBounds;
use Tests\TestCase;
/**
* SeoCacheBounds 단위 테스트
*
* 캐시 키 정규화(시스템 파라미터 제거·정렬·상한)와 저장 규모 상한을 검증한다.
*/
class SeoCacheBoundsTest extends TestCase
{
/**
* 시스템 파라미터는 캐시 키에서 빠진다 — 같은 페이지가 두 벌 저장되면 안 된다.
*
* @effects system_query_params_excluded_from_key
*/
public function test_normalize_query_drops_system_parameters(): void
{
$this->assertSame(
['page' => '2'],
SeoCacheBounds::normalizeQuery(['locale' => 'en', '_escaped_fragment_' => '', 'page' => '2'])
);
}
/**
* 같은 조합은 순서가 달라도 같은 키가 되어야 한다.
*
* @effects system_query_params_excluded_from_key
*/
public function test_normalize_query_sorts_keys(): void
{
$this->assertSame(
['a' => '1', 'b' => '2', 'c' => '3'],
SeoCacheBounds::normalizeQuery(['c' => '3', 'a' => '1', 'b' => '2'])
);
}
/**
* 파라미터 수 상한을 넘기면 캐시 불가(null)다.
*
* @effects oversized_query_bypasses_cache_and_render
*/
public function test_normalize_query_returns_null_when_too_many_parameters(): void
{
config(['core.seo_cache_limits.max_query_params' => 10]);
$query = [];
for ($i = 0; $i < 11; $i++) {
$query['p'.$i] = '1';
}
$this->assertNull(SeoCacheBounds::normalizeQuery($query));
array_pop($query);
$this->assertIsArray(SeoCacheBounds::normalizeQuery($query), '상한과 같은 개수는 허용된다');
}
/**
* 정규화된 쿼리 문자열 길이 상한을 넘기면 캐시 불가다.
*
* @effects oversized_query_bypasses_cache_and_render
*/
public function test_normalize_query_returns_null_when_query_string_too_long(): void
{
config(['core.seo_cache_limits.max_query_length' => 32]);
$this->assertNull(SeoCacheBounds::normalizeQuery(['q' => str_repeat('x', 64)]));
$this->assertIsArray(SeoCacheBounds::normalizeQuery(['q' => 'x']));
}
/**
* 시스템 파라미터는 길이·개수 상한 계산에서도 빠진다.
*
* @effects system_query_params_excluded_from_key
*/
public function test_normalize_query_excludes_system_parameters_from_limits(): void
{
config(['core.seo_cache_limits.max_query_params' => 1]);
$this->assertSame(
['page' => '2'],
SeoCacheBounds::normalizeQuery(['locale' => 'en', '_escaped_fragment_' => '', 'page' => '2'])
);
}
/**
* 같은 경로의 변종 수가 상한에 닿으면 새 URL 을 저장하지 않는다.
*
* @effects store_skips_write_at_path_variant_cap
*/
public function test_can_store_rejects_new_variant_at_path_cap(): void
{
config([
'core.seo_cache_limits.max_variants_per_path' => 3,
'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=3', 'locale' => 'ko'],
];
$this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=4', 'ko'));
// 다른 경로는 자기 예산을 따로 쓴다
$this->assertTrue(SeoCacheBounds::canStore($index, '/board?page=1', 'ko'));
}
/**
* 전체 항목 수 상한에 닿으면 어떤 경로도 저장하지 않는다.
*
* @effects store_skips_write_at_global_cap
*/
public function test_can_store_rejects_when_global_cap_reached(): void
{
config([
'core.seo_cache_limits.max_entries' => 2,
'core.seo_cache_limits.max_variants_per_path' => 50,
]);
$index = [
'k1' => ['url' => '/a', 'locale' => 'ko'],
'k2' => ['url' => '/b', 'locale' => 'ko'],
];
$this->assertFalse(SeoCacheBounds::canStore($index, '/c', 'ko'));
}
/**
* 렌더 예산은 IP 단위로 소진된다.
*
* @effects bot_miss_over_limit_gets_spa_bypass
*/
public function test_render_budget_is_per_ip(): void
{
config(['core.seo_cache_limits.render_misses_per_minute' => 2]);
$this->assertTrue(SeoCacheBounds::renderAllowed('10.0.0.1'));
SeoCacheBounds::recordRender('10.0.0.1');
SeoCacheBounds::recordRender('10.0.0.1');
$this->assertFalse(SeoCacheBounds::renderAllowed('10.0.0.1'));
$this->assertTrue(SeoCacheBounds::renderAllowed('10.0.0.2'), '다른 IP 는 자기 예산을 쓴다');
}
/**
* 통계 기록 예산도 IP 단위다 — 통계 테이블이 새 증식 축이 되지 않아야 한다.
*
* @effects stats_recording_capped_per_ip
*/
public function test_stats_budget_is_per_ip(): void
{
config(['core.seo_cache_limits.stats_records_per_minute' => 1]);
$this->assertTrue(SeoCacheBounds::statsAllowed('10.0.0.3'));
SeoCacheBounds::recordStat('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'), '다른 언어는 자기 예산을 따로 쓴다');
}
}
+272
View File
@@ -210,4 +210,276 @@ class SeoCacheManagerTest extends TestCase
$this->assertNull($result); $this->assertNull($result);
$this->assertEmpty($this->cacheManager->getCachedUrls()); $this->assertEmpty($this->cacheManager->getCachedUrls());
} }
/**
* 같은 경로의 변종 수가 상한에 닿으면 페이지도 인덱스도 쓰지 않는다.
*
* @effects store_skips_write_at_path_variant_cap
*/
public function test_put_with_layout_skips_write_when_path_variant_cap_reached(): 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>'.$i.'</html>', 'shop');
}
$before = $this->cacheManager->getCachedUrls();
$this->cacheManager->putWithLayout('/shop?page=4', 'ko', '<html>4</html>', 'shop');
$this->assertSame($before, $this->cacheManager->getCachedUrls());
$this->assertNull($this->cacheManager->get('/shop?page=4', 'ko'));
}
/**
* 전체 항목 수 상한에 닿으면 새 URL 을 저장하지 않는다.
*
* @effects store_skips_write_at_global_cap
*/
public function test_put_with_layout_skips_write_when_global_cap_reached(): void
{
config([
'core.seo_cache_limits.max_entries' => 2,
'core.seo_cache_limits.max_variants_per_path' => 50,
]);
$this->cacheManager->putWithLayout('/a', 'ko', '<html>a</html>', 'la');
$this->cacheManager->putWithLayout('/b', 'ko', '<html>b</html>', 'lb');
$this->cacheManager->putWithLayout('/c', 'ko', '<html>c</html>', 'lc');
$this->assertNull($this->cacheManager->get('/c', 'ko'));
$this->assertCount(2, $this->cacheManager->getCachedUrls());
}
/**
* 이미 있는 키의 **갱신**은 상한과 무관하다 — 저장 규모가 늘지 않는다.
*
* @effects store_skips_write_at_global_cap
*/
public function test_put_with_layout_updates_existing_key_regardless_of_caps(): void
{
config([
'core.seo_cache_limits.max_entries' => 1,
'core.seo_cache_limits.max_variants_per_path' => 1,
]);
$this->cacheManager->putWithLayout('/a', 'ko', '<html>old</html>', 'la');
$this->cacheManager->putWithLayout('/a', 'ko', '<html>new</html>', 'la');
$this->assertSame('<html>new</html>', $this->cacheManager->get('/a', 'ko'));
$this->assertCount(1, $this->cacheManager->getCachedUrls());
}
/**
* 페이지만 만료시킵니다 — 인덱스 항목은 그대로 남는 실제 만료 상태를 만듭니다.
*
* @param CoreCacheDriver $driver 매니저가 쓰는 캐시 드라이버
*/
private function expirePages(CoreCacheDriver $driver): void
{
foreach ($driver->get('seo.cached_urls', []) as $entry) {
$driver->forget($entry['key']);
}
}
/**
* 상한이 세는 항목에는 페이지가 이미 만료된 것이 섞인다 — 인덱스는 페이지보다 훨씬
* 오래 살고(30일 vs 2시간) 스스로 줄지 않는다. 상한에 닿았을 때 한 번 정리하고 다시
* 판정하지 않으면, 한 번 닿은 경로는 실제 캐시가 비어도 영영 저장이 막힌다.
*
* @effects expired_index_entries_are_pruned_before_cap_verdict
*/
public function test_put_with_layout_prunes_expired_entries_when_path_variant_cap_reached(): void
{
$driver = new CoreCacheDriver('array');
$manager = new SeoCacheManager($driver);
config([
'core.seo_cache_limits.max_variants_per_path' => 3,
'core.seo_cache_limits.max_entries' => 20000,
]);
for ($i = 1; $i <= 3; $i++) {
$manager->putWithLayout('/shop?page='.$i, 'ko', '<html>'.$i.'</html>', 'shop');
}
$this->expirePages($driver);
$manager->putWithLayout('/shop?page=4', 'ko', '<html>4</html>', 'shop');
$this->assertSame('<html>4</html>', $manager->get('/shop?page=4', 'ko'));
$this->assertSame(['/shop?page=4'], array_values($manager->getCachedUrls()));
}
/**
* 전체 항목 수 상한에서도 같다 — 정리 후 자리가 나면 저장한다.
*
* @effects expired_index_entries_are_pruned_before_cap_verdict
*/
public function test_put_with_layout_prunes_expired_entries_when_global_cap_reached(): void
{
$driver = new CoreCacheDriver('array');
$manager = new SeoCacheManager($driver);
config([
'core.seo_cache_limits.max_entries' => 2,
'core.seo_cache_limits.max_variants_per_path' => 50,
]);
$manager->putWithLayout('/a', 'ko', '<html>a</html>', 'la');
$manager->putWithLayout('/b', 'ko', '<html>b</html>', 'lb');
$this->expirePages($driver);
$manager->putWithLayout('/c', 'ko', '<html>c</html>', 'lc');
$this->assertSame('<html>c</html>', $manager->get('/c', 'ko'));
$this->assertCount(1, $manager->getCachedUrls());
}
/**
* `put()` 은 `putWithLayout()` 과 같은 자원(페이지 + 인덱스)을 쓰는 형제 공개 메서드다.
* 상한이 한쪽에만 있으면 다른 쪽이 우회로가 된다 — 확장은 인터페이스를 직접 호출한다.
*
* @effects put_and_put_with_layout_share_the_storage_cap
*/
public function test_put_applies_the_same_storage_cap_as_put_with_layout(): void
{
config([
'core.seo_cache_limits.max_entries' => 2,
'core.seo_cache_limits.max_variants_per_path' => 50,
]);
$this->cacheManager->put('/a', 'ko', '<html>a</html>');
$this->cacheManager->put('/b', 'ko', '<html>b</html>');
$this->cacheManager->put('/c', 'ko', '<html>c</html>');
$this->assertNull($this->cacheManager->get('/c', 'ko'));
$this->assertCount(2, $this->cacheManager->getCachedUrls());
}
/**
* 만료 항목 정리도 형제 메서드가 함께 갖는다.
*
* @effects put_and_put_with_layout_share_the_storage_cap
*/
public function test_put_prunes_expired_entries_when_cap_reached(): void
{
$driver = new CoreCacheDriver('array');
$manager = new SeoCacheManager($driver);
config([
'core.seo_cache_limits.max_entries' => 2,
'core.seo_cache_limits.max_variants_per_path' => 50,
]);
$manager->put('/a', 'ko', '<html>a</html>');
$manager->put('/b', 'ko', '<html>b</html>');
$this->expirePages($driver);
$manager->put('/c', 'ko', '<html>c</html>');
$this->assertSame('<html>c</html>', $manager->get('/c', 'ko'));
$this->assertCount(1, $manager->getCachedUrls());
}
/**
* 정리는 인덱스 전체를 훑으므로(항목마다 캐시 조회) 상한에 닿을 때마다 돌면 안 된다 —
* 살아 있는 항목만으로 상한에 닿은 경로는 저장 시도마다 그 스캔을 반복하게 되고,
* 그 빈도는 봇 미스 렌더 예산만큼이다. 그래서 정리는 간격 표식으로 묶는다.
*
* @effects index_prune_is_throttled_to_one_scan_per_interval
*/
public function test_index_prune_is_throttled_to_one_scan_per_interval(): void
{
$driver = new CoreCacheDriver('array');
$manager = new SeoCacheManager($driver);
config([
'core.seo_cache_limits.max_variants_per_path' => 3,
'core.seo_cache_limits.max_entries' => 20000,
]);
for ($i = 1; $i <= 3; $i++) {
$manager->putWithLayout('/shop?page='.$i, 'ko', '<html>'.$i.'</html>', 'shop');
}
// 전부 살아 있는 상태에서 상한 도달 → 1회 스캔하고 표식을 남긴다 (저장은 안 됨)
$manager->putWithLayout('/shop?page=4', 'ko', '<html>4</html>', 'shop');
$this->assertNull($manager->get('/shop?page=4', 'ko'));
// 이후 만료되어도 표식이 살아 있는 동안은 다시 훑지 않는다
$this->expirePages($driver);
$manager->putWithLayout('/shop?page=5', 'ko', '<html>5</html>', 'shop');
$this->assertNull($manager->get('/shop?page=5', 'ko'));
// 표식이 사라지면 다시 정리하고 저장한다
$driver->forget('seo.index_pruned_at');
$manager->putWithLayout('/shop?page=6', 'ko', '<html>6</html>', 'shop');
$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']);
}
} }
+8
View File
@@ -766,6 +766,11 @@ class SeoRendererTest extends TestCase
], ],
], JSON_PRETTY_PRINT)); ], JSON_PRETTY_PRINT));
// 선언한 산출물이 실재해야 링크된다 — 없는 경로의 <link> 는 봇 화면에서만 404 가
// 되고 어디에도 흔적이 남지 않으므로 렌더러가 실재 파일만 싣는다.
mkdir("{$configDir}/dist/css", 0755, true);
file_put_contents("{$configDir}/dist/css/components.css", '');
// 자산 URL 모드를 고정 — 운영 설정(general.asset_url_mode)에 따라 // 자산 URL 모드를 고정 — 운영 설정(general.asset_url_mode)에 따라
// 확장자 유지/제거 두 형태가 나오므로 검증 대상 형태를 명시한다. // 확장자 유지/제거 두 형태가 나오므로 검증 대상 형태를 명시한다.
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION); AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
@@ -818,6 +823,9 @@ class SeoRendererTest extends TestCase
$this->assertNotNull($result); $this->assertNotNull($result);
} finally { } finally {
AssetUrl::forceMode(null); AssetUrl::forceMode(null);
@unlink("{$configDir}/dist/css/components.css");
@rmdir("{$configDir}/dist/css");
@rmdir("{$configDir}/dist");
@unlink("{$configDir}/template.json"); @unlink("{$configDir}/template.json");
@rmdir($configDir); @rmdir($configDir);
} }
@@ -5,6 +5,9 @@ namespace Tests\Unit\Services;
use App\Extension\ModuleManager; use App\Extension\ModuleManager;
use App\Extension\PluginManager; use App\Extension\PluginManager;
use App\Services\ExtensionBundleService; use App\Services\ExtensionBundleService;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
@@ -128,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 !== []);
@@ -139,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)
@@ -437,8 +443,8 @@ class ExtensionBundleServiceTest extends TestCase
app()->detectEnvironment(fn () => 'production'); app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('a.js', '(function(){window.A=1})()'); $a = $this->writeFixture('a.js', '(function(){window.A=1})()');
// getActiveModules 는 두 번 호출될 수 있으므로 안정적으로 반환 // 캐시 적중 경로는 활성 확장을 다시 열거하지 않아야 하므로 정확히 1회로 조인다
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([ $this->moduleManager->shouldReceive('getActiveModules')->once()->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null), 'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]); ]);
@@ -449,9 +455,12 @@ class ExtensionBundleServiceTest extends TestCase
$this->assertFileExists($path1); $this->assertFileExists($path1);
$this->assertStringContainsString('module.999.js', $path1); $this->assertStringContainsString('module.999.js', $path1);
$content1 = (string) file_get_contents($path1);
// 같은 version 재요청 → 동일 파일 (캐시 히트) // 같은 version 재요청 → 동일 파일 (캐시 히트)
$path2 = $svc->getBundleFilePath('module', 'js', 999); $path2 = $svc->getBundleFilePath('module', 'js', 999);
$this->assertSame($path1, $path2); $this->assertSame($path1, $path2);
$this->assertSame($content1, (string) file_get_contents($path2));
} }
/** /**
@@ -711,4 +720,310 @@ class ExtensionBundleServiceTest extends TestCase
$this->assertSame([$absent], $this->service()->findMissingDeclaredAssets('plugin', 'js')); $this->assertSame([$absent], $this->service()->findMissingDeclaredAssets('plugin', 'js'));
} }
/**
* 프로덕션에서 같은 version 캐시가 있으면 **빌드도 원본 읽기도 하지 않는다**.
*
* 캐시 적중 판정이 빌드 뒤에 있으면 캐시가 있어도 요청마다 활성 확장을 열거하고
* 산출물 파일을 전부 읽는다. 응답은 정상 200 이라 타이밍 말고는 드러나는 증상이
* 없고, 원본이 사라진 순간에는 캐시가 멀쩡한데도 빈 경로가 반환되어 503 이 된다.
*
* @effects prod_cache_hit_skips_build_and_source_reads
*/
public function test_prod_cache_hit_returns_cached_path_without_enumerating_or_reading_sources(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('cache-hit.js', '(function(){window.A=1})()');
$enumerations = 0;
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations, $a) {
$enumerations++;
return ['ext-a' => $this->fakeExtension('ext-a', 10, $a, null)];
});
$svc = $this->service();
$path1 = $svc->getBundleFilePath('module', 'js', 424242);
$this->assertNotSame('', $path1);
$this->assertFileExists($path1);
$this->assertSame(1, $enumerations);
$before = (string) file_get_contents($path1);
// 원본 소실 — 캐시가 있으므로 응답에 영향이 없어야 한다
@unlink($a);
$path2 = $svc->getBundleFilePath('module', 'js', 424242);
$this->assertSame($path1, $path2);
$this->assertSame(1, $enumerations, '캐시 적중 시 활성 확장을 다시 열거하면 안 된다');
$this->assertSame($before, (string) file_get_contents($path2));
}
/**
* 프로덕션은 같은 version 이면 원본이 바뀌어도 캐시를 그대로 서빙한다.
*
* 비프로덕션 거울 테스트(test_non_production_rebuilds_every_request_without_cache_reuse)와
* 짝을 이룬다 — 원본 교체는 version bump 로만 반영된다.
*
* @effects prod_cache_hit_skips_build_and_source_reads
*/
public function test_prod_cache_hit_does_not_rebuild_when_source_changes_on_same_version(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('prod-stable.js', '(function(){window.A=1})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$svc = $this->service();
$path1 = $svc->getBundleFilePath('module', 'js', 424243);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path1));
File::put($a, '(function(){window.A=2})()');
$path2 = $svc->getBundleFilePath('module', 'js', 424243);
$this->assertSame($path1, $path2);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path2));
$this->assertStringNotContainsString('window.A=2', (string) file_get_contents($path2));
}
/**
* 캐시 미스는 같은 (type, kind, version) 잠금으로 1회 빌드에 수렴하고,
* 잠금 뒤에 캐시를 **다시 확인**한다.
*
* 다른 프로세스가 대기 중에 캐시를 완성했다면 이쪽은 빌드하지 않아야 한다.
*
* @effects prod_cache_miss_builds_once_under_lock_and_rechecks
*/
public function test_prod_cache_miss_serializes_concurrent_builds_and_rechecks_cache_inside_lock(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$bundleDir = storage_path('app/ext-bundles');
File::ensureDirectoryExists($bundleDir);
$cachePath = $bundleDir.'/module.424244.js';
@unlink($cachePath);
$enumerations = 0;
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations) {
$enumerations++;
return [];
});
$lock = Mockery::mock(Lock::class);
// 대기 중 다른 프로세스가 캐시를 완성한 상황
$lock->shouldReceive('block')->once()->andReturnUsing(function () use ($cachePath) {
File::put($cachePath, '(function(){window.OTHER=1})()');
return true;
});
$lock->shouldReceive('release')->once()->andReturn(true);
Cache::shouldReceive('lock')->once()->andReturn($lock);
$path = $this->service()->getBundleFilePath('module', 'js', 424244);
// 경로 구분자는 스토리지 드라이버가 정한다 — 같은 파일을 가리키는지만 본다
$this->assertSame(realpath($cachePath), realpath($path));
$this->assertSame(0, $enumerations, '잠금 뒤 재확인이 적중하면 빌드하지 않아야 한다');
@unlink($cachePath);
}
/**
* 잠금 대기가 초과되어도 실패로 바꾸지 않는다 — 잠금 없이 각자 빌드한다.
*
* @effects prod_build_lock_failure_falls_back_to_unlocked_build
*/
public function test_prod_cache_miss_builds_without_lock_when_lock_times_out(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('lock-timeout.js', '(function(){window.A=1})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
$lock = Mockery::mock(Lock::class);
$lock->shouldReceive('block')->once()->andThrow(new LockTimeoutException);
$lock->shouldReceive('release')->never();
Cache::shouldReceive('lock')->once()->andReturn($lock);
$path = $this->service()->getBundleFilePath('module', 'js', 424245);
$this->assertNotSame('', $path);
$this->assertFileExists($path);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path));
}
/**
* 캐시 저장소가 잠금을 제공하지 못해도(드라이버 미지원·권한 등) 빌드는 계속한다.
*
* @effects prod_build_lock_failure_falls_back_to_unlocked_build
*/
public function test_prod_cache_miss_builds_without_lock_when_store_cannot_lock(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$a = $this->writeFixture('lock-unavailable.js', '(function(){window.A=1})()');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
]);
Cache::shouldReceive('lock')->once()->andThrow(new \RuntimeException('lock unsupported'));
$path = $this->service()->getBundleFilePath('module', 'js', 424246);
$this->assertNotSame('', $path);
$this->assertFileExists($path);
$this->assertStringContainsString('window.A=1', (string) file_get_contents($path));
}
/**
* 선언 산출물이 **전부 존재하되 비어 있으면** 0바이트 캐시 파일을 만든다.
*
* 그래야 정적 게시 대상이 되어 브라우저가 웹서버에서 직접 받는다. 만들지 않으면
* 그 구성의 모든 페이지 로드가 PHP 를 경유하고, 요청마다 컨트롤러가 열거를 세 번
* 반복한다(경로 조회 → 재빌드 → 소실 판정).
*
* @effects prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
*/
public function test_prod_caches_zero_byte_bundle_when_declared_artifacts_exist_but_empty(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$cssPath = $this->writeFixture('zero-byte.css', '');
$enumerations = 0;
$this->moduleManager->shouldReceive('getActiveModules')->andReturnUsing(function () use (&$enumerations, $cssPath) {
$enumerations++;
return [
'ext-empty-css' => $this->fakeExtensionWithAssets(
'ext-empty-css',
100,
['css' => ['output' => 'dist/css/module.css']],
'global',
['css' => $cssPath],
['css' => $cssPath]
),
];
});
$svc = $this->service();
$path = $svc->getBundleFilePath('module', 'css', 424247);
$this->assertNotSame('', $path);
$this->assertFileExists($path);
$this->assertSame(0, filesize($path));
$enumerationsAfterFirst = $enumerations;
$this->assertSame($path, $svc->getBundleFilePath('module', 'css', 424247));
$this->assertSame($enumerationsAfterFirst, $enumerations, '0바이트 캐시도 적중하면 열거하지 않는다');
}
/**
* 선언 산출물이 **소실**이면 캐시하지 않는다 — 503 계약을 보존한다.
*
* 0바이트 캐시가 소실 상태까지 가리면 배포 중 dist 가 빈 장애가 정상(빈 200)으로
* 위장된다.
*
* @effects prod_empty_result_with_missing_artifact_is_not_cached
*/
public function test_prod_does_not_cache_when_declared_artifact_missing(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$absent = $this->fixtureDir.'/absent-prod.css';
@unlink($absent);
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
'ext-gone' => $this->fakeExtensionWithAssets(
'ext-gone',
100,
['css' => ['output' => 'dist/css/module.css']],
'global',
['css' => $absent]
),
]);
$path = $this->service()->getBundleFilePath('module', 'css', 424248);
$this->assertSame('', $path);
$this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424248.css'));
}
/**
* 선언한 확장이 **하나도 없어도** 0바이트 캐시를 만든다(정적 게시 가능).
*
* @effects prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file
*/
public function test_prod_caches_zero_byte_bundle_when_nothing_declared(): void
{
$this->app['env'] = 'production';
app()->detectEnvironment(fn () => 'production');
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
$path = $this->service()->getBundleFilePath('module', 'css', 424249);
$this->assertNotSame('', $path);
$this->assertFileExists($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}');
}
} }
@@ -30,7 +30,6 @@ axes:
exclusions: exclusions:
- { strategy: layout, active_combo: none, reason: "layout/lazy 전략은 번들 대상 아님 — 개별 레이아웃 처리" } - { strategy: layout, active_combo: none, reason: "layout/lazy 전략은 번들 대상 아님 — 개별 레이아웃 처리" }
- { active_combo: none, version_state: cache_hit, reason: "활성 에셋 0건이면 캐시 파일을 만들지 않음 (빈 경로)" }
effects: effects:
- ordered_by_priority_ascending_only_no_name_hardcode - ordered_by_priority_ascending_only_no_name_hardcode
@@ -65,6 +64,13 @@ effects:
- empty_result_with_missing_declared_artifact_returns_503 - empty_result_with_missing_declared_artifact_returns_503
- missing_artifact_paths_are_logged - missing_artifact_paths_are_logged
- bundle_css_failure_banner_uses_user_vocabulary - bundle_css_failure_banner_uses_user_vocabulary
- prod_cache_hit_skips_build_and_source_reads
- prod_cache_miss_builds_once_under_lock_and_rechecks
- 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_missing_artifact_is_not_cached
- prod_build_with_skipped_extension_is_not_cached
- zero_byte_bundle_is_statically_published
test_files: test_files:
- tests/Unit/Services/ExtensionBundleServiceTest.php - tests/Unit/Services/ExtensionBundleServiceTest.php
@@ -72,6 +78,7 @@ test_files:
- tests/Unit/Extension/AbstractPluginDeclaredAssetsTest.php - tests/Unit/Extension/AbstractPluginDeclaredAssetsTest.php
- tests/Unit/Extension/BundledManifestAssetDeclarationTest.php - tests/Unit/Extension/BundledManifestAssetDeclarationTest.php
- tests/Feature/Api/Public/ExtensionBundleServingTest.php - tests/Feature/Api/Public/ExtensionBundleServingTest.php
- tests/Feature/Services/ExtensionStaticCacheServiceTest.php
- resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts - resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts
- tests/Playwright/specs/extension-bundle-loading.spec.ts - tests/Playwright/specs/extension-bundle-loading.spec.ts
+56
View File
@@ -0,0 +1,56 @@
# audit:allow test-scenario-coverage[cross_product] reason: 입력 axis 의 완전 cross-product(5축)는 조합 폭발이라 대표 조합만 마킹한다. effects 축은 면제하지 않는다 — 모든 effect 가 테스트 마킹으로 도달해야 한다.
feature: SEO 봇 캐시 상한 (키 정규화 · 렌더 예산 · 저장 규모 · 통계 기록)
description: |
봇 판정은 User-Agent 문자열뿐이라 누구나 위장할 수 있는데, 캐시 키가 경로 + 전체 쿼리였다.
물음표 뒤 값만 바꾸면 매 요청이 미스가 되고, 미스 1건은 레이아웃 병합 · 표현식 평가 ·
자기 API 루프백 HTTP 호출을 유발하며 그 결과가 무제한으로 저장됐다. 요청 하나가 워커
여러 개를 묶고 캐시 저장소를 계속 키우는 통로였고, 통계 기록 호출처가 없어 그 진행이
운영자 화면에 전혀 나타나지 않았다.
핵심 동작:
- SeoCacheBounds 가 상한 판정의 단일 출처 (config/core.php seo_cache_limits + env)
- 키 정규화: locale · _escaped_fragment_ 제거 + ksort, 개수·길이 상한 초과 시 캐시 불가
- 렌더 예산: IP 당 분당 미스 렌더 상한, 초과분은 SPA + X-SEO-Cache: BYPASS (429 아님)
- 저장 규모: 경로당 변종 수 · 전체 항목 수 상한, 기존 키 갱신은 상한과 무관
- 통계: 미들웨어가 HIT/MISS 를 기록하되 IP 당 분당 상한 안에서만
- 캐시 항목은 HTML + 레이아웃명 — HIT 통계가 화면별로 귀속된다 (이전 문자열 항목 호환)
- 경로당 변종 상한은 언어별로 센다 (인덱스 항목이 url|locale 별이므로)
- 렌더러가 null 을 돌려준 요청은 렌더 예산을 되돌린다 (예외는 되돌리지 않는다)
- 통계 url 컬럼(768)은 캐시 키 URL 상한을 담는다 — 마이그레이션 왕복 안전
axes:
bot_state: [bot, browser]
cache_state: [hit, miss]
ip_budget: [within, exhausted]
query_shape: [none, system_only, normal, oversized]
store_state: [under_caps, path_cap, global_cap]
exclusions:
- { bot_state: browser, cache_state: hit, reason: "비봇 요청은 SEO 파이프라인에 들어가지 않아 캐시를 보지 않는다" }
- { cache_state: hit, store_state: path_cap, reason: "저장 상한은 미스 경로에서만 판정된다" }
effects:
- bot_miss_over_limit_gets_spa_bypass
- bot_hit_served_regardless_of_limit
- system_query_params_excluded_from_key
- oversized_query_bypasses_cache_and_render
- cache_hit_and_miss_are_recorded_in_stats
- stats_recording_capped_per_ip
- store_skips_write_at_path_variant_cap
- store_skips_write_at_global_cap
- expired_index_entries_are_pruned_before_cap_verdict
- put_and_put_with_layout_share_the_storage_cap
- 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:
- tests/Unit/Seo/SeoCacheBoundsTest.php
- tests/Unit/Seo/SeoCacheManagerTest.php
- tests/Feature/Seo/SeoMiddlewareTest.php
- tests/Unit/Seo/SeoCacheStatsServiceTest.php
- tests/Feature/Database/ModifyUrlInSeoCacheStatsMigrationTest.php