diff --git a/.env.example b/.env.example index 25f1be5e..5ad19038 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,16 @@ G7_UPDATE_PENDING_PATH= # 상태·수동 복구: php artisan ext-static:status / 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 # 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 이 정적 게시본을 지운다). # 기본값은 config/app.php 의 update 절과 같다. diff --git a/AGENTS.md b/AGENTS.md index fc2c03e9..404e2291 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1433,6 +1433,8 @@ lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱 - 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들. - 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, kind, version)` 만으로 계산되는데 빌드를 앞세우면 캐시 적중에도 매 요청 활성 확장 열거·파일 읽기가 일어나고, 원본이 소실되면 멀쩡한 캐시를 두고 503 이 된다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴하고 잠금 뒤 캐시를 재확인하며, 잠금 대기 초과·저장소 장애는 실패가 아니라 각자 빌드로 폴백한다. +- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이다. ### 빌드 명령어 (Artisan) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d9638b..0062d5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ - 로그인 중 네트워크가 끊겼을 때 영문 원문(`Network Error`)이 표시되던 문제를 수정했습니다. - 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다. - 안내 문구 안에 날짜·시각 서식을 넣으면 그 값만 비어 보이던 문제를 수정했습니다. +- 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191) +- 검색엔진 봇에게 대신 그려 주는 페이지가 주소의 물음표 뒤 값만 바꿔 계속 요청하면 매번 새로 그려지고 그 결과가 무한정 저장되던 문제를 수정했습니다. 봇으로 위장한 요청이 서버 부하와 저장 공간 증가로 이어질 수 있었습니다. 이제 한 IP 가 분당 일정 횟수를 넘겨 새 페이지를 요청하면 그 초과분에는 일반 페이지를 주고, 저장 개수에도 상한을 둡니다. 상한값은 서버 설정으로 바꿀 수 있습니다. +- 관리자 SEO 통계와 `seo:stats` 명령이 항상 0 으로 표시되던 문제를 수정했습니다. 캐시 적중·미적중이 기록되지 않고 있었습니다. ## [7.0.10] - 2026-09-06 diff --git a/app/Seo/SeoCacheBounds.php b/app/Seo/SeoCacheBounds.php new file mode 100644 index 00000000..2713aadf --- /dev/null +++ b/app/Seo/SeoCacheBounds.php @@ -0,0 +1,164 @@ + $query 요청 쿼리 파라미터 + * @return array|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); + } + + /** + * 이 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 을 캐시에 저장할 수 있는지 판정합니다. + * + * 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가 + * 늘지 않기 때문이다. + * + * @param array> $index 현재 캐시 인덱스 + * @param string $url 저장하려는 URL (경로 + 정규화 쿼리) + * @return bool 저장 허용 여부 + */ + public static function canStore(array $index, string $url): bool + { + if (count($index) >= self::limit('max_entries', 20000)) { + return false; + } + + $path = self::pathOf($url); + $variants = 0; + + foreach ($index as $entry) { + 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; + } +} diff --git a/app/Seo/SeoCacheManager.php b/app/Seo/SeoCacheManager.php index 0df68d37..1c1ebaf2 100644 --- a/app/Seo/SeoCacheManager.php +++ b/app/Seo/SeoCacheManager.php @@ -18,6 +18,16 @@ class SeoCacheManager implements SeoCacheManagerInterface */ 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) {} /** @@ -39,17 +49,7 @@ class SeoCacheManager implements SeoCacheManagerInterface */ public function put(string $url, string $locale, string $html): void { - if (! $this->isEnabled()) { - return; - } - - $key = $this->buildKey($url, $locale); - $ttl = $this->getCacheTtl(); - - $this->cache->put($key, $html, $ttl); - - // URL 인덱스 업데이트 - $this->addToIndex($url, $locale, $key); + $this->storePage($url, $locale, $html, null); } /** @@ -148,26 +148,6 @@ class SeoCacheManager implements SeoCacheManagerInterface 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 +157,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> 정리된 인덱스 + */ + private function rebuildIndex(): array { $index = $this->getIndex(); $validIndex = []; @@ -191,6 +196,8 @@ class SeoCacheManager implements SeoCacheManagerInterface } $this->cache->put(self::INDEX_KEY, $validIndex, 86400 * 30); + + return $validIndex; } /** @@ -210,32 +217,79 @@ class SeoCacheManager implements SeoCacheManagerInterface /** * 캐시 저장 시 레이아웃 정보를 함께 저장합니다. * + * 인덱스는 단일 캐시 항목에 전체 변종 배열을 담고 저장마다 통째로 다시 쓴다. 항목 수에 + * 상한이 없으면 쿼리만 바꾼 반복 요청이 그 배열을 무한히 키운다 — 그래서 **새 URL** 은 + * 경로당 변종 수와 전체 항목 수 상한 안에서만 저장한다. 이미 인덱스에 있는 키의 갱신은 + * 저장 규모를 늘리지 않으므로 상한과 무관하게 쓴다. + * * @param string $url URL * @param string $locale 로케일 * @param string $html HTML * @param string $layoutName 레이아웃명 */ 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()) { return; } $key = $this->buildKey($url, $locale); - $ttl = $this->getCacheTtl(); - - $this->cache->put($key, $html, $ttl); - - // 레이아웃 정보 포함하여 인덱스 업데이트 $index = $this->getIndex(); - $index[$key] = [ + + if (! isset($index[$key])) { + // 상한이 세는 인덱스에는 **페이지가 이미 만료된** 항목이 섞인다 — 인덱스는 + // 페이지보다 훨씬 오래 살고(30일 vs 기본 2시간) 저장마다 수명이 갱신되며 + // 스스로 줄지 않는다. 여기서 한 번 정리하지 않으면 상한이 "지금 저장된 양"이 + // 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 닿은 경로는 실제 + // 캐시가 비어도 영영 저장이 막힌다(상한이 아니라 일방향 래치가 된다). + if (! SeoCacheBounds::canStore($index, $url) && $this->shouldAttemptPrune()) { + $index = $this->rebuildIndex(); + } + + if (! SeoCacheBounds::canStore($index, $url)) { + Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [ + 'url' => $url, + 'locale' => $locale, + 'entries' => count($index), + ]); + + return; + } + } + + $this->cache->put($key, $html, $this->getCacheTtl()); + + $entry = [ 'url' => $url, 'locale' => $locale, '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); } } diff --git a/app/Seo/SeoMiddleware.php b/app/Seo/SeoMiddleware.php index 0a203c38..09513079 100644 --- a/app/Seo/SeoMiddleware.php +++ b/app/Seo/SeoMiddleware.php @@ -15,10 +15,15 @@ class SeoMiddleware private readonly BotDetector $botDetector, private readonly SeoCacheManagerInterface $cacheManager, private readonly SeoRendererInterface $renderer, + private readonly SeoCacheStatsService $statsService, ) {} /** * 검색 봇 요청 시 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 { @@ -56,18 +61,44 @@ class SeoMiddleware $request->attributes->set('seo_default_locale', $defaultLocale); app()->setLocale($locale); - // 캐시 키용 URL 생성 (경로 + 쿼리 파라미터, locale 제외) - $cacheUrl = $this->buildCacheUrl($request); + // 캐시 키용 쿼리 정규화 — 정규화할 수 없을 만큼 큰 쿼리는 색인 대상이 아니다. + // 그런 URL 까지 렌더·저장하면 물음표 뒤 값만 바꾼 반복 요청이 무한한 미스가 된다. + $normalizedQuery = SeoCacheBounds::normalizeQuery($request->query()); - // 캐시 확인 + if ($normalizedQuery === null) { + return $this->bypass($request, $next); + } + + $ip = (string) $request->ip(); + + // 캐시 키용 URL 생성 (경로 + 정규화된 쿼리) + $cacheUrl = $this->buildCacheUrl($request, $normalizedQuery); + + // 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다 $cachedHtml = $this->cacheManager->get($cacheUrl, $locale); if ($cachedHtml !== null) { + $this->recordStat($ip, fn () => $this->statsService->recordHit( + $cacheUrl, + $locale, + $request->attributes->get('seo_layout_name') ?: null + )); + return response($cachedHtml, 200, [ 'Content-Type' => 'text/html; charset=utf-8', 'X-SEO-Cache' => 'HIT', ]); } + // 미스 렌더는 IP 당 분당 예산 안에서만 — 초과분은 오류가 아니라 SPA 를 받는다. + // 봇에게 429 를 주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해가 된다. + if (! SeoCacheBounds::renderAllowed($ip)) { + return $this->bypass($request, $next); + } + + SeoCacheBounds::recordRender($ip); + + $startedAt = microtime(true); + // 렌더링 try { $html = $this->renderer->render($request); @@ -110,6 +141,14 @@ class SeoMiddleware $layoutName = $request->attributes->get('seo_layout_name', ''); $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, [ 'Content-Type' => 'text/html; charset=utf-8', 'X-SEO-Cache' => 'MISS', @@ -117,30 +156,61 @@ class SeoMiddleware } /** - * 캐시 키용 URL을 생성합니다. + * 캐시·렌더를 건너뛰고 SPA 응답을 돌려줍니다. * - * 경로 + 쿼리 파라미터를 포함하되, locale 파라미터는 제외합니다. - * 쿼리 파라미터를 키 순서로 정렬하여 동일 파라미터 조합이 같은 캐시 키를 생성하도록 합니다. + * 상한 초과는 오류가 아니라 "이 요청은 봇 렌더 대상이 아니다" 라는 판정이다. 헤더는 + * 운영 진단의 유일한 통로다 — 응답 본문만으로는 일반 SPA 폴백과 구분되지 않는다. * * @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 $normalizedQuery 정규화된 쿼리 파라미터 * @return string 캐시 키용 URL */ - private function buildCacheUrl(Request $request): string + private function buildCacheUrl(Request $request, array $normalizedQuery): string { $path = $request->getPathInfo(); - // locale을 제외한 쿼리 파라미터 추출 - $query = $request->query(); - unset($query['locale']); - - if (empty($query)) { + if ($normalizedQuery === []) { return $path; } - // 키 순서 정렬 (동일 파라미터 조합 → 동일 캐시 키 보장) - ksort($query); - - return $path.'?'.http_build_query($query); + return $path.'?'.http_build_query($normalizedQuery); } /** diff --git a/app/Services/ExtensionBundleService.php b/app/Services/ExtensionBundleService.php index ec7b51ce..72c7aec2 100644 --- a/app/Services/ExtensionBundleService.php +++ b/app/Services/ExtensionBundleService.php @@ -9,6 +9,8 @@ use App\Extension\Traits\ClearsTemplateCaches; use App\Http\View\Composers\TemplateComposer; use App\Support\AssetCssUrlRewriter; use App\Support\AssetUrl; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; /** @@ -49,6 +51,21 @@ class ExtensionBundleService */ 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; + /** * 서비스 주입 * @@ -251,22 +268,18 @@ class ExtensionBundleService * 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해 * rebuild 를 즉시 반영한다. * + * 프로덕션에서 **캐시 존재 확인이 빌드보다 먼저** 온다. 캐시 키는 인자만으로 + * 계산되므로 빌드가 필요 없는데, 빌드를 앞세우면 캐시가 있어도 요청마다 활성 확장을 + * 열거하고 산출물을 전부 읽는다. 응답은 정상 200 이라 그 반복은 타이밍 말고는 드러나지 + * 않고, 원본이 사라지는 순간에는 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 된다. + * * @param string $type 'module' | 'plugin' * @param string $kind 'js' | 'css' * @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion) - * @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 병합 결과가 빈 문자열이면 빈 문자열. + * @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 캐시할 수 없으면 빈 문자열. */ public function getBundleFilePath(string $type, string $kind, int $version): string { - $content = $kind === 'css' - ? $this->buildCssBundle($type) - : $this->buildJsBundle($type); - - // 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단). - if ($content === '') { - return ''; - } - $relativeName = $this->bundleFileName($type, $kind, $version); // 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다. @@ -279,15 +292,22 @@ class ExtensionBundleService // 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영 if (! app()->environment('production')) { + $content = $this->buildBundleContent($type, $kind); + + // 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단). + if ($content === '') { + return ''; + } + return $this->writeAtomically($storage, $relativeName, $content, cache: false); } - // 프로덕션: 동일 version 캐시가 있으면 그대로 사용 + // 프로덕션: 동일 version 캐시가 있으면 빌드 없이 그대로 사용 if ($storage->exists('', $relativeName)) { return $storage->getBasePath('').'/'.$relativeName; } - return $this->writeAtomically($storage, $relativeName, $content, cache: true); + return $this->buildAndCacheOnce($storage, $type, $kind, $version, $relativeName); } catch (\Throwable $e) { Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [ 'type' => $type, @@ -300,6 +320,85 @@ class ExtensionBundleService } } + /** + * 캐시 미스에서 한 번만 병합해 캐시 파일을 만들고 그 절대 경로를 반환합니다. + * + * 같은 (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 = $this->buildBundleContent($type, $kind); + + // 비었는데 선언한 산출물이 소실이면 캐시하지 않는다 — 배포 중 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 이 정리한다 — 서빙에는 영향이 없다. + } + } + } + } + /** * 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용). * diff --git a/config/core.php b/config/core.php index 53288a11..b8f71052 100644 --- a/config/core.php +++ b/config/core.php @@ -120,6 +120,42 @@ return [ '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), + ], + /* |-------------------------------------------------------------------------- | 아웃바운드 프록시 연결 테스트 diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index 7011dd3a..92575fa9 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -105,6 +105,10 @@ Request → web.php catch-all → SeoMiddleware (봇 감지) - 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조) - 렌더링 실패 시: SPA fallback (기존 응답 통과) +- 캐시 키: 경로 + **정규화된** 쿼리(`locale`·`_escaped_fragment_` 제외, 키 순서 정렬, 개수·길이 상한) +- 미스 렌더는 IP 당 분당 상한 안에서만 — 초과분은 SPA + `X-SEO-Cache: BYPASS`(오류가 아니다) +- 저장 상한: 경로당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수 +- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한) ## 봇 감지 구조 @@ -655,7 +659,7 @@ php artisan seo:warmup # SEO 캐시 워밍업 php artisan seo:warmup --layout=shop/show # 특정 레이아웃만 php artisan seo:clear # 전체 SEO 캐시 삭제 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 --sync # Sitemap 동기 생성 php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당) @@ -755,6 +759,43 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) | twitter_default_card | string | "summary_large_image" | twitter:card 기본 (summary/summary_large_image/app/player) | | 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 당 분당 미스 렌더 수 | +| 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` 로 몰리는지를 먼저 봅니다. + +### 저장 상한이 세는 것은 살아 있는 항목입니다 + +캐시 인덱스 항목은 페이지보다 오래 삽니다(기본 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 엔진은 컴포넌트 지식을 갖지 않습니다. 모든 컴포넌트→HTML 매핑, 렌더 모드, 셀프 클로징 태그, 외부 스타일시트는 `seo-config.json`으로 제공됩니다. @@ -1653,9 +1694,10 @@ SeoCacheManager는 URL + locale 기반 캐시 키(`md5($cacheUrl.'|'.$locale)`) SeoMiddleware의 `buildCacheUrl()`이 캐시 키용 URL을 구성합니다: -- **경로 + 쿼리 파라미터 포함**: `/shop/products?page=2&sort=price` → 페이지별 독립 캐시 -- **`locale` 파라미터 제외**: locale은 캐시 키의 두 번째 차원(`$locale`)으로 별도 관리 +- **경로 + 정규화된 쿼리 파라미터**: `/shop/products?page=2&sort=price` → 페이지별 독립 캐시 +- **시스템 파라미터 제외**: `locale` 은 캐시 키의 두 번째 차원(`$locale`)으로 별도 관리하고, 봇 렌더 표식인 `_escaped_fragment_` 는 내용에 영향이 없어 키에서 뺍니다(남기면 같은 페이지가 두 벌 저장됩니다) - **쿼리 파라미터 정렬**: `ksort()` — 동일 파라미터 조합 = 동일 캐시 키 보장 +- **상한 초과 시 캐시 불가**: 파라미터 수·길이가 상한을 넘으면 색인 대상이 아니라고 보고 캐시도 렌더도 하지 않습니다(위 "캐시 상한" 절) ## SEO 변수 시스템 diff --git a/docs/backend/static-asset-publishing.md b/docs/backend/static-asset-publishing.md index e6894493..b80718dc 100644 --- a/docs/backend/static-asset-publishing.md +++ b/docs/backend/static-asset-publishing.md @@ -33,7 +33,7 @@ public/build/ext/{cache_version}/ │ ├── routes.json ← 병합 결과 + {"success":true,...} 봉투 │ └── assets/{dist 이하 경로} ← dist/** 사본 (*.map 제외, 허용 확장자만) └── bundles/ - ├── modules.js / modules.css ← 확장 병합 번들 사본 + ├── modules.js / modules.css ← 확장 병합 번들 사본 (빈 번들도 0바이트로 게시) └── 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)은 그대로다 — 복구는 화면에서, 끄기는 서버에서. diff --git a/docs/extension/module-assets.md b/docs/extension/module-assets.md index 87c44b9a..c0fd5702 100644 --- a/docs/extension/module-assets.md +++ b/docs/extension/module-assets.md @@ -520,6 +520,7 @@ GET /api/plugins/bundle.css?v={version} | CSS url() | 상대 `url()`·`@import` 참조는 그 확장의 절대 자산 URL 로 **치환**해 병합. 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋난다 | (계약 테스트) | | 디스크 캐시 fail-soft | 캐시 쓰기 실패는 **500 이 아니다** — 메모리 병합 결과를 그대로 200 으로 서빙 | (계약 테스트) | | 빈 번들 판정 | 선언한 산출물이 소실·판독 불가면 **503**, 존재하되 비었으면 빈 200 (선언 0 도 빈 200) | (계약 테스트) | +| 캐시 우선 | 프로덕션은 `(type, kind, version)` 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴(잠금 실패는 각자 빌드) | (계약 테스트) | ### 병합 CSS 의 상대 참조 @@ -538,7 +539,7 @@ GET /api/plugins/bundle.css?v={version} | 상태 | 판정 | 응답 | |---|---|---| | 에셋을 선언한 활성 확장이 0개 | 정상 | 빈 200 | -| 선언은 있고 그 산출물이 **전부 존재**하되 비어 있음 | 정상 (스타일이 비어 있는 확장) | 빈 200 | +| 선언은 있고 그 산출물이 **전부 존재**하되 비어 있음 | 정상 (스타일이 비어 있는 확장) | 0바이트 캐시 파일 + 정적 게시, 빈 200 | | 선언한 산출물이 **소실·판독 불가** | 장애 (배포 중 `dist` 가 잠깐 빔, 경로 어긋남) | **503** + `Log::error`(소실 경로 목록) | 장애를 정상으로 흘리면 프론트는 404 도 오류도 받지 못한 채 한참 뒤 "Unknown action handler" 로 죽는다 — 그 시점에는 원인이 번들이라는 사실이 화면에도 로그에도 남아 있지 않다. 반대로 정상을 장애로 잡으면 스타일 소스가 자리표시 주석뿐인 확장만 설치된 기본 구성이 통째로 503 이 되어 **사용자 화면마다 실패 안내가 뜬다.** 판정은 **kind 별**이다(js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은 정상). @@ -550,7 +551,9 @@ GET /api/plugins/bundle.css?v={version} 0바이트 산출물은 정당한 상태다. 스타일 규칙이 아직 없는 확장이 CSS 를 선언하는 것은 어긋남이 아니며, 그 상태를 배포 장애로 등치하면 정상 사이트가 서비스 불능으로 보고된다. -빈 번들은 정적 게시 대상이 아니다(게시할 사본이 없다). 그 결과 `AssetUrl::extensionBundle()` 이 정적 URL 대신 API URL 을 방출하므로 브라우저에는 정적 404 폴백이 생기지 않고, 그 API 가 위 표대로 빈 200 을 낸다. +선언 산출물이 전부 존재하는 빈 번들은 **0바이트 파일로 캐시·정적 게시**되어 브라우저가 웹서버에서 직접 받는다. 게시하지 않으면 `AssetUrl::extensionBundle()` 이 API URL 을 방출해 그 구성의 **모든 페이지 로드**가 PHP 를 거치고, 그 요청마다 컨트롤러가 활성 확장 열거를 세 번 반복한다(경로 조회 → 재빌드 → 소실 판정). 오류도 로그도 남지 않아 드러나지 않는 경로다. + +캐시하지 않는 것은 둘뿐이다 — 선언 산출물이 **소실**된 경우(503 판정을 그대로 유지해야 한다)와 디스크 쓰기 자체가 실패한 경우다. 두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`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 bump 로 반영한다 — `{type}:update {id} --force` 가 그 bump 를 수행한다. `dist` 를 손으로 덮어쓰고 bump 를 건너뛰면 같은 version 의 캐시가 계속 서빙된다. + > 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다. > 다만 `*.map` 의 **실제 서빙은 `local` 환경에서만** 허용된다 — 소스맵에는 원본 코드 전문이 > 담기므로 운영에서는 확장자 화이트리스트가 차단한다. 상세: [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) 번들 JS/CSS 는 `fileResponse()`(= `response()->file()` → `BinaryFileResponse`)로 서빙되며, `GzipEncodeResponse` 미들웨어가 gzip 압축을 적용한다. `BinaryFileResponse` 는 `getContent()` 가 `false` 를 반환하므로, 미들웨어는 파일 경로(`getFile()->getPathname()`)에서 본문을 읽어 압축한 뒤 헤더(Content-Type/ETag/Cache-Control)를 승계한 일반 `Response` 로 치환한다. diff --git a/docs/testing/e2e-testing.md b/docs/testing/e2e-testing.md index d8a417a3..44ed0554 100644 --- a/docs/testing/e2e-testing.md +++ b/docs/testing/e2e-testing.md @@ -132,6 +132,7 @@ UA 를 실제 브라우저 값으로 고정해도 그 검증은 그대로 동작 | 공유 상태 | 같은 관리자 설정 화면을 건드리는 spec 이 병렬로 돌면 서로의 저장 상태를 덮어써 실패할 수 있다. 실행 옵션에 맡기지 않고 그 `describe` 에 `test.describe.configure({ mode: 'serial' })` 를 둔다 | | 워커 수 | 관리자 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 의 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건은 기본값에서 여유가 있다) | ### 브라우저 범위 diff --git a/tests/Feature/Seo/SeoMiddlewareTest.php b/tests/Feature/Seo/SeoMiddlewareTest.php index 649098d6..3120a893 100644 --- a/tests/Feature/Seo/SeoMiddlewareTest.php +++ b/tests/Feature/Seo/SeoMiddlewareTest.php @@ -5,8 +5,10 @@ namespace Tests\Feature\Seo; use App\Seo\BotDetector; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\Contracts\SeoRendererInterface; +use App\Seo\SeoCacheStatsService; use App\Seo\SeoMiddleware; use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Tests\TestCase; /** @@ -24,6 +26,8 @@ class SeoMiddlewareTest extends TestCase private SeoRendererInterface $renderer; + private SeoCacheStatsService $statsService; + /** * 테스트 환경 설정 */ @@ -34,12 +38,18 @@ class SeoMiddlewareTest extends TestCase $this->botDetector = $this->createMock(BotDetector::class); $this->cacheManager = $this->createMock(SeoCacheManagerInterface::class); $this->renderer = $this->createMock(SeoRendererInterface::class); + $this->statsService = $this->createMock(SeoCacheStatsService::class); $this->middleware = new SeoMiddleware( $this->botDetector, $this->cacheManager, $this->renderer, + $this->statsService, ); + + // 렌더·통계 예산은 IP 단위 카운터다 — 테스트 간 이월되면 순서에 따라 결과가 갈린다. + RateLimiter::clear('seo-render:127.0.0.1'); + RateLimiter::clear('seo-stats:127.0.0.1'); } /** @@ -403,6 +413,7 @@ class SeoMiddlewareTest extends TestCase app(BotDetector::class), $this->cacheManager, $this->renderer, + $this->statsService, ); $response = $middleware->handle($request, $this->spaNext()); @@ -435,6 +446,7 @@ class SeoMiddlewareTest extends TestCase app(BotDetector::class), $this->cacheManager, $this->renderer, + $this->statsService, ); $response = $middleware->handle($request, $this->spaNext()); @@ -469,6 +481,7 @@ class SeoMiddlewareTest extends TestCase app(BotDetector::class), $this->cacheManager, $this->renderer, + $this->statsService, ); $response = $middleware->handle($request, $this->spaNext()); @@ -501,4 +514,192 @@ class SeoMiddlewareTest extends TestCase $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('cached'); + + 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 'cached'; + }); + + $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('cached'); + + $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('rendered'); + + $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('cached'); + + 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()); + } } diff --git a/tests/Feature/Services/ExtensionStaticCacheServiceTest.php b/tests/Feature/Services/ExtensionStaticCacheServiceTest.php index 2e081844..160706c9 100644 --- a/tests/Feature/Services/ExtensionStaticCacheServiceTest.php +++ b/tests/Feature/Services/ExtensionStaticCacheServiceTest.php @@ -1461,4 +1461,52 @@ class ExtensionStaticCacheServiceTest extends TestCase 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}")); + } + } + } + } } diff --git a/tests/Playwright/specs/extension-bundle-loading.spec.ts b/tests/Playwright/specs/extension-bundle-loading.spec.ts index a6466fcc..e4a61968 100644 --- a/tests/Playwright/specs/extension-bundle-loading.spec.ts +++ b/tests/Playwright/specs/extension-bundle-loading.spec.ts @@ -109,8 +109,12 @@ test.describe('확장 병합 번들 로딩', () => { * @effects bundle_css_failure_banner_uses_user_vocabulary */ test('번들 CSS 가 503 이면 안내 항목명이 사용자 어휘다', async ({ page }) => { - await page.route(/\/api\/(modules|plugins)\/bundle[./]css/, (route) => - route.fulfill({ status: 503, contentType: 'text/css', body: '' }), + // 번들 CSS 는 구성에 따라 정적 게시본(`/build/ext/{v}/bundles/*.css`) 또는 API 로 + // 나간다 — 빈 번들도 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('/'); diff --git a/tests/Unit/Seo/SeoCacheBoundsTest.php b/tests/Unit/Seo/SeoCacheBoundsTest.php new file mode 100644 index 00000000..8658d6f6 --- /dev/null +++ b/tests/Unit/Seo/SeoCacheBoundsTest.php @@ -0,0 +1,165 @@ +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'], + 'k2' => ['url' => '/shop?page=2'], + 'k3' => ['url' => '/shop?page=3'], + ]; + + $this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=4')); + // 다른 경로는 자기 예산을 따로 쓴다 + $this->assertTrue(SeoCacheBounds::canStore($index, '/board?page=1')); + } + + /** + * 전체 항목 수 상한에 닿으면 어떤 경로도 저장하지 않는다. + * + * @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'], + 'k2' => ['url' => '/b'], + ]; + + $this->assertFalse(SeoCacheBounds::canStore($index, '/c')); + } + + /** + * 렌더 예산은 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')); + } +} diff --git a/tests/Unit/Seo/SeoCacheManagerTest.php b/tests/Unit/Seo/SeoCacheManagerTest.php index 93c85526..983d2e75 100644 --- a/tests/Unit/Seo/SeoCacheManagerTest.php +++ b/tests/Unit/Seo/SeoCacheManagerTest.php @@ -210,4 +210,217 @@ class SeoCacheManagerTest extends TestCase $this->assertNull($result); $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', ''.$i.'', 'shop'); + } + + $before = $this->cacheManager->getCachedUrls(); + + $this->cacheManager->putWithLayout('/shop?page=4', 'ko', '4', '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', 'a', 'la'); + $this->cacheManager->putWithLayout('/b', 'ko', 'b', 'lb'); + $this->cacheManager->putWithLayout('/c', 'ko', 'c', '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', 'old', 'la'); + $this->cacheManager->putWithLayout('/a', 'ko', 'new', 'la'); + + $this->assertSame('new', $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', ''.$i.'', 'shop'); + } + + $this->expirePages($driver); + + $manager->putWithLayout('/shop?page=4', 'ko', '4', 'shop'); + + $this->assertSame('4', $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', 'a', 'la'); + $manager->putWithLayout('/b', 'ko', 'b', 'lb'); + + $this->expirePages($driver); + + $manager->putWithLayout('/c', 'ko', 'c', 'lc'); + + $this->assertSame('c', $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', 'a'); + $this->cacheManager->put('/b', 'ko', 'b'); + $this->cacheManager->put('/c', 'ko', 'c'); + + $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', 'a'); + $manager->put('/b', 'ko', 'b'); + + $this->expirePages($driver); + + $manager->put('/c', 'ko', 'c'); + + $this->assertSame('c', $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', ''.$i.'', 'shop'); + } + + // 전부 살아 있는 상태에서 상한 도달 → 1회 스캔하고 표식을 남긴다 (저장은 안 됨) + $manager->putWithLayout('/shop?page=4', 'ko', '4', 'shop'); + $this->assertNull($manager->get('/shop?page=4', 'ko')); + + // 이후 만료되어도 표식이 살아 있는 동안은 다시 훑지 않는다 + $this->expirePages($driver); + $manager->putWithLayout('/shop?page=5', 'ko', '5', 'shop'); + $this->assertNull($manager->get('/shop?page=5', 'ko')); + + // 표식이 사라지면 다시 정리하고 저장한다 + $driver->forget('seo.index_pruned_at'); + $manager->putWithLayout('/shop?page=6', 'ko', '6', 'shop'); + $this->assertSame('6', $manager->get('/shop?page=6', 'ko')); + } } diff --git a/tests/Unit/Seo/SeoRendererTest.php b/tests/Unit/Seo/SeoRendererTest.php index c6354768..d759004c 100644 --- a/tests/Unit/Seo/SeoRendererTest.php +++ b/tests/Unit/Seo/SeoRendererTest.php @@ -766,6 +766,11 @@ class SeoRendererTest extends TestCase ], ], JSON_PRETTY_PRINT)); + // 선언한 산출물이 실재해야 링크된다 — 없는 경로의 는 봇 화면에서만 404 가 + // 되고 어디에도 흔적이 남지 않으므로 렌더러가 실재 파일만 싣는다. + mkdir("{$configDir}/dist/css", 0755, true); + file_put_contents("{$configDir}/dist/css/components.css", ''); + // 자산 URL 모드를 고정 — 운영 설정(general.asset_url_mode)에 따라 // 확장자 유지/제거 두 형태가 나오므로 검증 대상 형태를 명시한다. AssetUrl::forceMode(AssetUrl::MODE_EXTENSION); @@ -818,6 +823,9 @@ class SeoRendererTest extends TestCase $this->assertNotNull($result); } finally { AssetUrl::forceMode(null); + @unlink("{$configDir}/dist/css/components.css"); + @rmdir("{$configDir}/dist/css"); + @rmdir("{$configDir}/dist"); @unlink("{$configDir}/template.json"); @rmdir($configDir); } diff --git a/tests/Unit/Services/ExtensionBundleServiceTest.php b/tests/Unit/Services/ExtensionBundleServiceTest.php index 9aa5d1f5..0d124e9a 100644 --- a/tests/Unit/Services/ExtensionBundleServiceTest.php +++ b/tests/Unit/Services/ExtensionBundleServiceTest.php @@ -5,6 +5,9 @@ namespace Tests\Unit\Services; use App\Extension\ModuleManager; use App\Extension\PluginManager; 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 Mockery; use Tests\TestCase; @@ -437,8 +440,8 @@ class ExtensionBundleServiceTest extends TestCase app()->detectEnvironment(fn () => 'production'); $a = $this->writeFixture('a.js', '(function(){window.A=1})()'); - // getActiveModules 는 두 번 호출될 수 있으므로 안정적으로 반환 - $this->moduleManager->shouldReceive('getActiveModules')->andReturn([ + // 캐시 적중 경로는 활성 확장을 다시 열거하지 않아야 하므로 정확히 1회로 조인다 + $this->moduleManager->shouldReceive('getActiveModules')->once()->andReturn([ 'ext-a' => $this->fakeExtension('ext-a', 10, $a, null), ]); @@ -449,9 +452,12 @@ class ExtensionBundleServiceTest extends TestCase $this->assertFileExists($path1); $this->assertStringContainsString('module.999.js', $path1); + $content1 = (string) file_get_contents($path1); + // 같은 version 재요청 → 동일 파일 (캐시 히트) $path2 = $svc->getBundleFilePath('module', 'js', 999); $this->assertSame($path1, $path2); + $this->assertSame($content1, (string) file_get_contents($path2)); } /** @@ -711,4 +717,269 @@ class ExtensionBundleServiceTest extends TestCase $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] + ), + ]; + }); + + $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)); + } } diff --git a/tests/scenarios/extension-bundle-loading.yaml b/tests/scenarios/extension-bundle-loading.yaml index d922321e..780e06e1 100644 --- a/tests/scenarios/extension-bundle-loading.yaml +++ b/tests/scenarios/extension-bundle-loading.yaml @@ -30,7 +30,6 @@ axes: exclusions: - { strategy: layout, active_combo: none, reason: "layout/lazy 전략은 번들 대상 아님 — 개별 레이아웃 처리" } - - { active_combo: none, version_state: cache_hit, reason: "활성 에셋 0건이면 캐시 파일을 만들지 않음 (빈 경로)" } effects: - ordered_by_priority_ascending_only_no_name_hardcode @@ -65,6 +64,12 @@ effects: - empty_result_with_missing_declared_artifact_returns_503 - missing_artifact_paths_are_logged - 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 + - zero_byte_bundle_is_statically_published test_files: - tests/Unit/Services/ExtensionBundleServiceTest.php @@ -72,6 +77,7 @@ test_files: - tests/Unit/Extension/AbstractPluginDeclaredAssetsTest.php - tests/Unit/Extension/BundledManifestAssetDeclarationTest.php - tests/Feature/Api/Public/ExtensionBundleServingTest.php + - tests/Feature/Services/ExtensionStaticCacheServiceTest.php - resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts - tests/Playwright/specs/extension-bundle-loading.spec.ts diff --git a/tests/scenarios/seo-bot-cache-bounds.yaml b/tests/scenarios/seo-bot-cache-bounds.yaml new file mode 100644 index 00000000..59bb9e96 --- /dev/null +++ b/tests/scenarios/seo-bot-cache-bounds.yaml @@ -0,0 +1,46 @@ +# 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 당 분당 상한 안에서만 + +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 + +test_files: + - tests/Unit/Seo/SeoCacheBoundsTest.php + - tests/Unit/Seo/SeoCacheManagerTest.php + - tests/Feature/Seo/SeoMiddlewareTest.php