fix(core): SEO 봇 캐시·확장 번들 캐시의 정합 결함 5건 수정

KVE-2026-2191 대응에서 계획 안 세부를 자율 결정한 지점을 회귀 전수조사해 정합 결함
5건을 고쳤다. 넷은 같은 미출시 버전이 만든 자기교정이고, 마지막 하나는 7.0.10 및 그
이전부터 있던 결함이라 공개 CHANGELOG 에 항목을 넣었다.

- 적중 통계의 레이아웃명이 항상 비어 화면별 표가 성립하지 않던 것 → 페이지 캐시 값에
 레이아웃명을 함께 담고 적중 경로가 그것으로 귀속한다. 이전 버전이 문자열로만 저장한
 항목은 그대로 읽어 배포 직후 살아 있는 캐시를 버리지 않는다. 인터페이스는 불변이고
 코어 구현일 때만 항목 전체를 읽는다.
- 통계 url 컬럼(255)이 캐시 키 URL(정규화 쿼리 최대 512바이트)보다 짧아 긴 주소의 기록이
 엄격 모드에서 실패하고 그 예외를 서비스가 삼키던 것 → 768 로 확장(utf8mb4 인덱스 키
 상한 3072바이트). down 은 잘리는 행을 먼저 지운다.
- 경로당 변종 상한이 언어를 합산해 다국어 사이트의 실효 상한이 언어 수만큼 줄던 것 →
 인덱스 항목이 url|locale 별이므로 경로·언어 조합 단위로 판정한다.
- 그릴 게 없는 요청(미라우트 404·SEO 비활성)이 렌더 예산을 소모해, 캐시에 남지 않는 죽은
 주소 재크롤이 정상 페이지의 예산을 태우던 것 → null 렌더는 차감을 환급한다. 렌더 도중
 예외는 비용을 이미 치른 것이라 환급하지 않는다.
- 병합 단계에서 건너뛴 확장이 있는 결과가 캐시로 굳어 버전 bump 전까지 그 확장 자산이
 사라진 채 고정되던 것 → 병합 루프를 mergeJs/mergeCss 로 분리해 건너뛴 확장을 함께
 돌려주고, 하나라도 있으면 캐시하지 않고 Log::error 로 남긴다. 캐시 없이 돌면 호출측이
 매 요청 다시 병합하므로 원인이 사라지는 즉시 회복한다. 기본 로그 수준이 error 라
 종전 per-extension warning 은 기록되지 않아 흔적이 없었다.

회귀 테스트 8건은 red 확인 후 green 으로 전환했고, 변경 심볼의 소비자 모집단(테스트
클래스 9종)을 프로세스 분할로 재실행해 189건 통과를 확인했다.
This commit is contained in:
HeuJung
2026-09-08 08:17:51 +09:00
parent f506c040c1
commit f32729e20e
20 changed files with 667 additions and 125 deletions
@@ -0,0 +1,97 @@
<?php
namespace Tests\Feature\Database;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* `seo_cache_stats.url` 확장 마이그레이션의 왕복(up → down → up) 안전성.
*
* 캐시 키 URL(경로 + 정규화 쿼리 최대 512바이트)이 255자 컬럼에 들어가지 않아 긴 주소의
* 통계 기록이 조용히 실패하던 것을 768자로 넓힌 마이그레이션이다. 되돌릴 때는 잘리는 행을
* 먼저 지워야 엄격 모드에서 ALTER 가 성공한다.
*/
class ModifyUrlInSeoCacheStatsMigrationTest extends TestCase
{
use RefreshDatabase;
private const MIGRATION = 'database/migrations/2026_09_08_000001_modify_url_in_seo_cache_stats_table.php';
/**
* up → down → up 왕복 뒤 컬럼 길이가 복원되고 url 인덱스가 보존된다.
*
* @scenario bot_state=bot, cache_state=hit, ip_budget=within, query_shape=normal, store_state=under_caps
*
* @effects stats_url_column_fits_normalized_cache_url
*/
public function test_round_trip_restores_column_length_and_keeps_index(): void
{
$this->assertSame(768, $this->urlLength());
Artisan::call('migrate:rollback', ['--path' => [self::MIGRATION], '--force' => true]);
$this->assertSame(255, $this->urlLength());
Artisan::call('migrate', ['--path' => [self::MIGRATION], '--force' => true]);
$this->assertSame(768, $this->urlLength());
$this->assertContains(
'idx_seo_cache_stats_url',
array_column(Schema::getIndexes('seo_cache_stats'), 'name'),
'url 인덱스는 컬럼 변경 뒤에도 남아야 한다'
);
}
/**
* down 은 255자를 넘는 행을 먼저 지운다 — 엄격 모드에서는 잘리는 값이 하나라도 있으면
* ALTER 자체가 실패해 되돌리기가 막힌다.
*
* @scenario bot_state=bot, cache_state=hit, ip_budget=within, query_shape=normal, store_state=under_caps
*
* @effects stats_url_column_fits_normalized_cache_url
*/
public function test_down_removes_rows_that_would_not_fit_before_shrinking(): void
{
$long = '/shop?'.str_repeat('a=0123456789&', 30);
$this->assertGreaterThan(255, strlen($long));
DB::table('seo_cache_stats')->insert([
['url' => $long, 'locale' => 'ko', 'type' => 'hit'],
['url' => '/short', 'locale' => 'ko', 'type' => 'hit'],
]);
try {
Artisan::call('migrate:rollback', ['--path' => [self::MIGRATION], '--force' => true]);
$this->assertSame(255, $this->urlLength());
$this->assertDatabaseMissing('seo_cache_stats', ['url' => $long]);
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/short']);
} finally {
// DDL 이 테스트 트랜잭션을 암묵 커밋하므로 남는 행은 직접 치운다.
Artisan::call('migrate', ['--path' => [self::MIGRATION], '--force' => true]);
DB::table('seo_cache_stats')->where('url', '/short')->delete();
}
}
/**
* url 컬럼의 선언 길이를 읽습니다.
*
* @return int 문자 수 (varchar(N) 의 N)
*/
private function urlLength(): int
{
foreach (Schema::getColumns('seo_cache_stats') as $column) {
if ($column['name'] === 'url') {
preg_match('/\((\d+)\)/', (string) $column['type'], $matches);
return (int) ($matches[1] ?? 0);
}
}
$this->fail('seo_cache_stats.url 컬럼이 없다');
}
}
@@ -111,12 +111,13 @@ class ExtensionAssetCssRewriteContractTest extends TestCase
'병합 CSS 번들 라우트가 하나도 잡히지 않았습니다 — 이 테스트가 공허하게 통과하고 있습니다.'
);
$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.' 를 거치지 않습니다. '
.'개별 자산 서빙과 병합 번들이 서로 다른 규칙을 쓰면 한쪽만 고쳐진 채 남습니다.'
);
}
+66
View File
@@ -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' => '<html>cached</html>', 'layout' => 'shop/show']);
$middleware = new SeoMiddleware($this->botDetector, $cacheManager, $this->renderer, $this->statsService);
$this->botDetector->method('isBot')->willReturn(true);
$this->statsService->expects($this->once())
->method('recordHit')
->with('/products', config('app.locale'), 'shop/show');
$response = $middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
$this->assertSame('<html>cached</html>', $response->getContent());
$this->assertSame('HIT', $response->headers->get('X-SEO-Cache'));
}
/**
* 렌더러가 "그릴 게 없음"(null)을 돌려주면 방금 뺀 렌더 예산을 되돌린다 — 미라우트 404 나
* SEO 비활성 화면은 캐시에 남지 않아 올 때마다 다시 예산을 쓰므로, 죽은 주소 재크롤이
* 정상 페이지의 예산을 태운다.
*
* @effects null_render_refunds_render_budget
*/
public function test_null_render_refunds_render_budget(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willReturn(null);
$this->middleware->handle($this->createRequest('/gone', 'Googlebot/2.1'), $this->spaNext());
$this->assertSame(0, (int) RateLimiter::attempts('seo-render:127.0.0.1'));
}
/**
* 렌더 중 예외는 비용을 이미 치른 것이므로 예산을 되돌리지 않는다 (회귀 가드).
*
* @effects null_render_refunds_render_budget
*/
public function test_render_exception_keeps_render_budget_charged(): void
{
config(['g7_settings.core.seo.bot_detection_enabled' => true]);
$this->botDetector->method('isBot')->willReturn(true);
$this->cacheManager->method('get')->willReturn(null);
$this->renderer->method('render')->willThrowException(new \RuntimeException('boom'));
$this->middleware->handle($this->createRequest('/products', 'Googlebot/2.1'), $this->spaNext());
$this->assertSame(1, (int) RateLimiter::attempts('seo-render:127.0.0.1'));
}
}
+2 -1
View File
@@ -60,7 +60,8 @@ class SeoCacheTest extends TestCase
$this->manager->put('/board/notice/123', 'ko', '<html>Hello</html>');
$expectedKey = 'seo.page.'.md5('/board/notice/123|ko');
$this->assertSame('<html>Hello</html>', $this->cache->get($expectedKey));
// 페이지는 레이아웃명과 함께 저장된다 — 적중 경로가 통계를 화면별로 귀속할 출처다.
$this->assertSame('<html>Hello</html>', $this->cache->get($expectedKey)['html'] ?? null);
$this->assertSame(
'g7:core:'.$expectedKey,
$this->cache->resolveKey($expectedKey)
+31 -8
View File
@@ -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'), '다른 언어는 자기 예산을 따로 쓴다');
}
}
+59
View File
@@ -423,4 +423,63 @@ class SeoCacheManagerTest extends TestCase
$manager->putWithLayout('/shop?page=6', 'ko', '<html>6</html>', 'shop');
$this->assertSame('<html>6</html>', $manager->get('/shop?page=6', 'ko'));
}
/**
* 페이지와 함께 저장한 레이아웃명을 꺼낼 수 있다 — 캐시 적중(HIT)은 렌더러를 거치지
* 않으므로 요청 속성에 레이아웃명이 없고, 캐시 항목이 그것을 알아야 통계가 화면별로 귀속된다.
*
* @effects cache_hit_carries_layout_name_from_cache_entry
*/
public function test_get_entry_returns_layout_stored_with_page(): void
{
$this->cacheManager->putWithLayout('/products/1', 'ko', '<html>p</html>', 'shop/show');
$this->assertSame(
['html' => '<html>p</html>', 'layout' => 'shop/show'],
$this->cacheManager->getEntry('/products/1', 'ko')
);
$this->assertSame('<html>p</html>', $this->cacheManager->get('/products/1', 'ko'));
$this->assertNull($this->cacheManager->getEntry('/nope', 'ko'));
}
/**
* 이전 버전이 문자열로만 저장한 항목도 그대로 읽힌다 — 배포 직후 살아 있는 캐시를 버리지 않는다.
*
* @effects cache_hit_carries_layout_name_from_cache_entry
*/
public function test_get_reads_legacy_string_entries(): void
{
$driver = new CoreCacheDriver('array');
$manager = new SeoCacheManager($driver);
$driver->put('seo.page.'.md5('/legacy|ko'), '<html>legacy</html>', 3600);
$this->assertSame('<html>legacy</html>', $manager->get('/legacy', 'ko'));
$this->assertSame(['html' => '<html>legacy</html>', 'layout' => null], $manager->getEntry('/legacy', 'ko'));
}
/**
* 경로당 변종 상한은 언어별로 따로 센다 — 인덱스 항목은 url|locale 별인데 경로만 보고
* 합산하면 언어 수만큼 실효 상한이 줄어, 다국어 사이트의 목록 뒤쪽 페이지가 캐시에서 빠진다.
*
* @effects store_counts_path_variants_per_locale
*/
public function test_path_variant_cap_is_counted_per_locale(): void
{
config([
'core.seo_cache_limits.max_variants_per_path' => 3,
'core.seo_cache_limits.max_entries' => 20000,
]);
for ($i = 1; $i <= 3; $i++) {
$this->cacheManager->putWithLayout('/shop?page='.$i, 'ko', '<html>ko'.$i.'</html>', 'shop');
}
// ko 는 상한 도달, en 은 자기 예산을 따로 쓴다
$this->cacheManager->putWithLayout('/shop?page=4', 'ko', '<html>ko4</html>', 'shop');
$this->cacheManager->putWithLayout('/shop?page=1', 'en', '<html>en1</html>', 'shop');
$this->assertNull($this->cacheManager->get('/shop?page=4', 'ko'));
$this->assertSame('<html>en1</html>', $this->cacheManager->get('/shop?page=1', 'en'));
}
}
@@ -281,4 +281,22 @@ class SeoCacheStatsServiceTest extends TestCase
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/recent/1']);
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/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']);
}
}
@@ -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}');
}
}
@@ -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:
+10
View File
@@ -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