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..6afe14c7 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..39955770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ - 로그인 중 네트워크가 끊겼을 때 영문 원문(`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..9eca5642 --- /dev/null +++ b/app/Seo/SeoCacheBounds.php @@ -0,0 +1,187 @@ + $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); + } + + /** + * 차감한 미스 렌더 1건을 예산에 되돌립니다. + * + * 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청 — 미라우트 404, SEO 비활성 화면 — 은 + * 캐시에 남지 않아 올 때마다 다시 예산을 쓴다. 봇은 예전에 있던 죽은 주소를 오래 다시 + * 긁으므로, 그 요청까지 세면 정상 페이지의 예산이 죽은 주소에 소진된다. 렌더 도중 예외는 + * 비용을 이미 치른 것이라 되돌리지 않는다. + * + * @param string $ip 요청 IP + */ + public static function refundRender(string $ip): void + { + RateLimiter::decrement('seo-render:'.$ip, 60); + } + + /** + * 이 IP 의 요청을 통계로 기록할 수 있는지 판정합니다. + * + * 통계 테이블이 새로운 증식 축이 되지 않도록 기록 자체에도 상한을 둔다. + * + * @param string $ip 요청 IP + * @return bool 기록 허용 여부 + */ + public static function statsAllowed(string $ip): bool + { + return ! RateLimiter::tooManyAttempts( + 'seo-stats:'.$ip, + self::limit('stats_records_per_minute', 300) + ); + } + + /** + * 통계 기록 1건을 예산에서 차감합니다. + * + * @param string $ip 요청 IP + */ + public static function recordStat(string $ip): void + { + RateLimiter::hit('seo-stats:'.$ip, 60); + } + + /** + * 새 URL 을 캐시에 저장할 수 있는지 판정합니다. + * + * 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가 + * 늘지 않기 때문이다. + * + * 경로당 변종은 **언어별로** 센다. 인덱스 항목은 url|locale 별이라 경로만 보고 합산하면 + * 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 언어마다 캐시에서 빠진다. + * + * @param array> $index 현재 캐시 인덱스 + * @param string $url 저장하려는 URL (경로 + 정규화 쿼리) + * @param string $locale 저장하려는 로케일 + * @return bool 저장 허용 여부 + */ + public static function canStore(array $index, string $url, string $locale): bool + { + if (count($index) >= self::limit('max_entries', 20000)) { + return false; + } + + $path = self::pathOf($url); + $variants = 0; + + foreach ($index as $entry) { + if (($entry['locale'] ?? null) !== $locale) { + continue; + } + + if (self::pathOf((string) ($entry['url'] ?? '')) === $path) { + $variants++; + } + } + + return $variants < self::limit('max_variants_per_path', 50); + } + + /** + * URL 에서 경로 부분만 잘라냅니다. + * + * @param string $url 캐시 URL (`/path?a=1` 형태) + * @return string 경로 + */ + private static function pathOf(string $url): string + { + $path = parse_url($url, PHP_URL_PATH); + + return is_string($path) ? $path : $url; + } +} diff --git a/app/Seo/SeoCacheManager.php b/app/Seo/SeoCacheManager.php index 0df68d37..c117baaf 100644 --- a/app/Seo/SeoCacheManager.php +++ b/app/Seo/SeoCacheManager.php @@ -18,20 +18,57 @@ 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) {} /** * {@inheritdoc} */ public function get(string $url, string $locale): ?string + { + return $this->getEntry($url, $locale)['html'] ?? null; + } + + /** + * 캐시 항목(HTML + 레이아웃명)을 조회합니다. + * + * 페이지는 레이아웃명과 함께 저장된다 — 캐시 적중 경로는 렌더러를 거치지 않아 요청 + * 속성에 레이아웃명이 없고, 통계를 화면별로 귀속하려면 항목이 그것을 알아야 한다. + * 이전 버전이 문자열로만 저장한 항목은 레이아웃명 없이 그대로 읽힌다 — 배포 직후 + * 살아 있는 캐시를 버리지 않는다. + * + * @param string $url URL + * @param string $locale 로케일 + * @return array{html: string, layout: string|null}|null 캐시 항목 (없으면 null) + */ + public function getEntry(string $url, string $locale): ?array { if (! $this->isEnabled()) { return null; } - $key = $this->buildKey($url, $locale); + $value = $this->cache->get($this->buildKey($url, $locale)); - return $this->cache->get($key); + if (is_string($value)) { + return ['html' => $value, 'layout' => null]; + } + + if (is_array($value) && is_string($value['html'] ?? null)) { + $layout = $value['layout'] ?? null; + + return ['html' => $value['html'], 'layout' => is_string($layout) ? $layout : null]; + } + + return null; } /** @@ -39,17 +76,7 @@ class SeoCacheManager implements SeoCacheManagerInterface */ public function put(string $url, string $locale, string $html): void { - 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 +175,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 +184,34 @@ class SeoCacheManager implements SeoCacheManagerInterface } /** - * 유효한 캐시만 남겨 인덱스를 재구성합니다. + * 저장 상한에서 인덱스 정리를 시도해도 되는지 판정하고, 시도한다면 표식을 남깁니다. + * + * 정리는 인덱스 전체를 훑는다(항목마다 캐시 조회). 살아 있는 항목만으로 상한에 닿은 + * 경로는 저장 시도마다 그 스캔을 되풀이하게 되고, 그 빈도는 봇 미스 렌더 예산만큼이다 + * — 정리해도 자리가 나지 않는 상태에서 비용만 곱해진다. 그래서 간격으로 묶는다. + * + * @return bool 정리를 수행해도 되면 true */ - private function rebuildIndex(): void + private function shouldAttemptPrune(): bool + { + if ($this->cache->has(self::PRUNE_MARK_KEY)) { + return false; + } + + $this->cache->put(self::PRUNE_MARK_KEY, true, self::PRUNE_INTERVAL_SECONDS); + + return true; + } + + /** + * 유효한 캐시만 남겨 인덱스를 재구성하고 그 결과를 반환합니다. + * + * 페이지는 TTL 로 사라지지만 인덱스 항목은 남는다 — 이 메서드가 그 차이를 메우는 + * 유일한 지점이므로, 인덱스를 근거로 판정하는 쪽(저장 상한)은 판정 전에 여기를 거친다. + * + * @return array> 정리된 인덱스 + */ + private function rebuildIndex(): array { $index = $this->getIndex(); $validIndex = []; @@ -191,6 +223,8 @@ class SeoCacheManager implements SeoCacheManagerInterface } $this->cache->put(self::INDEX_KEY, $validIndex, 86400 * 30); + + return $validIndex; } /** @@ -210,32 +244,80 @@ class SeoCacheManager implements SeoCacheManagerInterface /** * 캐시 저장 시 레이아웃 정보를 함께 저장합니다. * + * 인덱스는 단일 캐시 항목에 전체 변종 배열을 담고 저장마다 통째로 다시 쓴다. 항목 수에 + * 상한이 없으면 쿼리만 바꾼 반복 요청이 그 배열을 무한히 키운다 — 그래서 **새 URL** 은 + * 경로당 변종 수와 전체 항목 수 상한 안에서만 저장한다. 이미 인덱스에 있는 키의 갱신은 + * 저장 규모를 늘리지 않으므로 상한과 무관하게 쓴다. + * * @param string $url URL * @param string $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, $locale) && $this->shouldAttemptPrune()) { + $index = $this->rebuildIndex(); + } + + if (! SeoCacheBounds::canStore($index, $url, $locale)) { + Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [ + 'url' => $url, + 'locale' => $locale, + 'entries' => count($index), + ]); + + return; + } + } + + // 레이아웃명을 페이지와 함께 둔다 — 적중 경로가 통계를 화면별로 귀속할 유일한 출처다. + $this->cache->put($key, ['html' => $html, 'layout' => $layoutName], $this->getCacheTtl()); + + $entry = [ 'url' => $url, '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..6f92ba8c 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()); - // 캐시 확인 - $cachedHtml = $this->cacheManager->get($cacheUrl, $locale); - if ($cachedHtml !== null) { - return response($cachedHtml, 200, [ + if ($normalizedQuery === null) { + return $this->bypass($request, $next); + } + + $ip = (string) $request->ip(); + + // 캐시 키용 URL 생성 (경로 + 정규화된 쿼리) + $cacheUrl = $this->buildCacheUrl($request, $normalizedQuery); + + // 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다 + $entry = $this->readEntry($cacheUrl, $locale); + if ($entry !== null) { + $this->recordStat($ip, fn () => $this->statsService->recordHit( + $cacheUrl, + $locale, + $entry['layout'] ?: null + )); + + return response($entry['html'], 200, [ 'Content-Type' => 'text/html; charset=utf-8', '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); @@ -103,6 +134,10 @@ class SeoMiddleware // 렌더링 실패 시 SPA fallback if ($html === null) { + // "그릴 게 없음" 은 비용을 치르지 않았다 — 되돌리지 않으면 캐시에 남지 않는 죽은 + // 주소 재크롤이 올 때마다 정상 페이지의 예산을 태운다. + SeoCacheBounds::refundRender($ip); + return $next($request); } @@ -110,6 +145,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 +160,84 @@ class SeoMiddleware } /** - * 캐시 키용 URL을 생성합니다. + * 캐시 항목(HTML + 레이아웃명)을 읽습니다. * - * 경로 + 쿼리 파라미터를 포함하되, locale 파라미터는 제외합니다. - * 쿼리 파라미터를 키 순서로 정렬하여 동일 파라미터 조합이 같은 캐시 키를 생성하도록 합니다. + * 적중 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없다 — 통계를 화면별로 + * 귀속하려면 캐시 항목이 그것을 알아야 한다. 인터페이스(`get`)는 HTML 만 돌려주므로 + * 코어 매니저일 때만 항목 전체를 읽고, 다른 구현이 바인딩된 경우에는 레이아웃명 없이 + * HTML 만 쓴다(그 통계는 레이아웃 미상으로 귀속된다). + * + * @param string $cacheUrl 캐시 키용 URL + * @param string $locale 로케일 + * @return array{html: string, layout: string|null}|null 캐시 항목 (없으면 null) + */ + private function readEntry(string $cacheUrl, string $locale): ?array + { + if ($this->cacheManager instanceof SeoCacheManager) { + return $this->cacheManager->getEntry($cacheUrl, $locale); + } + + $html = $this->cacheManager->get($cacheUrl, $locale); + + return $html === null ? null : ['html' => $html, 'layout' => null]; + } + + /** + * 캐시·렌더를 건너뛰고 SPA 응답을 돌려줍니다. + * + * 상한 초과는 오류가 아니라 "이 요청은 봇 렌더 대상이 아니다" 라는 판정이다. 헤더는 + * 운영 진단의 유일한 통로다 — 응답 본문만으로는 일반 SPA 폴백과 구분되지 않는다. * * @param Request $request HTTP 요청 + * @param 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..09af7245 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; + /** * 서비스 주입 * @@ -137,39 +154,7 @@ class ExtensionBundleService */ public function buildJsBundle(string $type): string { - $ordered = $this->getOrderedGlobalAssetPaths($type); - $isProduction = app()->environment('production'); - $segments = []; - - foreach ($ordered as $identifier => $paths) { - if (empty($paths['jsAbsPath'])) { - continue; - } - - try { - $content = @file_get_contents($paths['jsAbsPath']); - - if ($content === false) { - Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'path' => $paths['jsAbsPath'], - ]); - - continue; - } - - $segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction); - } catch (\Throwable $e) { - Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'error' => $e->getMessage(), - ]); - } - } - - return implode("\n;\n", $segments); + return $this->mergeBundle($type, 'js')['content']; } /** @@ -186,12 +171,265 @@ class ExtensionBundleService * @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열) */ public function buildCssBundle(string $type): string + { + return $this->mergeBundle($type, 'css')['content']; + } + + /** + * 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write). + * + * 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이 + * 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만 + * 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해 + * rebuild 를 즉시 반영한다. + * + * 프로덕션에서 **캐시 존재 확인이 빌드보다 먼저** 온다. 캐시 키는 인자만으로 + * 계산되므로 빌드가 필요 없는데, 빌드를 앞세우면 캐시가 있어도 요청마다 활성 확장을 + * 열거하고 산출물을 전부 읽는다. 응답은 정상 200 이라 그 반복은 타이밍 말고는 드러나지 + * 않고, 원본이 사라지는 순간에는 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 된다. + * + * @param string $type 'module' | 'plugin' + * @param string $kind 'js' | 'css' + * @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion) + * @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 캐시할 수 없으면 빈 문자열. + */ + public function getBundleFilePath(string $type, string $kind, int $version): string + { + $relativeName = $this->bundleFileName($type, $kind, $version); + + // 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다. + // `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서 + // `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가 + // 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면 + // 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙). + try { + $storage = $this->bundleStorage(); + + // 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영 + if (! app()->environment('production')) { + $content = $this->buildBundleContent($type, $kind); + + // 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단). + if ($content === '') { + return ''; + } + + return $this->writeAtomically($storage, $relativeName, $content, cache: false); + } + + // 프로덕션: 동일 version 캐시가 있으면 빌드 없이 그대로 사용 + if ($storage->exists('', $relativeName)) { + return $storage->getBasePath('').'/'.$relativeName; + } + + return $this->buildAndCacheOnce($storage, $type, $kind, $version, $relativeName); + } catch (\Throwable $e) { + Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [ + 'type' => $type, + 'kind' => $kind, + 'version' => $version, + 'error' => $e->getMessage(), + ]); + + return ''; + } + } + + /** + * 캐시 미스에서 한 번만 병합해 캐시 파일을 만들고 그 절대 경로를 반환합니다. + * + * 같은 (type, kind, version) 의 동시 요청은 잠금으로 하나에 수렴시킨다 — 버전 교체 + * 직후에는 캐시가 없는 상태로 요청이 몰리고, 각자 병합하면 그 비용이 워커 수만큼 곱해진다. + * 잠금은 **최적화**이므로 대기 초과·저장소 장애는 실패로 바꾸지 않고 각자 빌드로 폴백한다 + * (정적 게시 잠금 `ext-static.publish.{v}` 와 같은 저장소·같은 규율이며 키가 달라 자기 + * 교착이 없다). + * + * 병합 결과가 비어 있어도 선언한 산출물이 **전부 존재하면**(또는 선언이 0이면) 0바이트 + * 캐시 파일을 만든다. 그래야 정적 게시 대상이 되어 방문자가 웹서버에서 직접 받는다 — + * 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 둘이다 — + * 선언한 산출물이 **소실**된 경우(호출측의 503 판정을 그대로 유지한다)와 병합 단계에서 + * 확장을 **건너뛴** 경우(그 상태가 굳지 않도록 매 요청 재시도에 맡긴다). + * + * @param CoreStorageDriver $storage 번들 디스크 스토리지 + * @param string $type 'module' | 'plugin' + * @param string $kind 'js' | 'css' + * @param int $version 확장 캐시 버전 + * @param string $relativeName 캐시 파일명 + * @return string 캐시 파일의 절대 경로 (캐시하지 않았으면 빈 문자열) + */ + private function buildAndCacheOnce( + CoreStorageDriver $storage, + string $type, + string $kind, + int $version, + string $relativeName + ): string { + $lock = null; + $acquired = false; + + try { + $lock = Cache::lock(self::BUILD_LOCK_PREFIX."{$type}.{$kind}.{$version}", self::BUILD_LOCK_TTL_SECONDS); + $acquired = (bool) $lock->block(self::BUILD_LOCK_WAIT_SECONDS); + } catch (LockTimeoutException $e) { + // 대기 초과는 정상적인 경합이다 — 흔적만 남기고 각자 빌드한다. + Log::debug('확장 번들 빌드 잠금 대기 초과 — 잠금 없이 병합합니다', [ + 'type' => $type, + 'kind' => $kind, + 'version' => $version, + ]); + } catch (\Throwable $e) { + // 저장소가 잠금을 제공하지 못한다(드라이버 미지원, 캐시 디렉토리 권한 등). + // 번들 서빙 자체를 막지는 않으므로 사유만 남기고 계속한다. + Log::warning('확장 번들 빌드 잠금 획득 불가 — 잠금 없이 병합합니다', [ + 'type' => $type, + 'kind' => $kind, + 'version' => $version, + 'store' => config('cache.default'), + 'error' => $e->getMessage(), + ]); + } + + try { + // 기다리는 동안 다른 프로세스가 완성했을 수 있다 — 병합 전에 다시 본다. + if ($storage->exists('', $relativeName)) { + return $storage->getBasePath('').'/'.$relativeName; + } + + ['content' => $content, 'skipped' => $skipped] = $this->mergeBundle($type, $kind); + + // 건너뛴 확장이 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데 읽기·치환이 + // 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정된다. + // 캐시 없이 돌아가면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 순간 회복한다. + // 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다 — 이 통지가 유일한 흔적이다. + if ($skipped !== []) { + Log::error('확장 번들 캐시 보류 — 병합 단계에서 건너뛴 확장이 있어 캐시하지 않습니다', [ + 'type' => $type, + 'kind' => $kind, + 'version' => $version, + 'skipped' => $skipped, + ]); + + return ''; + } + + // 비었는데 선언한 산출물이 소실이면 캐시하지 않는다 — 배포 중 dist 가 잠깐 빈 + // 장애가 0바이트 캐시로 굳어 정상(빈 200)으로 위장되면 안 된다. + if ($content === '' && $this->findMissingDeclaredAssets($type, $kind) !== []) { + return ''; + } + + return $this->writeAtomically($storage, $relativeName, $content, cache: true); + } finally { + if ($acquired && $lock !== null) { + try { + $lock->release(); + } catch (\Throwable $e) { + // 해제 실패는 TTL 이 정리한다 — 서빙에는 영향이 없다. + } + } + } + } + + /** + * 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용). + * + * @param string $type 'module' | 'plugin' + * @param string $kind 'js' | 'css' + * @return string 병합 결과 (없으면 빈 문자열) + */ + public function buildBundleContent(string $type, string $kind): string + { + return $this->mergeBundle($type, $kind)['content']; + } + + /** + * 병합 결과와 함께 **건너뛴 확장**을 돌려줍니다 (캐시 판정용). + * + * 캐시할지는 결과 문자열만으로 판정할 수 없다 — 파일은 존재·판독 가능한데 읽기나 치환이 + * 실패해 건너뛴 확장은 결과에서 조용히 빠질 뿐이다. 그 상태가 캐시로 굳으면 버전 bump + * 전까지 그 확장 자산이 사라진 채 고정되므로, 캐시 경로는 건너뜀 여부를 함께 받는다. + * + * @param string $type 'module' | 'plugin' + * @param string $kind 'js' | 'css' + * @return array{content: string, skipped: list} 병합 결과와 건너뛴 확장 식별자 + */ + private function mergeBundle(string $type, string $kind): array + { + $merged = $kind === 'css' ? $this->mergeCss($type) : $this->mergeJs($type); + + return [ + 'content' => implode($kind === 'css' ? "\n" : "\n;\n", $merged['segments']), + 'skipped' => $merged['skipped'], + ]; + } + + /** + * JS 세그먼트를 priority 순으로 모읍니다. + * + * 확장별 fine-grained try/catch — 읽기 실패·처리 예외는 그 확장만 건너뛰고(`skipped` + * 에 기록) 나머지 병합을 지속한다. 한 확장의 실패가 번들 전체를 붕괴시키지 않는다. + * + * @param string $type 'module' | 'plugin' + * @return array{segments: list, skipped: list} 세그먼트와 건너뛴 확장 식별자 + */ + private function mergeJs(string $type): array + { + $ordered = $this->getOrderedGlobalAssetPaths($type); + $isProduction = app()->environment('production'); + $segments = []; + $skipped = []; + + foreach ($ordered as $identifier => $paths) { + if (empty($paths['jsAbsPath'])) { + continue; + } + + try { + $content = $this->readAssetSource($paths['jsAbsPath']); + + if ($content === false) { + Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'path' => $paths['jsAbsPath'], + ]); + $skipped[] = (string) $identifier; + + continue; + } + + $segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction); + } catch (\Throwable $e) { + Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'error' => $e->getMessage(), + ]); + $skipped[] = (string) $identifier; + } + } + + return ['segments' => $segments, 'skipped' => $skipped]; + } + + /** + * CSS 세그먼트를 priority 순으로 모읍니다. + * + * CSS 안의 상대 `url(...)`·`@import` 참조는 그 확장의 절대 자산 URL 로 치환한다 — + * 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋나기 때문이다. + * 치환은 개별 자산 서빙(ServesRewritableCssAssets)과 같은 규칙(AssetCssUrlRewriter)을 쓴다. + * + * @param string $type 'module' | 'plugin' + * @return array{segments: list, skipped: list} 세그먼트와 건너뛴 확장 식별자 + */ + private function mergeCss(string $type): array { $ordered = $this->getOrderedGlobalAssetPaths($type); $isProduction = app()->environment('production'); $typeSegment = $type === 'plugin' ? 'plugins' : 'modules'; $version = $this->getCurrentVersion(); $segments = []; + $skipped = []; foreach ($ordered as $identifier => $paths) { if (empty($paths['cssAbsPath'])) { @@ -199,7 +437,7 @@ class ExtensionBundleService } try { - $content = @file_get_contents($paths['cssAbsPath']); + $content = $this->readAssetSource($paths['cssAbsPath']); if ($content === false) { Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ @@ -207,6 +445,7 @@ class ExtensionBundleService 'identifier' => $identifier, 'path' => $paths['cssAbsPath'], ]); + $skipped[] = (string) $identifier; continue; } @@ -237,81 +476,26 @@ class ExtensionBundleService 'identifier' => $identifier, 'error' => $e->getMessage(), ]); + $skipped[] = (string) $identifier; } } - return implode("\n", $segments); + return ['segments' => $segments, 'skipped' => $skipped]; } /** - * 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write). + * 확장 자산 원본을 읽습니다. * - * 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이 - * 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만 - * 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해 - * rebuild 를 즉시 반영한다. + * 실패는 `false` 로 돌아오고 호출측이 그 확장을 건너뛴다. 별도 메서드인 이유는 + * "존재·판독 가능한데 읽기가 실패하는" 상태를 테스트가 재현할 수 있어야 하기 때문이다 — + * 그 상태가 캐시로 굳는 것이 이 서비스가 막아야 할 결함이다. * - * @param string $type 'module' | 'plugin' - * @param string $kind 'js' | 'css' - * @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion) - * @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 병합 결과가 빈 문자열이면 빈 문자열. + * @param string $path 절대 경로 + * @return string|false 파일 내용 (실패 시 false) */ - public function getBundleFilePath(string $type, string $kind, int $version): string + protected function readAssetSource(string $path): string|false { - $content = $kind === 'css' - ? $this->buildCssBundle($type) - : $this->buildJsBundle($type); - - // 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단). - if ($content === '') { - return ''; - } - - $relativeName = $this->bundleFileName($type, $kind, $version); - - // 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다. - // `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서 - // `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가 - // 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면 - // 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙). - try { - $storage = $this->bundleStorage(); - - // 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영 - if (! app()->environment('production')) { - return $this->writeAtomically($storage, $relativeName, $content, cache: false); - } - - // 프로덕션: 동일 version 캐시가 있으면 그대로 사용 - if ($storage->exists('', $relativeName)) { - return $storage->getBasePath('').'/'.$relativeName; - } - - return $this->writeAtomically($storage, $relativeName, $content, cache: true); - } catch (\Throwable $e) { - Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [ - 'type' => $type, - 'kind' => $kind, - 'version' => $version, - 'error' => $e->getMessage(), - ]); - - return ''; - } - } - - /** - * 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용). - * - * @param string $type 'module' | 'plugin' - * @param string $kind 'js' | 'css' - * @return string 병합 결과 (없으면 빈 문자열) - */ - public function buildBundleContent(string $type, string $kind): string - { - return $kind === 'css' - ? $this->buildCssBundle($type) - : $this->buildJsBundle($type); + return @file_get_contents($path); } /** diff --git a/config/core.php b/config/core.php index 53288a11..ef252a4c 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/database/migrations/2026_09_08_000001_modify_url_in_seo_cache_stats_table.php b/database/migrations/2026_09_08_000001_modify_url_in_seo_cache_stats_table.php new file mode 100644 index 00000000..959ac081 --- /dev/null +++ b/database/migrations/2026_09_08_000001_modify_url_in_seo_cache_stats_table.php @@ -0,0 +1,44 @@ +string('url', 768)->comment('요청 URL (경로 + 정규화 쿼리)')->change(); + }); + } + + /** + * url 컬럼을 원래 길이(255)로 되돌립니다. + * + * 되돌리기 전에 255자를 넘는 행을 지운다 — 엄격 모드에서는 잘리는 값이 하나라도 있으면 + * ALTER 자체가 실패한다. 통계 행은 파생 데이터라 손실이 복구 대상이 아니다. + * + * @return void + */ + public function down(): void + { + DB::table('seo_cache_stats')->whereRaw('CHAR_LENGTH(url) > 255')->delete(); + + Schema::table('seo_cache_stats', function (Blueprint $table) { + $table->string('url', 255)->comment('요청 URL')->change(); + }); + } +}; diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index 7011dd3a..67e62329 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`(오류가 아니다). 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청은 차감을 되돌린다 +- 저장 상한: 경로·언어당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수 +- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한). HIT 의 레이아웃명은 캐시 항목에 함께 저장된 값으로 귀속한다 ## 봇 감지 구조 @@ -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,57 @@ 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 당 분당 미스 렌더 수. 렌더러가 그릴 게 없다고 돌아온 요청(미라우트 404·SEO 비활성 화면)은 세지 않는다 | +| stats_records_per_minute | `G7_SEO_STATS_RECORDS_PER_MINUTE` | 300 | IP 당 분당 통계 기록 수 | + +상한을 넘긴 요청은 **차단되지 않고** 일반 SPA 응답을 받습니다. 봇에게 오류를 돌려주면 그 URL 이 색인에서 빠지므로 차단이 곧 손해입니다. 판정 결과는 응답 헤더 `X-SEO-Cache`(`HIT`/`MISS`/`BYPASS`)로 드러나며, 그것이 운영 진단의 통로입니다. + +이미 캐시에 있는 키의 **갱신**은 저장 규모를 늘리지 않으므로 상한과 무관하게 수행됩니다. + +### IP 단위 상한은 프록시 신뢰 설정에 의존합니다 + +`render_misses_per_minute` 와 `stats_records_per_minute` 는 요청 IP 를 기준으로 셉니다. TLS 가 앞단에서 종단되는 구성(리버스 프록시, CDN, 로드밸런서)에서 신뢰할 프록시를 지정하지 않으면 모든 요청의 IP 가 프록시 IP 하나로 보이므로, 그 분당 예산을 사이트 전체 봇 트래픽이 함께 쓰게 됩니다. 예산을 넘긴 시점부터 정상 검색엔진 봇도 SEO HTML 대신 SPA 를 받아 색인 품질이 떨어지고, 응답은 200 이라 서버 로그에 흔적이 남지 않습니다. + +프록시 뒤에 두는 설치본은 `TRUSTED_PROXIES` 를 반드시 지정합니다 — 설정 방법과 진단은 [리버스 프록시 환경](reverse-proxy.md)에 있습니다. 진단이 어려우면 응답 헤더 `X-SEO-Cache` 가 `BYPASS` 로 몰리는지를 먼저 봅니다. + +### 경로당 변종 상한은 언어별로 셉니다 + +캐시 인덱스 항목은 URL 과 로케일의 조합마다 하나입니다. 경로만 보고 변종을 합산하면 언어 수만큼 실효 상한이 줄어, 언어가 셋인 사이트는 한 경로에 언어당 16개만 저장되고 목록 17페이지부터는 봇이 올 때마다 새로 그리되 저장하지 않게 됩니다. 그래서 상한은 경로와 언어의 조합 단위로 판정합니다. + +### 그릴 게 없는 요청은 렌더 예산을 쓰지 않습니다 + +렌더 예산은 새로 그리기 직전에 1을 차감합니다. 그런데 미라우트 주소(404)나 SEO 를 끈 화면은 렌더러가 "그릴 게 없음"으로 바로 돌아오고 캐시에도 남지 않아, 올 때마다 다시 차감됩니다. 봇은 예전에 있던 죽은 주소를 오래 다시 긁으므로 그 요청까지 세면 정상 페이지의 예산이 죽은 주소에 소진됩니다. 렌더러가 null 을 돌려주면 그 차감을 되돌리고, 렌더 도중 예외는 비용을 이미 치른 것이라 되돌리지 않습니다. + +### 캐시 항목은 레이아웃명을 함께 담습니다 + +페이지 캐시 값은 HTML 과 레이아웃명의 쌍입니다. 캐시 적중(HIT) 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없으므로, 통계를 화면별로 귀속하려면 캐시 항목이 그것을 알아야 합니다. `SeoCacheManagerInterface::get()` 은 종전대로 HTML 만 돌려주고, 코어 구현 `SeoCacheManager::getEntry()` 가 쌍 전체를 돌려줍니다. 미들웨어는 코어 구현일 때만 쌍을 읽고, 다른 구현이 바인딩된 경우에는 레이아웃명 없이 기록합니다. 이전 버전이 문자열로만 저장한 항목은 레이아웃명 없이 그대로 읽힙니다 — 배포 직후 살아 있는 캐시를 버리지 않습니다. + +통계 테이블(`seo_cache_stats`)의 `url` 컬럼은 캐시 키 URL 상한(경로 + 정규화 쿼리 최대 512바이트)을 담도록 768자입니다. 컬럼이 짧으면 긴 주소의 기록이 엄격 모드에서 실패하고, 통계 서비스는 그 예외를 삼키므로 흔적이 남지 않습니다. + +### 저장 상한이 세는 것은 살아 있는 항목입니다 + +캐시 인덱스 항목은 페이지보다 오래 삽니다(기본 30일 vs 2시간). 그래서 저장 상한에 닿으면 **먼저 만료된 항목을 인덱스에서 걷어내고 다시 판정**합니다. 그러지 않으면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 상한에 닿은 경로는 실제 캐시가 비어 있어도 다시는 저장되지 않습니다. + +이 정리는 인덱스 전체를 훑으므로 **최소 60초 간격**으로만 수행합니다. 살아 있는 항목만으로 상한에 닿은 경우에는 정리해도 자리가 나지 않는데, 그 상태에서 저장 시도마다 훑으면 비용만 반복되기 때문입니다. 따라서 항목이 만료된 뒤 저장이 다시 열리기까지 최대 그 간격만큼 늦어질 수 있습니다. + +### 상한은 봇 요청만이 아니라 모든 저장 경로에 걸립니다 + +저장 상한은 `SeoCacheManagerInterface` 의 저장 메서드(`put`·`putWithLayout`)에 공통으로 걸립니다. 봇 요청의 미스 렌더뿐 아니라, 게시글·상품을 저장할 때 도는 단건 재생성(`SeoCacheRegenerator`)도 같은 판정을 거칩니다 — 한쪽만 상한 밖이면 그쪽이 증식 우회로가 되기 때문입니다. + +상한에 닿아 저장하지 않은 경우 재생성 호출은 **예외를 던지지 않고** 흔적을 `Log::debug` 로만 남깁니다. 콘텐츠 페이지는 서로 다른 경로라 경로당 변종 상한(`max_variants_per_path`)과는 무관하고, 전체 항목 상한(`max_entries`)은 만료 항목을 걷어낸 뒤 판정하므로 실제로는 페이지 TTL 안에 살아 있는 항목만 셉니다. 그래도 대규모 사이트에서 이 상한에 닿는다면 저장이 조용히 넘어가므로, `seo:stats` 의 미스 비율이 지속적으로 오르는지를 신호로 삼고 `G7_SEO_CACHE_MAX_ENTRIES` 를 올립니다. + ## SEO Config 동적 확장 시스템 SEO 엔진은 컴포넌트 지식을 갖지 않습니다. 모든 컴포넌트→HTML 매핑, 렌더 모드, 셀프 클로징 태그, 외부 스타일시트는 `seo-config.json`으로 제공됩니다. @@ -1653,9 +1708,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..08f4ffb1 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 판정을 그대로 유지해야 한다), 병합 단계에서 확장을 **건너뛴** 경우(파일은 있는데 읽기·치환이 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정되므로, 종전처럼 매 요청 재시도에 맡긴다), 그리고 디스크 쓰기 자체가 실패한 경우다. 두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`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/Database/ModifyUrlInSeoCacheStatsMigrationTest.php b/tests/Feature/Database/ModifyUrlInSeoCacheStatsMigrationTest.php new file mode 100644 index 00000000..b9c1016c --- /dev/null +++ b/tests/Feature/Database/ModifyUrlInSeoCacheStatsMigrationTest.php @@ -0,0 +1,97 @@ +assertSame(768, $this->urlLength()); + + Artisan::call('migrate:rollback', ['--path' => [self::MIGRATION], '--force' => true]); + $this->assertSame(255, $this->urlLength()); + + Artisan::call('migrate', ['--path' => [self::MIGRATION], '--force' => true]); + $this->assertSame(768, $this->urlLength()); + + $this->assertContains( + 'idx_seo_cache_stats_url', + array_column(Schema::getIndexes('seo_cache_stats'), 'name'), + 'url 인덱스는 컬럼 변경 뒤에도 남아야 한다' + ); + } + + /** + * down 은 255자를 넘는 행을 먼저 지운다 — 엄격 모드에서는 잘리는 값이 하나라도 있으면 + * ALTER 자체가 실패해 되돌리기가 막힌다. + * + * @scenario bot_state=bot, cache_state=hit, ip_budget=within, query_shape=normal, store_state=under_caps + * + * @effects stats_url_column_fits_normalized_cache_url + */ + public function test_down_removes_rows_that_would_not_fit_before_shrinking(): void + { + $long = '/shop?'.str_repeat('a=0123456789&', 30); + + $this->assertGreaterThan(255, strlen($long)); + + DB::table('seo_cache_stats')->insert([ + ['url' => $long, 'locale' => 'ko', 'type' => 'hit'], + ['url' => '/short', 'locale' => 'ko', 'type' => 'hit'], + ]); + + try { + Artisan::call('migrate:rollback', ['--path' => [self::MIGRATION], '--force' => true]); + + $this->assertSame(255, $this->urlLength()); + $this->assertDatabaseMissing('seo_cache_stats', ['url' => $long]); + $this->assertDatabaseHas('seo_cache_stats', ['url' => '/short']); + } finally { + // DDL 이 테스트 트랜잭션을 암묵 커밋하므로 남는 행은 직접 치운다. + Artisan::call('migrate', ['--path' => [self::MIGRATION], '--force' => true]); + DB::table('seo_cache_stats')->where('url', '/short')->delete(); + } + } + + /** + * url 컬럼의 선언 길이를 읽습니다. + * + * @return int 문자 수 (varchar(N) 의 N) + */ + private function urlLength(): int + { + foreach (Schema::getColumns('seo_cache_stats') as $column) { + if ($column['name'] === 'url') { + preg_match('/\((\d+)\)/', (string) $column['type'], $matches); + + return (int) ($matches[1] ?? 0); + } + } + + $this->fail('seo_cache_stats.url 컬럼이 없다'); + } +} diff --git a/tests/Feature/Extension/ExtensionAssetCssRewriteContractTest.php b/tests/Feature/Extension/ExtensionAssetCssRewriteContractTest.php index d5c06a94..cf5ba967 100644 --- a/tests/Feature/Extension/ExtensionAssetCssRewriteContractTest.php +++ b/tests/Feature/Extension/ExtensionAssetCssRewriteContractTest.php @@ -111,12 +111,13 @@ class ExtensionAssetCssRewriteContractTest extends TestCase '병합 CSS 번들 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다.' ); - $source = $this->methodSource(ExtensionBundleService::class, 'buildCssBundle'); + // 병합 루프는 mergeCss() 에 있다 — buildCssBundle()/buildBundleContent()/캐시 경로가 모두 이 루프를 거친다. + $source = $this->methodSource(ExtensionBundleService::class, 'mergeCss'); $this->assertStringContainsString( self::REWRITER, $source, - 'ExtensionBundleService::buildCssBundle() 이 '.self::REWRITER.' 를 거치지 않습니다. ' + 'ExtensionBundleService::mergeCss() 이 '.self::REWRITER.' 를 거치지 않습니다. ' .'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.' ); } diff --git a/tests/Feature/Seo/SeoMiddlewareTest.php b/tests/Feature/Seo/SeoMiddlewareTest.php index 649098d6..dc3799d1 100644 --- a/tests/Feature/Seo/SeoMiddlewareTest.php +++ b/tests/Feature/Seo/SeoMiddlewareTest.php @@ -5,8 +5,11 @@ namespace Tests\Feature\Seo; use App\Seo\BotDetector; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\Contracts\SeoRendererInterface; +use App\Seo\SeoCacheManager; +use App\Seo\SeoCacheStatsService; use App\Seo\SeoMiddleware; use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Tests\TestCase; /** @@ -24,6 +27,8 @@ class SeoMiddlewareTest extends TestCase private SeoRendererInterface $renderer; + private SeoCacheStatsService $statsService; + /** * 테스트 환경 설정 */ @@ -34,12 +39,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 +414,7 @@ class SeoMiddlewareTest extends TestCase app(BotDetector::class), $this->cacheManager, $this->renderer, + $this->statsService, ); $response = $middleware->handle($request, $this->spaNext()); @@ -435,6 +447,7 @@ class SeoMiddlewareTest extends TestCase app(BotDetector::class), $this->cacheManager, $this->renderer, + $this->statsService, ); $response = $middleware->handle($request, $this->spaNext()); @@ -469,6 +482,7 @@ class SeoMiddlewareTest extends TestCase app(BotDetector::class), $this->cacheManager, $this->renderer, + $this->statsService, ); $response = $middleware->handle($request, $this->spaNext()); @@ -501,4 +515,257 @@ 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()); + } + + /** + * 캐시 적중(HIT)은 렌더러를 거치지 않으므로 레이아웃명을 요청 속성에서 얻을 수 없다 — + * 캐시 항목에 함께 저장된 레이아웃명으로 통계에 귀속한다. 없으면 화면별 표에서 모든 + * 화면의 적중이 0 이 되고 'N/A' 행에만 쌓인다. + * + * @effects cache_hit_carries_layout_name_from_cache_entry + */ + public function test_cache_hit_records_stat_hit_with_layout_from_cache_entry(): void + { + config(['g7_settings.core.seo.bot_detection_enabled' => true]); + + $cacheManager = $this->createMock(SeoCacheManager::class); + $cacheManager->method('getEntry')->willReturn(['html' => 'cached', 'layout' => 'shop/show']); + + $middleware = new SeoMiddleware($this->botDetector, $cacheManager, $this->renderer, $this->statsService); + + $this->botDetector->method('isBot')->willReturn(true); + $this->statsService->expects($this->once()) + ->method('recordHit') + ->with('/products', config('app.locale'), 'shop/show'); + + $response = $middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext()); + + $this->assertSame('cached', $response->getContent()); + $this->assertSame('HIT', $response->headers->get('X-SEO-Cache')); + } + + /** + * 렌더러가 "그릴 게 없음"(null)을 돌려주면 방금 뺀 렌더 예산을 되돌린다 — 미라우트 404 나 + * SEO 비활성 화면은 캐시에 남지 않아 올 때마다 다시 예산을 쓰므로, 죽은 주소 재크롤이 + * 정상 페이지의 예산을 태운다. + * + * @effects null_render_refunds_render_budget + */ + public function test_null_render_refunds_render_budget(): void + { + config(['g7_settings.core.seo.bot_detection_enabled' => true]); + + $this->botDetector->method('isBot')->willReturn(true); + $this->cacheManager->method('get')->willReturn(null); + $this->renderer->method('render')->willReturn(null); + + $this->middleware->handle($this->createRequest('/gone', 'Googlebot/2.1'), $this->spaNext()); + + $this->assertSame(0, (int) RateLimiter::attempts('seo-render:127.0.0.1')); + } + + /** + * 렌더 중 예외는 비용을 이미 치른 것이므로 예산을 되돌리지 않는다 (회귀 가드). + * + * @effects null_render_refunds_render_budget + */ + public function test_render_exception_keeps_render_budget_charged(): void + { + config(['g7_settings.core.seo.bot_detection_enabled' => true]); + + $this->botDetector->method('isBot')->willReturn(true); + $this->cacheManager->method('get')->willReturn(null); + $this->renderer->method('render')->willThrowException(new \RuntimeException('boom')); + + $this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext()); + + $this->assertSame(1, (int) RateLimiter::attempts('seo-render:127.0.0.1')); + } } 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/Cache/Migration/SeoCacheTest.php b/tests/Unit/Cache/Migration/SeoCacheTest.php index 4bb65ec0..3b25dd49 100644 --- a/tests/Unit/Cache/Migration/SeoCacheTest.php +++ b/tests/Unit/Cache/Migration/SeoCacheTest.php @@ -60,7 +60,8 @@ class SeoCacheTest extends TestCase $this->manager->put('/board/notice/123', 'ko', 'Hello'); $expectedKey = 'seo.page.'.md5('/board/notice/123|ko'); - $this->assertSame('Hello', $this->cache->get($expectedKey)); + // 페이지는 레이아웃명과 함께 저장된다 — 적중 경로가 통계를 화면별로 귀속할 출처다. + $this->assertSame('Hello', $this->cache->get($expectedKey)['html'] ?? null); $this->assertSame( 'g7:core:'.$expectedKey, $this->cache->resolveKey($expectedKey) diff --git a/tests/Unit/Seo/SeoCacheBoundsTest.php b/tests/Unit/Seo/SeoCacheBoundsTest.php new file mode 100644 index 00000000..9b33d533 --- /dev/null +++ b/tests/Unit/Seo/SeoCacheBoundsTest.php @@ -0,0 +1,188 @@ +assertSame( + ['page' => '2'], + SeoCacheBounds::normalizeQuery(['locale' => 'en', '_escaped_fragment_' => '', 'page' => '2']) + ); + } + + /** + * 같은 조합은 순서가 달라도 같은 키가 되어야 한다. + * + * @effects system_query_params_excluded_from_key + */ + public function test_normalize_query_sorts_keys(): void + { + $this->assertSame( + ['a' => '1', 'b' => '2', 'c' => '3'], + SeoCacheBounds::normalizeQuery(['c' => '3', 'a' => '1', 'b' => '2']) + ); + } + + /** + * 파라미터 수 상한을 넘기면 캐시 불가(null)다. + * + * @effects oversized_query_bypasses_cache_and_render + */ + public function test_normalize_query_returns_null_when_too_many_parameters(): void + { + config(['core.seo_cache_limits.max_query_params' => 10]); + + $query = []; + for ($i = 0; $i < 11; $i++) { + $query['p'.$i] = '1'; + } + + $this->assertNull(SeoCacheBounds::normalizeQuery($query)); + + array_pop($query); + $this->assertIsArray(SeoCacheBounds::normalizeQuery($query), '상한과 같은 개수는 허용된다'); + } + + /** + * 정규화된 쿼리 문자열 길이 상한을 넘기면 캐시 불가다. + * + * @effects oversized_query_bypasses_cache_and_render + */ + public function test_normalize_query_returns_null_when_query_string_too_long(): void + { + config(['core.seo_cache_limits.max_query_length' => 32]); + + $this->assertNull(SeoCacheBounds::normalizeQuery(['q' => str_repeat('x', 64)])); + $this->assertIsArray(SeoCacheBounds::normalizeQuery(['q' => 'x'])); + } + + /** + * 시스템 파라미터는 길이·개수 상한 계산에서도 빠진다. + * + * @effects system_query_params_excluded_from_key + */ + public function test_normalize_query_excludes_system_parameters_from_limits(): void + { + config(['core.seo_cache_limits.max_query_params' => 1]); + + $this->assertSame( + ['page' => '2'], + SeoCacheBounds::normalizeQuery(['locale' => 'en', '_escaped_fragment_' => '', 'page' => '2']) + ); + } + + /** + * 같은 경로의 변종 수가 상한에 닿으면 새 URL 을 저장하지 않는다. + * + * @effects store_skips_write_at_path_variant_cap + */ + public function test_can_store_rejects_new_variant_at_path_cap(): void + { + config([ + 'core.seo_cache_limits.max_variants_per_path' => 3, + 'core.seo_cache_limits.max_entries' => 20000, + ]); + + $index = [ + 'k1' => ['url' => '/shop?page=1', 'locale' => 'ko'], + 'k2' => ['url' => '/shop?page=2', 'locale' => 'ko'], + 'k3' => ['url' => '/shop?page=3', 'locale' => 'ko'], + ]; + + $this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=4', 'ko')); + // 다른 경로는 자기 예산을 따로 쓴다 + $this->assertTrue(SeoCacheBounds::canStore($index, '/board?page=1', 'ko')); + } + + /** + * 전체 항목 수 상한에 닿으면 어떤 경로도 저장하지 않는다. + * + * @effects store_skips_write_at_global_cap + */ + public function test_can_store_rejects_when_global_cap_reached(): void + { + config([ + 'core.seo_cache_limits.max_entries' => 2, + 'core.seo_cache_limits.max_variants_per_path' => 50, + ]); + + $index = [ + 'k1' => ['url' => '/a', 'locale' => 'ko'], + 'k2' => ['url' => '/b', 'locale' => 'ko'], + ]; + + $this->assertFalse(SeoCacheBounds::canStore($index, '/c', 'ko')); + } + + /** + * 렌더 예산은 IP 단위로 소진된다. + * + * @effects bot_miss_over_limit_gets_spa_bypass + */ + public function test_render_budget_is_per_ip(): void + { + config(['core.seo_cache_limits.render_misses_per_minute' => 2]); + + $this->assertTrue(SeoCacheBounds::renderAllowed('10.0.0.1')); + + SeoCacheBounds::recordRender('10.0.0.1'); + SeoCacheBounds::recordRender('10.0.0.1'); + + $this->assertFalse(SeoCacheBounds::renderAllowed('10.0.0.1')); + $this->assertTrue(SeoCacheBounds::renderAllowed('10.0.0.2'), '다른 IP 는 자기 예산을 쓴다'); + } + + /** + * 통계 기록 예산도 IP 단위다 — 통계 테이블이 새 증식 축이 되지 않아야 한다. + * + * @effects stats_recording_capped_per_ip + */ + public function test_stats_budget_is_per_ip(): void + { + config(['core.seo_cache_limits.stats_records_per_minute' => 1]); + + $this->assertTrue(SeoCacheBounds::statsAllowed('10.0.0.3')); + + SeoCacheBounds::recordStat('10.0.0.3'); + + $this->assertFalse(SeoCacheBounds::statsAllowed('10.0.0.3')); + } + + /** + * 경로당 변종 상한은 언어별로 따로 센다 — 인덱스 항목은 url|locale 별이므로 경로만 보고 + * 합산하면 언어 수만큼 실효 상한이 줄어든다. + * + * @effects store_counts_path_variants_per_locale + */ + public function test_can_store_counts_path_variants_per_locale(): void + { + config([ + 'core.seo_cache_limits.max_variants_per_path' => 2, + 'core.seo_cache_limits.max_entries' => 20000, + ]); + + $index = [ + 'k1' => ['url' => '/shop?page=1', 'locale' => 'ko'], + 'k2' => ['url' => '/shop?page=2', 'locale' => 'ko'], + 'k3' => ['url' => '/shop?page=1', 'locale' => 'en'], + ]; + + $this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=3', 'ko')); + $this->assertTrue(SeoCacheBounds::canStore($index, '/shop?page=2', 'en'), '다른 언어는 자기 예산을 따로 쓴다'); + } +} diff --git a/tests/Unit/Seo/SeoCacheManagerTest.php b/tests/Unit/Seo/SeoCacheManagerTest.php index 93c85526..e62bb1f4 100644 --- a/tests/Unit/Seo/SeoCacheManagerTest.php +++ b/tests/Unit/Seo/SeoCacheManagerTest.php @@ -210,4 +210,276 @@ 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')); + } + + /** + * 페이지와 함께 저장한 레이아웃명을 꺼낼 수 있다 — 캐시 적중(HIT)은 렌더러를 거치지 + * 않으므로 요청 속성에 레이아웃명이 없고, 캐시 항목이 그것을 알아야 통계가 화면별로 귀속된다. + * + * @effects cache_hit_carries_layout_name_from_cache_entry + */ + public function test_get_entry_returns_layout_stored_with_page(): void + { + $this->cacheManager->putWithLayout('/products/1', 'ko', 'p', 'shop/show'); + + $this->assertSame( + ['html' => 'p', 'layout' => 'shop/show'], + $this->cacheManager->getEntry('/products/1', 'ko') + ); + $this->assertSame('p', $this->cacheManager->get('/products/1', 'ko')); + $this->assertNull($this->cacheManager->getEntry('/nope', 'ko')); + } + + /** + * 이전 버전이 문자열로만 저장한 항목도 그대로 읽힌다 — 배포 직후 살아 있는 캐시를 버리지 않는다. + * + * @effects cache_hit_carries_layout_name_from_cache_entry + */ + public function test_get_reads_legacy_string_entries(): void + { + $driver = new CoreCacheDriver('array'); + $manager = new SeoCacheManager($driver); + + $driver->put('seo.page.'.md5('/legacy|ko'), 'legacy', 3600); + + $this->assertSame('legacy', $manager->get('/legacy', 'ko')); + $this->assertSame(['html' => 'legacy', 'layout' => null], $manager->getEntry('/legacy', 'ko')); + } + + /** + * 경로당 변종 상한은 언어별로 따로 센다 — 인덱스 항목은 url|locale 별인데 경로만 보고 + * 합산하면 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 캐시에서 빠진다. + * + * @effects store_counts_path_variants_per_locale + */ + public function test_path_variant_cap_is_counted_per_locale(): void + { + config([ + 'core.seo_cache_limits.max_variants_per_path' => 3, + 'core.seo_cache_limits.max_entries' => 20000, + ]); + + for ($i = 1; $i <= 3; $i++) { + $this->cacheManager->putWithLayout('/shop?page='.$i, 'ko', 'ko'.$i.'', 'shop'); + } + + // ko 는 상한 도달, en 은 자기 예산을 따로 쓴다 + $this->cacheManager->putWithLayout('/shop?page=4', 'ko', 'ko4', 'shop'); + $this->cacheManager->putWithLayout('/shop?page=1', 'en', 'en1', 'shop'); + + $this->assertNull($this->cacheManager->get('/shop?page=4', 'ko')); + $this->assertSame('en1', $this->cacheManager->get('/shop?page=1', 'en')); + } } diff --git a/tests/Unit/Seo/SeoCacheStatsServiceTest.php b/tests/Unit/Seo/SeoCacheStatsServiceTest.php index 4acfb753..068fb566 100644 --- a/tests/Unit/Seo/SeoCacheStatsServiceTest.php +++ b/tests/Unit/Seo/SeoCacheStatsServiceTest.php @@ -281,4 +281,22 @@ class SeoCacheStatsServiceTest extends TestCase $this->assertDatabaseHas('seo_cache_stats', ['url' => '/recent/1']); $this->assertDatabaseHas('seo_cache_stats', ['url' => '/today/1']); } + + /** + * 통계의 url 컬럼은 캐시 키 URL(경로 + 정규화 쿼리 최대 512바이트)을 그대로 담을 수 있어야 + * 한다 — 짧으면 긴 주소의 기록이 조용히 실패한다(예외는 서비스가 삼키고 warning 만 남긴다). + * + * @effects stats_url_column_fits_normalized_cache_url + */ + public function test_record_persists_url_as_long_as_the_cache_url_bound(): void + { + $url = '/shop/products?'.str_repeat('filters[a]=0123456789&', 24).'page=2'; + + $this->assertGreaterThan(255, strlen($url)); + $this->assertLessThanOrEqual(768, strlen($url)); + + $this->statsService->recordHit($url, 'ko', 'shop/index'); + + $this->assertDatabaseHas('seo_cache_stats', ['url' => $url, 'type' => 'hit']); + } } 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..740ca680 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; @@ -128,7 +131,8 @@ class ExtensionBundleServiceTest extends TestCase int $priority, array $assets, string $strategy = 'global', - ?array $declaredPaths = null + ?array $declaredPaths = null, + ?array $builtPaths = null ): object { $ext = Mockery::mock(); $ext->shouldReceive('hasAssets')->andReturn($assets !== []); @@ -139,8 +143,10 @@ class ExtensionBundleServiceTest extends TestCase 'dependencies' => [], ]); $ext->shouldReceive('getAssets')->andReturn($assets); + // 실제 확장은 존재하는 산출물만 built 경로로 돌려준다(getBuiltAssetPaths 가 file_exists 로 거른다). + // 기본값은 "산출물 없음" 상태를 흉내 내는 부재 경로다 — 존재하는 산출물을 흉내 내려면 $builtPaths 로 지정한다. $ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn( - array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets) + $builtPaths ?? array_map(fn () => $this->fixtureDir.'/missing-'.$identifier.'.out', $assets) ); $ext->shouldReceive('getBuiltAssetPaths')->andReturn( array_map(fn () => 'dist/css/module.css', $assets) @@ -437,8 +443,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 +455,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 +720,310 @@ 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], + ['css' => $cssPath] + ), + ]; + }); + + $svc = $this->service(); + + $path = $svc->getBundleFilePath('module', 'css', 424247); + + $this->assertNotSame('', $path); + $this->assertFileExists($path); + $this->assertSame(0, filesize($path)); + + $enumerationsAfterFirst = $enumerations; + $this->assertSame($path, $svc->getBundleFilePath('module', 'css', 424247)); + $this->assertSame($enumerationsAfterFirst, $enumerations, '0바이트 캐시도 적중하면 열거하지 않는다'); + } + + /** + * 선언 산출물이 **소실**이면 캐시하지 않는다 — 503 계약을 보존한다. + * + * 0바이트 캐시가 소실 상태까지 가리면 배포 중 dist 가 빈 장애가 정상(빈 200)으로 + * 위장된다. + * + * @effects prod_empty_result_with_missing_artifact_is_not_cached + */ + public function test_prod_does_not_cache_when_declared_artifact_missing(): void + { + $this->app['env'] = 'production'; + app()->detectEnvironment(fn () => 'production'); + + $absent = $this->fixtureDir.'/absent-prod.css'; + @unlink($absent); + + $this->moduleManager->shouldReceive('getActiveModules')->andReturn([ + 'ext-gone' => $this->fakeExtensionWithAssets( + 'ext-gone', + 100, + ['css' => ['output' => 'dist/css/module.css']], + 'global', + ['css' => $absent] + ), + ]); + + $path = $this->service()->getBundleFilePath('module', 'css', 424248); + + $this->assertSame('', $path); + $this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424248.css')); + } + + /** + * 선언한 확장이 **하나도 없어도** 0바이트 캐시를 만든다(정적 게시 가능). + * + * @effects prod_empty_result_with_present_artifacts_is_cached_as_zero_byte_file + */ + public function test_prod_caches_zero_byte_bundle_when_nothing_declared(): void + { + $this->app['env'] = 'production'; + app()->detectEnvironment(fn () => 'production'); + + $this->moduleManager->shouldReceive('getActiveModules')->andReturn([]); + + $path = $this->service()->getBundleFilePath('module', 'css', 424249); + + $this->assertNotSame('', $path); + $this->assertFileExists($path); + $this->assertSame(0, filesize($path)); + } + + /** + * 병합 단계에서 건너뛴 확장이 하나라도 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데 + * 읽기·치환이 실패한 상태가 0바이트(또는 일부 빠진) 캐시로 굳으면 버전 bump 전까지 그 + * 확장 스타일이 사라진 채 고정된다. 종전처럼 매 요청 재시도해 원인이 사라지면 회복한다. + * + * @effects prod_build_with_skipped_extension_is_not_cached + */ + public function test_prod_does_not_cache_when_an_extension_was_skipped_during_merge(): void + { + $this->app['env'] = 'production'; + app()->detectEnvironment(fn () => 'production'); + + $cssPath = $this->writeFixture('unreadable.css', '.a{color:red}'); + + $this->moduleManager->shouldReceive('getActiveModules')->andReturn([ + 'ext-unreadable' => $this->fakeExtension('ext-unreadable', 100, null, $cssPath), + ]); + + $svc = new class($this->moduleManager, $this->pluginManager) extends ExtensionBundleService + { + public string $failOn = ''; + + protected function readAssetSource(string $path): string|false + { + return $path === $this->failOn ? false : parent::readAssetSource($path); + } + }; + $svc->failOn = $cssPath; + + $this->assertSame('', $svc->getBundleFilePath('module', 'css', 424250)); + $this->assertFileDoesNotExist(storage_path('app/ext-bundles/module.424250.css')); + + // 원인이 사라지면 다음 요청이 정상 캐시한다 (매 요청 재시도) + $svc->failOn = ''; + $path = $svc->getBundleFilePath('module', 'css', 424250); + + $this->assertNotSame('', $path); + $this->assertStringEqualsFile($path, '.a{color:red}'); + } } diff --git a/tests/scenarios/extension-bundle-loading.yaml b/tests/scenarios/extension-bundle-loading.yaml index d922321e..56dd260f 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,13 @@ 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 + - prod_build_with_skipped_extension_is_not_cached + - zero_byte_bundle_is_statically_published test_files: - tests/Unit/Services/ExtensionBundleServiceTest.php @@ -72,6 +78,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..f42f6896 --- /dev/null +++ b/tests/scenarios/seo-bot-cache-bounds.yaml @@ -0,0 +1,56 @@ +# audit:allow test-scenario-coverage[cross_product] reason: 입력 axis 의 완전 cross-product(5축)는 조합 폭발이라 대표 조합만 마킹한다. effects 축은 면제하지 않는다 — 모든 effect 가 테스트 마킹으로 도달해야 한다. + +feature: SEO 봇 캐시 상한 (키 정규화 · 렌더 예산 · 저장 규모 · 통계 기록) + +description: | + 봇 판정은 User-Agent 문자열뿐이라 누구나 위장할 수 있는데, 캐시 키가 경로 + 전체 쿼리였다. + 물음표 뒤 값만 바꾸면 매 요청이 미스가 되고, 미스 1건은 레이아웃 병합 · 표현식 평가 · + 자기 API 루프백 HTTP 호출을 유발하며 그 결과가 무제한으로 저장됐다. 요청 하나가 워커 + 여러 개를 묶고 캐시 저장소를 계속 키우는 통로였고, 통계 기록 호출처가 없어 그 진행이 + 운영자 화면에 전혀 나타나지 않았다. + + 핵심 동작: + - SeoCacheBounds 가 상한 판정의 단일 출처 (config/core.php seo_cache_limits + env) + - 키 정규화: locale · _escaped_fragment_ 제거 + ksort, 개수·길이 상한 초과 시 캐시 불가 + - 렌더 예산: IP 당 분당 미스 렌더 상한, 초과분은 SPA + X-SEO-Cache: BYPASS (429 아님) + - 저장 규모: 경로당 변종 수 · 전체 항목 수 상한, 기존 키 갱신은 상한과 무관 + - 통계: 미들웨어가 HIT/MISS 를 기록하되 IP 당 분당 상한 안에서만 + - 캐시 항목은 HTML + 레이아웃명 — HIT 통계가 화면별로 귀속된다 (이전 문자열 항목 호환) + - 경로당 변종 상한은 언어별로 센다 (인덱스 항목이 url|locale 별이므로) + - 렌더러가 null 을 돌려준 요청은 렌더 예산을 되돌린다 (예외는 되돌리지 않는다) + - 통계 url 컬럼(768)은 캐시 키 URL 상한을 담는다 — 마이그레이션 왕복 안전 + +axes: + bot_state: [bot, browser] + cache_state: [hit, miss] + ip_budget: [within, exhausted] + query_shape: [none, system_only, normal, oversized] + store_state: [under_caps, path_cap, global_cap] + +exclusions: + - { bot_state: browser, cache_state: hit, reason: "비봇 요청은 SEO 파이프라인에 들어가지 않아 캐시를 보지 않는다" } + - { cache_state: hit, store_state: path_cap, reason: "저장 상한은 미스 경로에서만 판정된다" } + +effects: + - bot_miss_over_limit_gets_spa_bypass + - bot_hit_served_regardless_of_limit + - system_query_params_excluded_from_key + - oversized_query_bypasses_cache_and_render + - cache_hit_and_miss_are_recorded_in_stats + - stats_recording_capped_per_ip + - store_skips_write_at_path_variant_cap + - store_skips_write_at_global_cap + - expired_index_entries_are_pruned_before_cap_verdict + - put_and_put_with_layout_share_the_storage_cap + - index_prune_is_throttled_to_one_scan_per_interval + - cache_hit_carries_layout_name_from_cache_entry + - store_counts_path_variants_per_locale + - null_render_refunds_render_budget + - stats_url_column_fits_normalized_cache_url + +test_files: + - tests/Unit/Seo/SeoCacheBoundsTest.php + - tests/Unit/Seo/SeoCacheManagerTest.php + - tests/Feature/Seo/SeoMiddlewareTest.php + - tests/Unit/Seo/SeoCacheStatsServiceTest.php + - tests/Feature/Database/ModifyUrlInSeoCacheStatsMigrationTest.php