대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 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)에 따라 마감 단계에서 일괄 수행한다.
208 lines
6.2 KiB
PHP
208 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Seo;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
/**
|
|
* Sitemap 파일 저장소 (읽기측)
|
|
*
|
|
* SitemapWriter 가 비공개 디스크에 커밋한 sitemap 인덱스/자식 파일을 조회하고
|
|
* 스트리밍 응답으로 반환합니다. public/storage 심볼릭 링크에 의존하지 않으며,
|
|
* 자식 파일(최대 50MB)을 메모리에 적재하지 않고 스트리밍합니다.
|
|
*
|
|
* 디스크 레이아웃(카테고리 cache 하위):
|
|
* sitemap/manifest.json 커밋 마커 + 자식 목록 메타
|
|
* sitemap/sitemap.xml sitemapindex
|
|
* sitemap/sitemap-{n}.xml 자식 sitemap (gzip 시 .xml.gz)
|
|
* sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
|
|
*/
|
|
class SitemapFileStore
|
|
{
|
|
/**
|
|
* StorageInterface 카테고리
|
|
*/
|
|
public const CATEGORY = 'cache';
|
|
|
|
/**
|
|
* 커밋된 sitemap 파일 디렉토리 (카테고리 하위 상대 경로)
|
|
*/
|
|
public const LIVE_DIR = 'sitemap';
|
|
|
|
/**
|
|
* 생성 중 임시 디렉토리 (카테고리 하위 상대 경로)
|
|
*/
|
|
public const TMP_DIR = 'sitemap/_tmp';
|
|
|
|
/**
|
|
* 커밋 마커 파일명
|
|
*/
|
|
public const MANIFEST_FILE = 'manifest.json';
|
|
|
|
/**
|
|
* sitemapindex 파일명
|
|
*/
|
|
public const INDEX_FILE = 'sitemap.xml';
|
|
|
|
/**
|
|
* SitemapFileStore 생성자
|
|
*
|
|
* @param StorageInterface $storage 비공개 디스크 스토리지 드라이버
|
|
*/
|
|
public function __construct(
|
|
private readonly StorageInterface $storage,
|
|
) {}
|
|
|
|
/**
|
|
* 자식 sitemap 파일명을 반환합니다.
|
|
*
|
|
* 파일명 규칙의 단일 출처이며, SitemapWriter(기록)와 web 라우트(서빙)가 공유합니다.
|
|
*
|
|
* @param int $n 자식 번호 (1부터)
|
|
* @param bool $gzip gzip 압축 여부
|
|
* @return string 자식 파일명 (예: sitemap-1.xml, sitemap-1.xml.gz)
|
|
*/
|
|
public static function childFilename(int $n, bool $gzip = false): string
|
|
{
|
|
return 'sitemap-'.$n.'.xml'.($gzip ? '.gz' : '');
|
|
}
|
|
|
|
/**
|
|
* 자식 sitemap 의 공개 절대 URL 을 반환합니다.
|
|
*
|
|
* sitemapindex 의 <loc> 값이며, web 라우트 `/sitemap-{n}.xml` 과 대응합니다.
|
|
*
|
|
* @param int $n 자식 번호 (1부터)
|
|
* @param bool $gzip gzip 압축 여부
|
|
* @return string 자식 sitemap 절대 URL
|
|
*/
|
|
public static function childUrl(int $n, bool $gzip = false): string
|
|
{
|
|
return url('/'.self::childFilename($n, $gzip));
|
|
}
|
|
|
|
/**
|
|
* 커밋된 sitemap 세트가 존재하는지 확인합니다.
|
|
*
|
|
* manifest.json 존재 = 커밋 완료 신호입니다.
|
|
*
|
|
* @return bool 서빙 가능한 세트 존재 여부
|
|
*/
|
|
public function exists(): bool
|
|
{
|
|
return $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE);
|
|
}
|
|
|
|
/**
|
|
* manifest 메타데이터를 반환합니다.
|
|
*
|
|
* @return array{generated_at?: string, url_count?: int, child_count?: int, size_bytes?: int, gzip?: bool, children?: array}|null
|
|
* manifest 배열 (없거나 손상 시 null)
|
|
*/
|
|
public function getManifest(): ?array
|
|
{
|
|
$raw = $this->storage->get(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE);
|
|
if ($raw === null || $raw === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($raw, true);
|
|
if (! is_array($decoded)) {
|
|
Log::warning('[SEO] Sitemap manifest 손상', ['path' => self::LIVE_DIR.'/'.self::MANIFEST_FILE]);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
/**
|
|
* sitemapindex 를 스트리밍 응답으로 반환합니다.
|
|
*
|
|
* @return StreamedResponse|null 인덱스 스트림 (세트가 없으면 null)
|
|
*/
|
|
public function indexResponse(): ?StreamedResponse
|
|
{
|
|
if (! $this->exists()) {
|
|
return null;
|
|
}
|
|
|
|
return $this->storage->response(
|
|
self::CATEGORY,
|
|
self::LIVE_DIR.'/'.self::INDEX_FILE,
|
|
self::INDEX_FILE,
|
|
['Content-Type' => 'application/xml'],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 자식 sitemap 을 스트리밍 응답으로 반환합니다.
|
|
*
|
|
* 실제 저장된 파일명은 manifest 에서 조회하므로, 생성 이후 gzip 설정이
|
|
* 바뀌어도 커밋된 세트를 그대로 서빙합니다.
|
|
*
|
|
* @param int $n 자식 번호 (1부터)
|
|
* @return StreamedResponse|null 자식 스트림 (없으면 null)
|
|
*/
|
|
public function childResponse(int $n): ?StreamedResponse
|
|
{
|
|
$filename = $this->childFile($n);
|
|
if ($filename === null) {
|
|
return null;
|
|
}
|
|
|
|
$headers = ['Content-Type' => 'application/xml'];
|
|
if (str_ends_with($filename, '.gz')) {
|
|
$headers['Content-Encoding'] = 'gzip';
|
|
}
|
|
|
|
return $this->storage->response(
|
|
self::CATEGORY,
|
|
self::LIVE_DIR.'/'.$filename,
|
|
$filename,
|
|
$headers,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 커밋된 sitemap 의 생성 시각을 반환합니다.
|
|
*
|
|
* @return string|null ISO8601 생성 시각 (세트가 없으면 null)
|
|
*/
|
|
public function lastGeneratedAt(): ?string
|
|
{
|
|
$manifest = $this->getManifest();
|
|
|
|
return $manifest['generated_at'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 자식 번호에 해당하는 실제 파일명을 반환합니다.
|
|
*
|
|
* @param int $n 자식 번호 (1부터)
|
|
* @return string|null 파일명 (존재하지 않으면 null)
|
|
*/
|
|
private function childFile(int $n): ?string
|
|
{
|
|
if ($n < 1) {
|
|
return null;
|
|
}
|
|
|
|
$manifest = $this->getManifest();
|
|
$children = $manifest['children'] ?? [];
|
|
|
|
$filename = $children[$n - 1]['file'] ?? null;
|
|
if (! is_string($filename) || $filename === '') {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.$filename)) {
|
|
return null;
|
|
}
|
|
|
|
return $filename;
|
|
}
|
|
}
|