diff --git a/AGENTS.md b/AGENTS.md index 404e2291..6afe14c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1434,7 +1434,7 @@ lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱 - 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 판정 보존)과 디스크 쓰기 실패뿐이다. +- 병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시 파일을 만들어 정적 게시까지 간다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해 방문자의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)·병합 단계에서 건너뛴 확장이 있는 결과(굳지 않도록 매 요청 재시도)·디스크 쓰기 실패뿐이다. ### 빌드 명령어 (Artisan) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0062d5b6..39955770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - 확장 스크립트·스타일을 합쳐 주는 공개 주소가 이미 만들어 둔 파일이 있어도 요청마다 다시 합치던 문제를 수정했습니다. 같은 주소를 반복해서 부르면 활성 확장 수에 비례하는 파일 읽기와 처리가 매번 일어나 서버 부하로 이어질 수 있었습니다. 이제 만들어 둔 파일이 있으면 그것을 바로 내보내고, 처음 만드는 순간에만 한 번 합칩니다. 합친 결과가 비어 있는 경우(스타일이 없는 확장만 설치된 기본 구성)도 파일로 두어 웹서버가 직접 내보냅니다. (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 index 2713aadf..9eca5642 100644 --- a/app/Seo/SeoCacheBounds.php +++ b/app/Seo/SeoCacheBounds.php @@ -95,6 +95,21 @@ final class SeoCacheBounds 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 의 요청을 통계로 기록할 수 있는지 판정합니다. * @@ -127,11 +142,15 @@ final class SeoCacheBounds * 이미 인덱스에 있는 키의 **갱신**은 이 판정을 거치지 않는다(호출측 책임) — 저장 규모가 * 늘지 않기 때문이다. * + * 경로당 변종은 **언어별로** 센다. 인덱스 항목은 url|locale 별이라 경로만 보고 합산하면 + * 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 언어마다 캐시에서 빠진다. + * * @param array> $index 현재 캐시 인덱스 * @param string $url 저장하려는 URL (경로 + 정규화 쿼리) + * @param string $locale 저장하려는 로케일 * @return bool 저장 허용 여부 */ - public static function canStore(array $index, string $url): bool + public static function canStore(array $index, string $url, string $locale): bool { if (count($index) >= self::limit('max_entries', 20000)) { return false; @@ -141,6 +160,10 @@ final class SeoCacheBounds $variants = 0; foreach ($index as $entry) { + if (($entry['locale'] ?? null) !== $locale) { + continue; + } + if (self::pathOf((string) ($entry['url'] ?? '')) === $path) { $variants++; } diff --git a/app/Seo/SeoCacheManager.php b/app/Seo/SeoCacheManager.php index 1c1ebaf2..c117baaf 100644 --- a/app/Seo/SeoCacheManager.php +++ b/app/Seo/SeoCacheManager.php @@ -34,14 +34,41 @@ class SeoCacheManager implements SeoCacheManagerInterface * {@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; } /** @@ -259,11 +286,11 @@ class SeoCacheManager implements SeoCacheManagerInterface // 스스로 줄지 않는다. 여기서 한 번 정리하지 않으면 상한이 "지금 저장된 양"이 // 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 닿은 경로는 실제 // 캐시가 비어도 영영 저장이 막힌다(상한이 아니라 일방향 래치가 된다). - if (! SeoCacheBounds::canStore($index, $url) && $this->shouldAttemptPrune()) { + if (! SeoCacheBounds::canStore($index, $url, $locale) && $this->shouldAttemptPrune()) { $index = $this->rebuildIndex(); } - if (! SeoCacheBounds::canStore($index, $url)) { + if (! SeoCacheBounds::canStore($index, $url, $locale)) { Log::debug('[SEO] 캐시 저장 상한에 도달해 저장하지 않습니다', [ 'url' => $url, 'locale' => $locale, @@ -274,7 +301,8 @@ class SeoCacheManager implements SeoCacheManagerInterface } } - $this->cache->put($key, $html, $this->getCacheTtl()); + // 레이아웃명을 페이지와 함께 둔다 — 적중 경로가 통계를 화면별로 귀속할 유일한 출처다. + $this->cache->put($key, ['html' => $html, 'layout' => $layoutName], $this->getCacheTtl()); $entry = [ 'url' => $url, diff --git a/app/Seo/SeoMiddleware.php b/app/Seo/SeoMiddleware.php index 09513079..6f92ba8c 100644 --- a/app/Seo/SeoMiddleware.php +++ b/app/Seo/SeoMiddleware.php @@ -75,15 +75,15 @@ class SeoMiddleware $cacheUrl = $this->buildCacheUrl($request, $normalizedQuery); // 캐시 확인 — 적중은 비용이 없으므로 렌더 예산과 무관하게 서빙한다 - $cachedHtml = $this->cacheManager->get($cacheUrl, $locale); - if ($cachedHtml !== null) { + $entry = $this->readEntry($cacheUrl, $locale); + if ($entry !== null) { $this->recordStat($ip, fn () => $this->statsService->recordHit( $cacheUrl, $locale, - $request->attributes->get('seo_layout_name') ?: null + $entry['layout'] ?: null )); - return response($cachedHtml, 200, [ + return response($entry['html'], 200, [ 'Content-Type' => 'text/html; charset=utf-8', 'X-SEO-Cache' => 'HIT', ]); @@ -134,6 +134,10 @@ class SeoMiddleware // 렌더링 실패 시 SPA fallback if ($html === null) { + // "그릴 게 없음" 은 비용을 치르지 않았다 — 되돌리지 않으면 캐시에 남지 않는 죽은 + // 주소 재크롤이 올 때마다 정상 페이지의 예산을 태운다. + SeoCacheBounds::refundRender($ip); + return $next($request); } @@ -155,6 +159,29 @@ class SeoMiddleware ]); } + /** + * 캐시 항목(HTML + 레이아웃명)을 읽습니다. + * + * 적중 경로는 렌더러를 거치지 않아 요청 속성에 레이아웃명이 없다 — 통계를 화면별로 + * 귀속하려면 캐시 항목이 그것을 알아야 한다. 인터페이스(`get`)는 HTML 만 돌려주므로 + * 코어 매니저일 때만 항목 전체를 읽고, 다른 구현이 바인딩된 경우에는 레이아웃명 없이 + * HTML 만 쓴다(그 통계는 레이아웃 미상으로 귀속된다). + * + * @param string $cacheUrl 캐시 키용 URL + * @param string $locale 로케일 + * @return array{html: string, layout: string|null}|null 캐시 항목 (없으면 null) + */ + private function readEntry(string $cacheUrl, string $locale): ?array + { + if ($this->cacheManager instanceof SeoCacheManager) { + return $this->cacheManager->getEntry($cacheUrl, $locale); + } + + $html = $this->cacheManager->get($cacheUrl, $locale); + + return $html === null ? null : ['html' => $html, 'layout' => null]; + } + /** * 캐시·렌더를 건너뛰고 SPA 응답을 돌려줍니다. * diff --git a/app/Services/ExtensionBundleService.php b/app/Services/ExtensionBundleService.php index 72c7aec2..09af7245 100644 --- a/app/Services/ExtensionBundleService.php +++ b/app/Services/ExtensionBundleService.php @@ -154,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']; } /** @@ -204,60 +172,7 @@ class ExtensionBundleService */ public function buildCssBundle(string $type): string { - $ordered = $this->getOrderedGlobalAssetPaths($type); - $isProduction = app()->environment('production'); - $typeSegment = $type === 'plugin' ? 'plugins' : 'modules'; - $version = $this->getCurrentVersion(); - $segments = []; - - foreach ($ordered as $identifier => $paths) { - if (empty($paths['cssAbsPath'])) { - continue; - } - - try { - $content = @file_get_contents($paths['cssAbsPath']); - - if ($content === false) { - Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'path' => $paths['cssAbsPath'], - ]); - - continue; - } - - // 상대 참조는 **치환**한다. 병합본의 주소(`/api/{type}/bundle.css` 또는 정적 - // 게시본)는 어느 확장의 dist 디렉토리도 아니므로 상대 해석이 반드시 어긋나는데, - // 그 실패는 404 하나로만 나타나 서버 로그에 흔적이 없다. - // - // 종전에는 그런 CSS 를 가진 확장을 번들에서 통째로 제외했다. 그러나 번들 URL 이 - // 내려오면 프론트는 개별 로딩을 아예 타지 않으므로(TemplateApp.loadExtensionAssets) - // 제외 = 그 확장의 스타일이 **하나도 적용되지 않음** 이었다. 주석이 말하던 - // "개별 폴백" 은 bundleUrls 부재(구버전 blade) 경로에만 있다. - $content = AssetCssUrlRewriter::rewrite( - $content, - (string) ($paths['cssRelPath'] ?? ''), - fn (string $path): string => AssetUrl::extensionApiAsset( - $typeSegment, - $identifier, - $path, - $version - ) - ); - - $segments[] = $this->processCssSourceMap($content, $isProduction); - } catch (\Throwable $e) { - Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [ - 'type' => $type, - 'identifier' => $identifier, - 'error' => $e->getMessage(), - ]); - } - } - - return implode("\n", $segments); + return $this->mergeBundle($type, 'css')['content']; } /** @@ -331,8 +246,9 @@ class ExtensionBundleService * * 병합 결과가 비어 있어도 선언한 산출물이 **전부 존재하면**(또는 선언이 0이면) 0바이트 * 캐시 파일을 만든다. 그래야 정적 게시 대상이 되어 방문자가 웹서버에서 직접 받는다 — - * 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 선언한 산출물이 **소실**된 - * 경우에만 캐시하지 않아, 호출측의 503 판정이 그대로 유지된다. + * 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 둘이다 — + * 선언한 산출물이 **소실**된 경우(호출측의 503 판정을 그대로 유지한다)와 병합 단계에서 + * 확장을 **건너뛴** 경우(그 상태가 굳지 않도록 매 요청 재시도에 맡긴다). * * @param CoreStorageDriver $storage 번들 디스크 스토리지 * @param string $type 'module' | 'plugin' @@ -379,7 +295,22 @@ class ExtensionBundleService return $storage->getBasePath('').'/'.$relativeName; } - $content = $this->buildBundleContent($type, $kind); + ['content' => $content, 'skipped' => $skipped] = $this->mergeBundle($type, $kind); + + // 건너뛴 확장이 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데 읽기·치환이 + // 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정된다. + // 캐시 없이 돌아가면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 순간 회복한다. + // 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다 — 이 통지가 유일한 흔적이다. + if ($skipped !== []) { + Log::error('확장 번들 캐시 보류 — 병합 단계에서 건너뛴 확장이 있어 캐시하지 않습니다', [ + 'type' => $type, + 'kind' => $kind, + 'version' => $version, + 'skipped' => $skipped, + ]); + + return ''; + } // 비었는데 선언한 산출물이 소실이면 캐시하지 않는다 — 배포 중 dist 가 잠깐 빈 // 장애가 0바이트 캐시로 굳어 정상(빈 200)으로 위장되면 안 된다. @@ -408,9 +339,163 @@ class ExtensionBundleService */ public function buildBundleContent(string $type, string $kind): string { - return $kind === 'css' - ? $this->buildCssBundle($type) - : $this->buildJsBundle($type); + 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'])) { + continue; + } + + try { + $content = $this->readAssetSource($paths['cssAbsPath']); + + if ($content === false) { + Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'path' => $paths['cssAbsPath'], + ]); + $skipped[] = (string) $identifier; + + continue; + } + + // 상대 참조는 **치환**한다. 병합본의 주소(`/api/{type}/bundle.css` 또는 정적 + // 게시본)는 어느 확장의 dist 디렉토리도 아니므로 상대 해석이 반드시 어긋나는데, + // 그 실패는 404 하나로만 나타나 서버 로그에 흔적이 없다. + // + // 종전에는 그런 CSS 를 가진 확장을 번들에서 통째로 제외했다. 그러나 번들 URL 이 + // 내려오면 프론트는 개별 로딩을 아예 타지 않으므로(TemplateApp.loadExtensionAssets) + // 제외 = 그 확장의 스타일이 **하나도 적용되지 않음** 이었다. 주석이 말하던 + // "개별 폴백" 은 bundleUrls 부재(구버전 blade) 경로에만 있다. + $content = AssetCssUrlRewriter::rewrite( + $content, + (string) ($paths['cssRelPath'] ?? ''), + fn (string $path): string => AssetUrl::extensionApiAsset( + $typeSegment, + $identifier, + $path, + $version + ) + ); + + $segments[] = $this->processCssSourceMap($content, $isProduction); + } catch (\Throwable $e) { + Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'error' => $e->getMessage(), + ]); + $skipped[] = (string) $identifier; + } + } + + return ['segments' => $segments, 'skipped' => $skipped]; + } + + /** + * 확장 자산 원본을 읽습니다. + * + * 실패는 `false` 로 돌아오고 호출측이 그 확장을 건너뛴다. 별도 메서드인 이유는 + * "존재·판독 가능한데 읽기가 실패하는" 상태를 테스트가 재현할 수 있어야 하기 때문이다 — + * 그 상태가 캐시로 굳는 것이 이 서비스가 막아야 할 결함이다. + * + * @param string $path 절대 경로 + * @return string|false 파일 내용 (실패 시 false) + */ + protected function readAssetSource(string $path): string|false + { + return @file_get_contents($path); } /** diff --git a/config/core.php b/config/core.php index b8f71052..ef252a4c 100644 --- a/config/core.php +++ b/config/core.php @@ -143,7 +143,7 @@ return [ // 정규화된 쿼리 문자열 길이 상한 (바이트) '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), // 캐시 인덱스 전체 항목 수 상한 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 92575fa9..67e62329 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -106,9 +106,9 @@ Request → web.php catch-all → SeoMiddleware (봇 감지) - 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조) - 렌더링 실패 시: SPA fallback (기존 응답 통과) - 캐시 키: 경로 + **정규화된** 쿼리(`locale`·`_escaped_fragment_` 제외, 키 순서 정렬, 개수·길이 상한) -- 미스 렌더는 IP 당 분당 상한 안에서만 — 초과분은 SPA + `X-SEO-Cache: BYPASS`(오류가 아니다) -- 저장 상한: 경로당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수 -- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한) +- 미스 렌더는 IP 당 분당 상한 안에서만 — 초과분은 SPA + `X-SEO-Cache: BYPASS`(오류가 아니다). 렌더러가 "그릴 게 없음"(null)으로 돌아온 요청은 차감을 되돌린다 +- 저장 상한: 경로·언어당 쿼리 변종 수 · 캐시 인덱스 전체 항목 수 +- 캐시 HIT/MISS 를 통계에 기록 (IP 당 분당 상한). HIT 의 레이아웃명은 캐시 항목에 함께 저장된 값으로 귀속한다 ## 봇 감지 구조 @@ -769,9 +769,9 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) |----|-----|-------|------| | 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_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 당 분당 미스 렌더 수 | +| 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`)로 드러나며, 그것이 운영 진단의 통로입니다. @@ -784,6 +784,20 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) 프록시 뒤에 두는 설치본은 `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시간). 그래서 저장 상한에 닿으면 **먼저 만료된 항목을 인덱스에서 걷어내고 다시 판정**합니다. 그러지 않으면 상한이 "지금 저장된 양"이 아니라 "과거에 저장한 적이 있는 양"을 재게 되어, 한 번 상한에 닿은 경로는 실제 캐시가 비어 있어도 다시는 저장되지 않습니다. diff --git a/docs/extension/module-assets.md b/docs/extension/module-assets.md index c0fd5702..08f4ffb1 100644 --- a/docs/extension/module-assets.md +++ b/docs/extension/module-assets.md @@ -520,7 +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회 빌드에 수렴(잠금 실패는 각자 빌드) | (계약 테스트) | +| 캐시 우선 | 프로덕션은 `(type, kind, version)` 캐시 파일 존재를 **빌드보다 먼저** 확인한다. 캐시 미스는 같은 키의 잠금으로 1회 빌드에 수렴(잠금 실패는 각자 빌드). 병합 단계에서 건너뛴 확장이 있는 결과는 캐시하지 않는다 | (계약 테스트) | ### 병합 CSS 의 상대 참조 @@ -553,7 +553,7 @@ GET /api/plugins/bundle.css?v={version} 선언 산출물이 전부 존재하는 빈 번들은 **0바이트 파일로 캐시·정적 게시**되어 브라우저가 웹서버에서 직접 받는다. 게시하지 않으면 `AssetUrl::extensionBundle()` 이 API URL 을 방출해 그 구성의 **모든 페이지 로드**가 PHP 를 거치고, 그 요청마다 컨트롤러가 활성 확장 열거를 세 번 반복한다(경로 조회 → 재빌드 → 소실 판정). 오류도 로그도 남지 않아 드러나지 않는 경로다. -캐시하지 않는 것은 둘뿐이다 — 선언 산출물이 **소실**된 경우(503 판정을 그대로 유지해야 한다)와 디스크 쓰기 자체가 실패한 경우다. +캐시하지 않는 것은 셋뿐이다 — 선언 산출물이 **소실**된 경우(503 판정을 그대로 유지해야 한다), 병합 단계에서 확장을 **건너뛴** 경우(파일은 있는데 읽기·치환이 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정되므로, 종전처럼 매 요청 재시도에 맡긴다), 그리고 디스크 쓰기 자체가 실패한 경우다. 두 판정은 모듈·플러그인 컨트롤러가 **공유하는 단일 지점**(`ServesExtensionBundles::bundleResponse()`)에 둔다. 각자 구현하면 한쪽만 고쳐진 채 다른 쪽이 옛 동작으로 남는다. 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 3120a893..dc3799d1 100644 --- a/tests/Feature/Seo/SeoMiddlewareTest.php +++ b/tests/Feature/Seo/SeoMiddlewareTest.php @@ -5,6 +5,7 @@ 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; @@ -702,4 +703,69 @@ class SeoMiddlewareTest extends TestCase $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/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 index 8658d6f6..9b33d533 100644 --- a/tests/Unit/Seo/SeoCacheBoundsTest.php +++ b/tests/Unit/Seo/SeoCacheBoundsTest.php @@ -99,14 +99,14 @@ class SeoCacheBoundsTest extends TestCase ]); $index = [ - 'k1' => ['url' => '/shop?page=1'], - 'k2' => ['url' => '/shop?page=2'], - 'k3' => ['url' => '/shop?page=3'], + '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')); + $this->assertFalse(SeoCacheBounds::canStore($index, '/shop?page=4', 'ko')); // 다른 경로는 자기 예산을 따로 쓴다 - $this->assertTrue(SeoCacheBounds::canStore($index, '/board?page=1')); + $this->assertTrue(SeoCacheBounds::canStore($index, '/board?page=1', 'ko')); } /** @@ -122,11 +122,11 @@ class SeoCacheBoundsTest extends TestCase ]); $index = [ - 'k1' => ['url' => '/a'], - 'k2' => ['url' => '/b'], + 'k1' => ['url' => '/a', 'locale' => 'ko'], + 'k2' => ['url' => '/b', 'locale' => 'ko'], ]; - $this->assertFalse(SeoCacheBounds::canStore($index, '/c')); + $this->assertFalse(SeoCacheBounds::canStore($index, '/c', 'ko')); } /** @@ -162,4 +162,27 @@ class SeoCacheBoundsTest extends TestCase $this->assertFalse(SeoCacheBounds::statsAllowed('10.0.0.3')); } + + /** + * 경로당 변종 상한은 언어별로 따로 센다 — 인덱스 항목은 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 983d2e75..e62bb1f4 100644 --- a/tests/Unit/Seo/SeoCacheManagerTest.php +++ b/tests/Unit/Seo/SeoCacheManagerTest.php @@ -423,4 +423,63 @@ class SeoCacheManagerTest extends TestCase $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/Services/ExtensionBundleServiceTest.php b/tests/Unit/Services/ExtensionBundleServiceTest.php index 0d124e9a..740ca680 100644 --- a/tests/Unit/Services/ExtensionBundleServiceTest.php +++ b/tests/Unit/Services/ExtensionBundleServiceTest.php @@ -131,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 !== []); @@ -142,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) @@ -914,6 +917,7 @@ class ExtensionBundleServiceTest extends TestCase 100, ['css' => ['output' => 'dist/css/module.css']], 'global', + ['css' => $cssPath], ['css' => $cssPath] ), ]; @@ -982,4 +986,44 @@ class ExtensionBundleServiceTest extends TestCase $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 780e06e1..56dd260f 100644 --- a/tests/scenarios/extension-bundle-loading.yaml +++ b/tests/scenarios/extension-bundle-loading.yaml @@ -69,6 +69,7 @@ effects: - 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: diff --git a/tests/scenarios/seo-bot-cache-bounds.yaml b/tests/scenarios/seo-bot-cache-bounds.yaml index 59bb9e96..f42f6896 100644 --- a/tests/scenarios/seo-bot-cache-bounds.yaml +++ b/tests/scenarios/seo-bot-cache-bounds.yaml @@ -15,6 +15,10 @@ description: | - 렌더 예산: IP 당 분당 미스 렌더 상한, 초과분은 SPA + X-SEO-Cache: BYPASS (429 아님) - 저장 규모: 경로당 변종 수 · 전체 항목 수 상한, 기존 키 갱신은 상한과 무관 - 통계: 미들웨어가 HIT/MISS 를 기록하되 IP 당 분당 상한 안에서만 + - 캐시 항목은 HTML + 레이아웃명 — HIT 통계가 화면별로 귀속된다 (이전 문자열 항목 호환) + - 경로당 변종 상한은 언어별로 센다 (인덱스 항목이 url|locale 별이므로) + - 렌더러가 null 을 돌려준 요청은 렌더 예산을 되돌린다 (예외는 되돌리지 않는다) + - 통계 url 컬럼(768)은 캐시 키 URL 상한을 담는다 — 마이그레이션 왕복 안전 axes: bot_state: [bot, browser] @@ -39,8 +43,14 @@ effects: - 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