feat(seo): 사이트맵 안전 서빙·잡 견고화·분할 설정 + 캐시 설정 정합

봇 요청 스레드의 동기 생성을 제거하고 디스크 스트리밍 서빙으로 전환했다.
신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale) 또는 503+Retry-After 로
응답해, 대용량에서 봇 요청이 OOM/타임아웃을 유발하던 경로를 끊었다.

- 잡: ShouldBeUnique(고정 uniqueId)+uniqueFor+retryUntil, timeout 300→1800, backoff
- 컨트롤러: 인덱스 stale/503 분기 + 자식 라우트(.xml/.xml.gz 동일 액션)
- 스케줄 withoutOverlapping, 관리자 재생성 큐 이관
- 설정 4키(분할 기준·압축·stale 서빙·수집기 상한) + 고급탭 Sitemap 캐시 짝
- SitemapManager 응답 메시지 다국어화

설정 저장 테스트가 실제 설정 파일(사이트명/언어 포함)을 덮어쓰던 사전결함을
TestCase 전역 격리로 차단하고, 두 탭이 같은 다국어 키를 공유해 한쪽에 틀린
한도를 안내하던 사전결함을 키 분리로 해소했다.
This commit is contained in:
HeuJung committed 2026-07-18 11:32:11 +09:00
1 parent 21f666e4b5
commit 1c99547bc1
71 files changed
+3767 -2472

No files matched your search

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCacheClearRequest;
use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheStatsService;
use App\Seo\SitemapManager;
@@ -54,7 +55,7 @@ class SeoCacheController extends AdminBaseController
*
* 레이아웃 또는 모듈 지정 시 해당 캐시만, 미지정 시 전체 캐시를 삭제합니다.
*
* @param SeoCacheClearRequest $request 캐시 삭제 요청
* @param SeoCacheClearRequest $request 캐시 삭제 요청
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function clearCache(SeoCacheClearRequest $request): JsonResponse
@@ -97,28 +98,25 @@ class SeoCacheController extends AdminBaseController
}
/**
* Sitemap XML 을 즉시 재생성합니다.
* Sitemap XML 재생성을 큐에 예약합니다.
*
* 큐 드라이버와 무관하게 동기 실행되며, 생성 완료 후 last_updated_at 을 갱신합니다.
* 대용량(수백만 URL) 사이트에서 요청 스레드 동기 생성은 메모리/타임아웃 붕괴를 일으키므로
* 큐 잡으로 위임합니다. 응답은 예약 시점의 상태이며, 실제 완료 시각은 이후 갱신됩니다.
*
* @return JsonResponse 재생성 결과 JSON 응답
* @return JsonResponse 예약 결과 JSON 응답
*/
public function regenerateSitemap(): JsonResponse
{
$result = $this->sitemapManager->regenerate();
if ($result['success']) {
return $this->success('seo.sitemap_regenerated', $result['data'] ?? null);
if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) {
return $this->error('seo.sitemap_disabled', 400);
}
$messageKey = match ($result['status']) {
'disabled' => 'seo.sitemap_disabled',
default => 'seo.sitemap_regenerate_failed',
};
GenerateSitemapJob::dispatch();
$statusCode = $result['status'] === 'disabled' ? 400 : 500;
return $this->error($messageKey, $statusCode, $result['message'] ?? null);
return $this->success('seo.sitemap_regenerate_dispatched', [
'status' => 'dispatched',
...$this->sitemapManager->getStatus(),
]);
}
/**
@@ -4,44 +4,92 @@ namespace App\Http\Controllers\Api\Public;
use App\Contracts\Extension\CacheInterface;
use App\Http\Controllers\Controller;
use App\Seo\SeoCacheSettings;
use App\Seo\SitemapGenerator;
use Illuminate\Http\Response;
use App\Jobs\GenerateSitemapJob;
use App\Seo\SitemapFileStore;
use App\Seo\SitemapManager;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Sitemap XML 컨트롤러
*
* sitemap.xml 요청을 처리하여 캐시된 또는 실시간 생성된
* Sitemap XML을 반환합니다.
* 비공개 디스크에 커밋된 sitemap 인덱스/자식 파일을 스트리밍으로 서빙합니다.
* 봇 요청 스레드에서 sitemap 을 생성하지 않으며(대용량에서 OOM/타임아웃 원인),
* 신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale)를 그대로 내보냅니다.
*/
class SitemapController extends Controller
{
/**
* sitemap.xml을 반환합니다.
*
* 캐시가 있으면 캐시된 XML, 없으면 실시간 생성 후 캐시 저장.
*
* @param SitemapGenerator $generator Sitemap 생성기
* @param CacheInterface $cache 코어 캐시 드라이버
* @return Response Sitemap XML 응답
* 세트가 없어 503 을 반환할 때 봇에 안내할 재시도 간격 (초)
*/
public function index(SitemapGenerator $generator, CacheInterface $cache): Response
private const RETRY_AFTER_SECONDS = 120;
/**
* sitemapindex 를 반환합니다.
*
* 커밋된 세트가 있으면 스트리밍하고, 신선도(메타 캐시)가 만료되었으면
* 생성 잡을 예약합니다. 세트가 전혀 없으면 503 + Retry-After 를 반환합니다.
*
* @param SitemapFileStore $store sitemap 파일 저장소 (읽기측)
* @param CacheInterface $cache 코어 캐시 드라이버
* @return StreamedResponse sitemapindex 스트림
*/
public function index(SitemapFileStore $store, CacheInterface $cache): StreamedResponse
{
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
if (! $enabled) {
$this->assertSitemapEnabled();
$isFresh = $cache->get(SitemapManager::META_CACHE_KEY) !== null;
if ($isFresh) {
$response = $store->indexResponse();
if ($response !== null) {
return $response;
}
}
// 신선도 만료 또는 세트 부재 — 생성은 큐에 맡긴다 (ShouldBeUnique 로 중복 디스패치 방지).
GenerateSitemapJob::dispatch();
$serveStale = (bool) g7_core_settings('seo.sitemap_serve_stale_on_miss', true);
if ($serveStale) {
$response = $store->indexResponse();
if ($response !== null) {
return $response;
}
}
abort(503, __('seo.sitemap_not_ready'), ['Retry-After' => (string) self::RETRY_AFTER_SECONDS]);
}
/**
* 자식 sitemap 을 반환합니다.
*
* gzip 여부는 manifest 가 결정하므로 `.xml` / `.xml.gz` 요청을 같은 액션에서 처리합니다.
*
* @param int $n 자식 번호 (1부터)
* @param SitemapFileStore $store sitemap 파일 저장소 (읽기측)
* @return StreamedResponse 자식 sitemap 스트림
*/
public function child(int $n, SitemapFileStore $store): StreamedResponse
{
$this->assertSitemapEnabled();
$response = $store->childResponse($n);
if ($response === null) {
abort(404);
}
$xml = $cache->get('seo.sitemap');
return $response;
}
if (! $xml) {
$xml = $generator->generate();
// 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19)
$cache->put('seo.sitemap', $xml, SeoCacheSettings::sitemapCacheTtl());
/**
* Sitemap 기능이 비활성화되어 있으면 404 로 중단합니다.
*/
private function assertSitemapEnabled(): void
{
if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) {
abort(404);
}
return response($xml, 200, [
'Content-Type' => 'application/xml',
]);
}
}