Files
Gnuboard7/tests/Unit/Seo/SeoCacheStatsServiceTest.php
T
HeuJung 21f666e4b5 feat(seo): 사이트맵 스트리밍 생성 + SEO 캐시 설정 정합화
대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의
1단계로, 전체 URL을 메모리에 적재하던 구조를 스트리밍 분할 생성으로 교체한다.

사이트맵 스트리밍 코어:
- SitemapWriter — 자식 파일 1개 분량만 버퍼링하고 임계 도달 시 flush.
 StorageInterface가 append를 제공하지 않아(put이 전체 덮어쓰기) 택한 구조이며,
 최대 메모리가 "자식 파일 1개 크기"로 유계가 된다. URL 수(50,000)와
 바이트(45MB) 두 임계로 분할해 sitemaps.org 프로토콜을 지킨다.
- 커밋은 _tmp 전량 기록 → live 재배치 → manifest 마지막 기록 순. manifest 존재가
 커밋 완료 신호다. 기존 manifest를 먼저 지우지 않아 스왑 중 봇에게 503이 나가지 않는다.
- SitemapFileStore — 읽기측 + 파일 경로 SSoT. SitemapXmlRenderer — escape 단일 출처.

데이터 접근 계층 정리:
- contributor 3종이 쿼리를 직접 조립하던 것을 Repository 위임으로 전환.
 MySQL 버퍼드 쿼리에서 cursor는 결과셋 전체를 적재하므로 lazyById를 쓴다.
- SeoCacheStatsService → SeoCacheStatRepository 신설 (집계 4쿼리 → 1쿼리).
- audit 룰 service-direct-data-access의 사각 2건 수정 — 확장 디렉토리 정규식이
 상대경로를 매칭하지 못해 *Contributor처럼 접미사 없는 클래스가 전부 미검출이었다.

SEO 캐시 설정 정합화:
- SEO 설정의 캐시 3키가 cache 카테고리 형제 키에 항상 져서 운영자 입력이 조용히
 무시되고 있었다(설정을 cache 카테고리로 이관하며 SEO 탭 옛 칸을 미제거).
 고급 탭을 기준값으로 두되 SEO 탭에 지정이 있으면 오버라이드하도록 SeoCacheSettings
 단일 출처로 정리. "지정 여부"는 null로 판정하므로 기본값을 비웠다.
- 기존 설치는 Upgrade_7_1_0이 이행 — 옛 기본값과 같으면 미설정으로 비우고, 다르면
 운영자 의도로 보아 보존한다.

코어 버전 bump와 루트 CHANGELOG는 계획서 결정(D20)에 따라 마감 단계에서 일괄 수행한다.
2026-07-17 19:59:40 +09:00

285 lines
12 KiB
PHP

<?php
namespace Tests\Unit\Seo;
use App\Models\SeoCacheStat;
use App\Seo\SeoCacheStatsService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SeoCacheStatsServiceTest extends TestCase
{
use RefreshDatabase;
private SeoCacheStatsService $statsService;
/**
* 테스트 초기화 - SeoCacheStatsService 인스턴스를 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
// Repository 주입이 필요하므로 컨테이너로 해석합니다.
$this->statsService = $this->app->make(SeoCacheStatsService::class);
}
/**
* recordHit이 type='hit'인 레코드를 생성합니다.
*/
public function test_record_hit_creates_hit_record(): void
{
$this->statsService->recordHit('/products/1', 'ko', 'shop/show', 'sirsoft-ecommerce');
$this->assertDatabaseHas('seo_cache_stats', [
'url' => '/products/1',
'locale' => 'ko',
'layout_name' => 'shop/show',
'module_identifier' => 'sirsoft-ecommerce',
'type' => 'hit',
'response_time_ms' => null,
]);
}
/**
* recordMiss가 type='miss'이고 response_time_ms를 포함한 레코드를 생성합니다.
*/
public function test_record_miss_creates_miss_record_with_response_time(): void
{
$this->statsService->recordMiss('/products/1', 'ko', 'shop/show', 'sirsoft-ecommerce', 150);
$this->assertDatabaseHas('seo_cache_stats', [
'url' => '/products/1',
'locale' => 'ko',
'layout_name' => 'shop/show',
'module_identifier' => 'sirsoft-ecommerce',
'type' => 'miss',
'response_time_ms' => 150,
]);
}
/**
* recordHit에서 예외 발생 시 Log::warning으로 처리됩니다.
*/
public function test_record_hit_handles_exception_gracefully(): void
{
Log::shouldReceive('warning')
->once()
->withArgs(function (string $message, array $context) {
return str_contains($message, '캐시 히트 기록 실패')
&& $context['url'] === '/products/1';
});
// 테이블을 삭제하여 DB 예외를 강제 발생
Schema::drop('seo_cache_stats');
// 예외가 전파되지 않음을 확인
$this->statsService->recordHit('/products/1', 'ko');
}
/**
* recordMiss에서 예외 발생 시 Log::warning으로 처리됩니다.
*/
public function test_record_miss_handles_exception_gracefully(): void
{
Log::shouldReceive('warning')
->once()
->withArgs(function (string $message, array $context) {
return str_contains($message, '캐시 미스 기록 실패')
&& $context['url'] === '/products/1';
});
// 테이블을 삭제하여 DB 예외를 강제 발생
Schema::drop('seo_cache_stats');
// 예외가 전파되지 않음을 확인
$this->statsService->recordMiss('/products/1', 'ko', null, null, 200);
}
/**
* 데이터가 없을 때 getStats가 기본값을 반환합니다.
*/
public function test_get_stats_returns_defaults_with_no_data(): void
{
$stats = $this->statsService->getStats();
$this->assertSame(0, $stats['total_entries']);
$this->assertSame(0, $stats['hits']);
$this->assertSame(0, $stats['misses']);
$this->assertSame(0.0, $stats['hit_rate']);
$this->assertNull($stats['avg_response_time_ms']);
}
/**
* getStats가 올바른 히트율을 계산합니다.
*/
public function test_get_stats_returns_correct_hit_rate(): void
{
// 3개 히트, 2개 미스 = 60% 히트율
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/4', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
SeoCacheStat::create(['url' => '/p/5', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 200]);
$stats = $this->statsService->getStats();
$this->assertSame(5, $stats['total_entries']);
$this->assertSame(3, $stats['hits']);
$this->assertSame(2, $stats['misses']);
$this->assertSame(60.0, $stats['hit_rate']);
}
/**
* getStats가 since 파라미터로 기간 필터링합니다.
*/
public function test_get_stats_filters_by_since_date(): void
{
// 오래된 레코드 (5일 전)
$old = SeoCacheStat::create(['url' => '/old', 'locale' => 'ko', 'type' => 'hit']);
$old->created_at = Carbon::now()->subDays(5);
$old->save();
// 최근 레코드 (1일 전)
$recent = SeoCacheStat::create(['url' => '/recent', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
$recent->created_at = Carbon::now()->subDays(1);
$recent->save();
// 3일 이내 데이터만 조회
$stats = $this->statsService->getStats(Carbon::now()->subDays(3));
$this->assertSame(1, $stats['total_entries']);
$this->assertSame(0, $stats['hits']);
$this->assertSame(1, $stats['misses']);
}
/**
* getStats가 미스 레코드의 평균 응답 시간을 계산합니다.
*/
public function test_get_stats_calculates_avg_response_time_from_misses_only(): void
{
// 히트 (response_time_ms 없음)
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'type' => 'hit']);
// 미스 (response_time_ms 있음) - 평균 = (100 + 300) / 2 = 200
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 300]);
// 미스이지만 response_time_ms가 null
SeoCacheStat::create(['url' => '/p/4', 'locale' => 'ko', 'type' => 'miss']);
$stats = $this->statsService->getStats();
$this->assertSame(4, $stats['total_entries']);
$this->assertSame(200.0, $stats['avg_response_time_ms']);
}
/**
* getStatsByLayout이 레이아웃별로 통계를 그룹화합니다.
*/
public function test_get_stats_by_layout_groups_by_layout_name(): void
{
// shop/show 레이아웃: 2히트, 1미스
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'layout_name' => 'shop/show', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'layout_name' => 'shop/show', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'layout_name' => 'shop/show', 'type' => 'miss', 'response_time_ms' => 150]);
// shop/category 레이아웃: 1히트, 1미스
SeoCacheStat::create(['url' => '/c/1', 'locale' => 'ko', 'layout_name' => 'shop/category', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/c/2', 'locale' => 'ko', 'layout_name' => 'shop/category', 'type' => 'miss', 'response_time_ms' => 200]);
$result = $this->statsService->getStatsByLayout();
$this->assertCount(2, $result);
// layout_name 기준으로 정렬하여 검증
$byLayout = collect($result)->keyBy('layout_name');
$showStats = $byLayout['shop/show'];
$this->assertSame(3, $showStats['total']);
$this->assertSame(2, $showStats['hits']);
$this->assertSame(1, $showStats['misses']);
$this->assertSame(66.67, $showStats['hit_rate']);
$this->assertSame(150.0, $showStats['avg_response_time_ms']);
$categoryStats = $byLayout['shop/category'];
$this->assertSame(2, $categoryStats['total']);
$this->assertSame(1, $categoryStats['hits']);
$this->assertSame(1, $categoryStats['misses']);
$this->assertSame(50.0, $categoryStats['hit_rate']);
$this->assertSame(200.0, $categoryStats['avg_response_time_ms']);
}
/**
* getStatsByModule이 모듈별로 통계를 그룹화합니다.
*/
public function test_get_stats_by_module_groups_by_module_identifier(): void
{
// sirsoft-ecommerce 모듈: 3히트, 1미스
SeoCacheStat::create(['url' => '/p/1', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/2', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/3', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/p/4', 'locale' => 'ko', 'module_identifier' => 'sirsoft-ecommerce', 'type' => 'miss', 'response_time_ms' => 250]);
// sirsoft-blog 모듈: 1히트, 2미스
SeoCacheStat::create(['url' => '/b/1', 'locale' => 'ko', 'module_identifier' => 'sirsoft-blog', 'type' => 'hit']);
SeoCacheStat::create(['url' => '/b/2', 'locale' => 'ko', 'module_identifier' => 'sirsoft-blog', 'type' => 'miss', 'response_time_ms' => 100]);
SeoCacheStat::create(['url' => '/b/3', 'locale' => 'ko', 'module_identifier' => 'sirsoft-blog', 'type' => 'miss', 'response_time_ms' => 300]);
$result = $this->statsService->getStatsByModule();
$this->assertCount(2, $result);
$byModule = collect($result)->keyBy('module_identifier');
$ecommerceStats = $byModule['sirsoft-ecommerce'];
$this->assertSame(4, $ecommerceStats['total']);
$this->assertSame(3, $ecommerceStats['hits']);
$this->assertSame(1, $ecommerceStats['misses']);
$this->assertSame(75.0, $ecommerceStats['hit_rate']);
$this->assertSame(250.0, $ecommerceStats['avg_response_time_ms']);
$blogStats = $byModule['sirsoft-blog'];
$this->assertSame(3, $blogStats['total']);
$this->assertSame(1, $blogStats['hits']);
$this->assertSame(2, $blogStats['misses']);
$this->assertSame(33.33, $blogStats['hit_rate']);
$this->assertSame(200.0, $blogStats['avg_response_time_ms']);
}
/**
* cleanup이 오래된 레코드를 삭제하고 최근 레코드를 유지합니다.
*/
public function test_cleanup_deletes_old_records_and_keeps_recent(): void
{
// 오래된 레코드 (40일 전)
$old1 = SeoCacheStat::create(['url' => '/old/1', 'locale' => 'ko', 'type' => 'hit']);
$old1->created_at = Carbon::now()->subDays(40);
$old1->save();
$old2 = SeoCacheStat::create(['url' => '/old/2', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 100]);
$old2->created_at = Carbon::now()->subDays(35);
$old2->save();
// 최근 레코드 (10일 전)
$recent1 = SeoCacheStat::create(['url' => '/recent/1', 'locale' => 'ko', 'type' => 'hit']);
$recent1->created_at = Carbon::now()->subDays(10);
$recent1->save();
// 오늘 레코드
SeoCacheStat::create(['url' => '/today/1', 'locale' => 'ko', 'type' => 'miss', 'response_time_ms' => 200]);
$deleted = $this->statsService->cleanup(30);
$this->assertSame(2, $deleted);
$this->assertDatabaseCount('seo_cache_stats', 2);
$this->assertDatabaseMissing('seo_cache_stats', ['url' => '/old/1']);
$this->assertDatabaseMissing('seo_cache_stats', ['url' => '/old/2']);
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/recent/1']);
$this->assertDatabaseHas('seo_cache_stats', ['url' => '/today/1']);
}
}