Files
Gnuboard7/tests/Unit/Seo/SeoCacheManagerTest.php
T
HeuJung f506c040c1 fix(core): 공개 확장 번들의 캐시 우선 서빙 + SEO 봇 캐시 상한
공개 번들 엔드포인트가 캐시 파일이 있어도 요청마다 다시 병합했다. 캐시 키는
(type, kind, version) 인자만으로 계산되는데 "병합 결과가 비면 파일을 만들지
않는다" 는 규칙을 먼저 두느라 빌드를 앞세운 것이 원인이다. 그래서 캐시 적중
경로에도 활성 확장 열거와 산출물 전량 읽기가 붙었고, 원본이 소실되면 멀쩡한
캐시를 두고 빈 경로가 반환되어 503 이 됐다. 응답은 정상 200 이라 타이밍 말고는
드러나는 증상이 없다.

프로덕션에서 캐시 존재를 병합보다 먼저 확인하고, 캐시 미스는 같은 키의 잠금으로
1회 빌드에 수렴시킨 뒤 잠금 뒤 캐시를 재확인한다. 잠금 대기 초과·저장소 장애는
실패로 바꾸지 않고 각자 빌드로 폴백한다.

병합 결과가 비어도 선언 산출물이 전부 존재하거나 선언이 0이면 0바이트 캐시를
만들어 정적 게시까지 보낸다. 만들지 않으면 그 구성의 자산 URL 이 API 로 폴백해
방문자의 모든 페이지 로드가 PHP 를 거치고, 그 요청마다 컨트롤러가 열거를 세 번
반복한다. 캐시하지 않는 것은 산출물 소실(503 판정 보존)과 디스크 쓰기 실패뿐이라
응답 계약은 바뀌지 않는다 — 컨트롤러와 트레이트는 손대지 않았다.

같은 결의 결함이 검색봇 캐시에도 있었다. 봇 판정은 User-Agent 문자열뿐인데 캐시
키가 경로 + 전체 쿼리여서, 물음표 뒤 값만 바꾼 반복 요청이 매번 미스가 되고 그
미스마다 레이아웃 병합·표현식 평가·자기 API 루프백 호출이 일어나며 결과가 무제한
저장됐다. 키를 정규화하고(시스템 파라미터 제외·개수/길이 상한), IP 당 분당 미스
렌더 예산과 저장 규모 상한을 뒀다. 초과분은 차단이 아니라 일반 SPA 응답을 받는다
— 봇에게 오류를 주면 그 URL 이 색인에서 빠지기 때문이다.

저장 상한은 만료 항목을 걷어낸 뒤 판정한다. 인덱스는 페이지보다 오래 살아
(30일 vs 기본 2시간) 정리 없이 세면 상한이 "지금 저장된 양"이 아니라 "과거에
저장한 적이 있는 양"을 재게 되어 일방향 래치가 된다. 정리는 인덱스 전체를
훑으므로 최소 60초 간격으로만 수행한다. put 과 putWithLayout 은 같은 자원을
쓰므로 단일 저장 경로로 합쳤다 — 한쪽만 상한 밖이면 그쪽이 우회로가 되고,
인터페이스는 확장에 열려 있어 "지금 호출부가 없다" 는 방어가 되지 않는다.

캐시 적중·미적중을 기록하는 호출처가 없어 관리자 SEO 통계와 seo:stats 가 항상
0 이었던 것도 함께 고쳤다. 기록 자체에도 IP 당 상한을 둬 통계 테이블이 새 증식
축이 되지 않게 했다.

(KISA 측에서 제보해주셨습니다 — KVE-2026-2191)
2026-09-08 00:31:20 +09:00

427 lines
16 KiB
PHP

<?php
namespace Tests\Unit\Seo;
use App\Extension\Cache\CoreCacheDriver;
use App\Seo\SeoCacheManager;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class SeoCacheManagerTest extends TestCase
{
private SeoCacheManager $cacheManager;
/**
* 테스트 초기화 - SeoCacheManager 인스턴스를 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
// 캐시 활성화 기본 설정
config()->set('g7_settings.core.seo.cache_enabled', true);
config()->set('g7_settings.core.seo.cache_ttl', 7200);
// 이전 테스트 캐시 잔여물 제거
Cache::flush();
$this->cacheManager = new SeoCacheManager(new CoreCacheDriver('array'));
}
/**
* put 후 get 호출 시 저장된 HTML이 반환되는지 확인합니다.
*/
public function test_put_and_get_returns_stored_html(): void
{
$url = '/products/123';
$locale = 'ko';
$html = '<html><body>상품 상세</body></html>';
$this->cacheManager->put($url, $locale, $html);
$result = $this->cacheManager->get($url, $locale);
$this->assertSame($html, $result);
}
/**
* 캐시되지 않은 URL에 대해 get 호출 시 null을 반환합니다.
*/
public function test_get_returns_null_when_not_cached(): void
{
$result = $this->cacheManager->get('/nonexistent', 'ko');
$this->assertNull($result);
}
/**
* cache_enabled=false일 때 put이 무시되고 get이 null을 반환합니다.
*/
public function test_cache_disabled_ignores_put_and_returns_null(): void
{
config()->set('g7_settings.core.cache.seo_enabled', false);
config()->set('g7_settings.core.seo.cache_enabled', false);
$url = '/products/123';
$locale = 'ko';
$html = '<html><body>상품 상세</body></html>';
$this->cacheManager->put($url, $locale, $html);
$result = $this->cacheManager->get($url, $locale);
$this->assertNull($result);
}
/**
* invalidateByUrl로 정확한 URL의 캐시만 제거됩니다.
*/
public function test_invalidate_by_url_removes_exact_url_cache(): void
{
$this->cacheManager->put('/products/1', 'ko', '<html>상품1</html>');
$this->cacheManager->put('/products/2', 'ko', '<html>상품2</html>');
$this->cacheManager->put('/categories/1', 'ko', '<html>카테고리1</html>');
$count = $this->cacheManager->invalidateByUrl('/products/1');
$this->assertSame(1, $count);
$this->assertNull($this->cacheManager->get('/products/1', 'ko'));
$this->assertNotNull($this->cacheManager->get('/products/2', 'ko'));
$this->assertNotNull($this->cacheManager->get('/categories/1', 'ko'));
}
/**
* invalidateByLayout으로 해당 레이아웃의 캐시가 모두 제거됩니다.
*/
public function test_invalidate_by_layout_removes_matching_layout_caches(): void
{
$this->cacheManager->putWithLayout('/products/1', 'ko', '<html>상품1</html>', 'shop/show');
$this->cacheManager->putWithLayout('/products/2', 'ko', '<html>상품2</html>', 'shop/show');
$this->cacheManager->putWithLayout('/categories/1', 'ko', '<html>카테고리1</html>', 'shop/category');
$count = $this->cacheManager->invalidateByLayout('shop/show');
$this->assertSame(2, $count);
$this->assertNull($this->cacheManager->get('/products/1', 'ko'));
$this->assertNull($this->cacheManager->get('/products/2', 'ko'));
$this->assertNotNull($this->cacheManager->get('/categories/1', 'ko'));
}
/**
* clearAll로 모든 SEO 캐시가 제거됩니다.
*/
public function test_clear_all_removes_all_seo_caches(): void
{
$this->cacheManager->put('/products/1', 'ko', '<html>상품1</html>');
$this->cacheManager->put('/products/2', 'ko', '<html>상품2</html>');
$this->cacheManager->put('/categories/1', 'en', '<html>Category1</html>');
$this->cacheManager->clearAll();
$this->assertNull($this->cacheManager->get('/products/1', 'ko'));
$this->assertNull($this->cacheManager->get('/products/2', 'ko'));
$this->assertNull($this->cacheManager->get('/categories/1', 'en'));
$this->assertEmpty($this->cacheManager->getCachedUrls());
}
/**
* 캐시 키에 로케일이 포함되어 다른 로케일은 별도로 저장됩니다.
*/
public function test_cache_key_includes_locale_stores_separately(): void
{
$url = '/products/1';
$htmlKo = '<html><body>상품 상세 (한국어)</body></html>';
$htmlEn = '<html><body>Product Detail (English)</body></html>';
$this->cacheManager->put($url, 'ko', $htmlKo);
$this->cacheManager->put($url, 'en', $htmlEn);
$resultKo = $this->cacheManager->get($url, 'ko');
$resultEn = $this->cacheManager->get($url, 'en');
$this->assertSame($htmlKo, $resultKo);
$this->assertSame($htmlEn, $resultEn);
$this->assertNotSame($resultKo, $resultEn);
}
/**
* getCachedUrls가 캐시된 URL 목록을 반환합니다.
*/
public function test_get_cached_urls_returns_list(): void
{
$this->cacheManager->put('/products/1', 'ko', '<html>상품1</html>');
$this->cacheManager->put('/categories/1', 'ko', '<html>카테고리1</html>');
$urls = $this->cacheManager->getCachedUrls();
$this->assertCount(2, $urls);
$this->assertContains('/products/1', $urls);
$this->assertContains('/categories/1', $urls);
}
/**
* putWithLayout이 레이아웃 정보를 인덱스에 함께 저장합니다.
*/
public function test_put_with_layout_stores_layout_info_in_index(): void
{
$this->cacheManager->putWithLayout('/products/1', 'ko', '<html>상품1</html>', 'shop/show');
// 캐시된 HTML 확인
$result = $this->cacheManager->get('/products/1', 'ko');
$this->assertSame('<html>상품1</html>', $result);
// 인덱스에서 URL 확인
$urls = $this->cacheManager->getCachedUrls();
$this->assertContains('/products/1', $urls);
// invalidateByLayout으로 레이아웃 정보가 저장되었는지 간접 확인
$count = $this->cacheManager->invalidateByLayout('shop/show');
$this->assertSame(1, $count);
}
/**
* invalidateByUrl에 와일드카드 패턴을 사용하여 여러 URL을 제거합니다.
*/
public function test_invalidate_by_url_with_wildcard_pattern(): void
{
$this->cacheManager->put('/products/1', 'ko', '<html>상품1</html>');
$this->cacheManager->put('/products/2', 'ko', '<html>상품2</html>');
$this->cacheManager->put('/categories/1', 'ko', '<html>카테고리1</html>');
$count = $this->cacheManager->invalidateByUrl('/products/*');
$this->assertSame(2, $count);
$this->assertNull($this->cacheManager->get('/products/1', 'ko'));
$this->assertNull($this->cacheManager->get('/products/2', 'ko'));
$this->assertNotNull($this->cacheManager->get('/categories/1', 'ko'));
}
/**
* cache_enabled=false일 때 putWithLayout도 무시됩니다.
*/
public function test_put_with_layout_ignored_when_cache_disabled(): void
{
config()->set('g7_settings.core.cache.seo_enabled', false);
config()->set('g7_settings.core.seo.cache_enabled', false);
$this->cacheManager->putWithLayout('/products/1', 'ko', '<html>상품1</html>', 'shop/show');
$result = $this->cacheManager->get('/products/1', 'ko');
$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', '<html>'.$i.'</html>', 'shop');
}
$before = $this->cacheManager->getCachedUrls();
$this->cacheManager->putWithLayout('/shop?page=4', 'ko', '<html>4</html>', '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', '<html>a</html>', 'la');
$this->cacheManager->putWithLayout('/b', 'ko', '<html>b</html>', 'lb');
$this->cacheManager->putWithLayout('/c', 'ko', '<html>c</html>', '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', '<html>old</html>', 'la');
$this->cacheManager->putWithLayout('/a', 'ko', '<html>new</html>', 'la');
$this->assertSame('<html>new</html>', $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', '<html>'.$i.'</html>', 'shop');
}
$this->expirePages($driver);
$manager->putWithLayout('/shop?page=4', 'ko', '<html>4</html>', 'shop');
$this->assertSame('<html>4</html>', $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', '<html>a</html>', 'la');
$manager->putWithLayout('/b', 'ko', '<html>b</html>', 'lb');
$this->expirePages($driver);
$manager->putWithLayout('/c', 'ko', '<html>c</html>', 'lc');
$this->assertSame('<html>c</html>', $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', '<html>a</html>');
$this->cacheManager->put('/b', 'ko', '<html>b</html>');
$this->cacheManager->put('/c', 'ko', '<html>c</html>');
$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', '<html>a</html>');
$manager->put('/b', 'ko', '<html>b</html>');
$this->expirePages($driver);
$manager->put('/c', 'ko', '<html>c</html>');
$this->assertSame('<html>c</html>', $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', '<html>'.$i.'</html>', 'shop');
}
// 전부 살아 있는 상태에서 상한 도달 → 1회 스캔하고 표식을 남긴다 (저장은 안 됨)
$manager->putWithLayout('/shop?page=4', 'ko', '<html>4</html>', 'shop');
$this->assertNull($manager->get('/shop?page=4', 'ko'));
// 이후 만료되어도 표식이 살아 있는 동안은 다시 훑지 않는다
$this->expirePages($driver);
$manager->putWithLayout('/shop?page=5', 'ko', '<html>5</html>', 'shop');
$this->assertNull($manager->get('/shop?page=5', 'ko'));
// 표식이 사라지면 다시 정리하고 저장한다
$driver->forget('seo.index_pruned_at');
$manager->putWithLayout('/shop?page=6', 'ko', '<html>6</html>', 'shop');
$this->assertSame('<html>6</html>', $manager->get('/shop?page=6', 'ko'));
}
}