From f506c040c18bcd8ea5799880220a08f48b96329c Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 8 Sep 2026 00:31:20 +0900 Subject: [PATCH] =?UTF-8?q?fix(core):=20=EA=B3=B5=EA=B0=9C=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=EB=B2=88=EB=93=A4=EC=9D=98=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EC=84=9C=EB=B9=99=20+=20SEO=20=EB=B4=87?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EC=83=81=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는 (type, kind, version) 인자만으로 계산되는데 "병합 결과가 비면 파일을 만들지 않는다" 는 규칙을 먼저 두느라 빌드를 앞세운 것이 원인이다. 그래서 캐시 적중 경로에도 활성 확장 열거와 산출물 전량 읽기가 붙었고, 원본이 소실되면 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 됐다. 응답은 정상 200 이라 타이밍 말고는 드러나는 증상이 없다. 프로덕션에서 캐시 존재를 병합보다 먼저 확인하고, 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴시킨 뒤 잠금 뒤 캐시를 재확인한다. 잠금 대기 초과·저장소 장애는 실패로 바꾸지 않고 각자 빌드로 폴백한다. 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시를 만들어 정적 게시까지 보낸다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거치고, 그 요청마다 컨트롤러가 열거를 세 번 반복한다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이라 응답 계약은 바뀌지 않는다 — 컨트롤러와 트레이트는 손대지 않았다. 같은 결의 결함이 검색봇 캐시에도 있었다. 봇 판정은 User-Agent 문자열뿐인데 캐시 키가 경로 + 전체 쿼리여서, 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 되고 그 미스마다 레이아웃 병합·표현식 평가·자기 API 루프백 호출이 일어나며 결과가 무제한 저장됐다. 키를 정규화하고(시스템 파라미터 제외·개수/길이 상한), IP 당 분당 미스 렌더 예산과 저장 규모 상한을 뒀다. 초과분은 차단이 아니라 일반 SPA 응답을 받는다 — 봇에게 오류를 주면 그 URL 이 색인에서 빠지기 때문이다. 저장 상한은 만료 항목을 걷어낸 뒤 판정한다. 인덱스는 페이지보다 오래 살아 (30일 vs 기본 2시간) 정리 없이 세면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어 일방향 래치가 된다. 정리는 인덱스 전체를 훑으므로 최소 60초 간격으로만 수행한다. put 과 putWithLayout 은 같은 자원을 쓰므로 단일 저장 경로로 합쳤다 — 한쪽만 상한 밖이면 그쪽이 우회로가 되고, 인터페이스는 확장에 열려 있어 "지금 호출부가 없다" 는 방어가 되지 않는다. 캐시 적중·미적중을 기록하는 호출처가 없어 관리자 SEO 통계와 seo:stats 가 항상 0 이었던 것도 함께 고쳤다. 기록 자체에도 IP 당 상한을 둬 통계 테이블이 새 증식 축이 되지 않게 했다. (KISA 측에서 제보해주셨습니다 — KVE-2026-2191) --- .env.example | 10 + AGENTS.md | 2 + CHANGELOG.md | 3 + app/Seo/SeoCacheBounds.php | 164 +++++++++++ app/Seo/SeoCacheManager.php | 136 ++++++--- app/Seo/SeoMiddleware.php | 102 ++++++- app/Services/ExtensionBundleService.php | 123 +++++++- config/core.php | 36 +++ docs/backend/seo-system.md | 48 ++- docs/backend/static-asset-publishing.md | 4 +- docs/extension/module-assets.md | 17 +- docs/testing/e2e-testing.md | 1 + tests/Feature/Seo/SeoMiddlewareTest.php | 201 +++++++++++++ .../ExtensionStaticCacheServiceTest.php | 48 +++ .../specs/extension-bundle-loading.spec.ts | 8 +- tests/Unit/Seo/SeoCacheBoundsTest.php | 165 +++++++++++ tests/Unit/Seo/SeoCacheManagerTest.php | 213 ++++++++++++++ tests/Unit/Seo/SeoRendererTest.php | 8 + .../Services/ExtensionBundleServiceTest.php | 275 +++++++++++++++++- tests/scenarios/extension-bundle-loading.yaml | 8 +- tests/scenarios/seo-bot-cache-bounds.yaml | 46 +++ 21 files changed, 1537 insertions(+), 81 deletions(-) create mode 100644 app/Seo/SeoCacheBounds.php create mode 100644 tests/Unit/Seo/SeoCacheBoundsTest.php create mode 100644 tests/scenarios/seo-bot-cache-bounds.yaml 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