대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 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)에 따라 마감 단계에서 일괄 수행한다.
339 lines
11 KiB
PHP
339 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Seo;
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Sitemap 파일 기록기 (스트리밍 분할 writer)
|
|
*
|
|
* URL 을 한 건씩 받아 자식 sitemap 파일로 분할 기록합니다.
|
|
* sitemaps.org 프로토콜(파일당 50,000 URL / 50MB)을 준수하며,
|
|
* 전체 URL 을 메모리에 적재하지 않습니다.
|
|
*
|
|
* StorageInterface 는 append 스트림을 제공하지 않고 put() 이 매 호출 파일 전체를
|
|
* 덮어쓰므로, 자식 파일 하나 분량만 메모리 버퍼에 쌓았다가 임계 도달 시 flush 합니다.
|
|
* 따라서 최대 메모리 사용량은 "자식 파일 1개 크기"로 제한됩니다.
|
|
*
|
|
* 커밋은 임시 디렉토리(_tmp)에 전량 기록한 뒤 live 로 재배치하고,
|
|
* manifest.json 을 마지막에 기록하는 방식으로 수행합니다(manifest = 커밋 마커).
|
|
*/
|
|
class SitemapWriter
|
|
{
|
|
/**
|
|
* 파일당 URL 수 상한 (sitemaps.org 프로토콜)
|
|
*/
|
|
public const HARD_URL_CAP = 50000;
|
|
|
|
/**
|
|
* 파일당 바이트 상한 (프로토콜 50MB 에 대한 안전 여유)
|
|
*/
|
|
public const MAX_FILE_BYTES = 45 * 1024 * 1024;
|
|
|
|
/**
|
|
* 현재 자식 파일 버퍼
|
|
*/
|
|
private string $buffer = '';
|
|
|
|
/**
|
|
* 현재 자식 파일에 기록된 <url> 블록 수
|
|
*/
|
|
private int $currentUrlCount = 0;
|
|
|
|
/**
|
|
* 현재 자식 파일 번호 (1부터)
|
|
*/
|
|
private int $childIndex = 0;
|
|
|
|
/**
|
|
* 현재 자식 파일이 열려 있는지 여부
|
|
*/
|
|
private bool $childOpen = false;
|
|
|
|
/**
|
|
* 현재 자식 파일의 최신 lastmod
|
|
*/
|
|
private ?string $childLastmod = null;
|
|
|
|
/**
|
|
* 기록된 자식 파일 목록
|
|
*
|
|
* @var array<int, array{file: string, loc: string, lastmod: ?string, url_count: int, size_bytes: int}>
|
|
*/
|
|
private array $children = [];
|
|
|
|
/**
|
|
* 전체 <url> 블록 수
|
|
*/
|
|
private int $totalUrls = 0;
|
|
|
|
/**
|
|
* 전체 자식 파일 바이트 합계
|
|
*/
|
|
private int $totalBytes = 0;
|
|
|
|
/**
|
|
* 파일당 URL 수 임계
|
|
*/
|
|
private readonly int $urlsPerFile;
|
|
|
|
/**
|
|
* SitemapWriter 생성자
|
|
*
|
|
* @param StorageInterface $storage 비공개 디스크 스토리지 드라이버
|
|
* @param SitemapXmlRenderer $renderer XML 조각 렌더러 (SitemapGenerator 와 공유)
|
|
* @param int $urlsPerFile 파일당 URL 수 (1 ~ HARD_URL_CAP 로 클램프)
|
|
* @param bool $gzip 자식 파일 gzip 압축 여부
|
|
*/
|
|
public function __construct(
|
|
private readonly StorageInterface $storage,
|
|
private readonly SitemapXmlRenderer $renderer,
|
|
int $urlsPerFile = self::HARD_URL_CAP,
|
|
private readonly bool $gzip = false,
|
|
) {
|
|
$this->urlsPerFile = max(1, min($urlsPerFile, self::HARD_URL_CAP));
|
|
}
|
|
|
|
/**
|
|
* 기록을 시작합니다 (임시 디렉토리 초기화 + 상태 리셋).
|
|
*/
|
|
public function open(): void
|
|
{
|
|
$this->purgeTmpDirectory();
|
|
|
|
$this->buffer = '';
|
|
$this->currentUrlCount = 0;
|
|
$this->childIndex = 0;
|
|
$this->childOpen = false;
|
|
$this->childLastmod = null;
|
|
$this->children = [];
|
|
$this->totalUrls = 0;
|
|
$this->totalBytes = 0;
|
|
}
|
|
|
|
/**
|
|
* URL 항목 하나를 기록합니다.
|
|
*
|
|
* 임계(URL 수 또는 바이트) 도달 시 현재 자식 파일을 flush 합니다.
|
|
*
|
|
* @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목
|
|
*/
|
|
public function addUrl(array $entry): void
|
|
{
|
|
$block = $this->renderer->urlBlock($entry);
|
|
if ($block === '') {
|
|
return;
|
|
}
|
|
|
|
if (! $this->childOpen) {
|
|
$this->openNextChild();
|
|
}
|
|
|
|
$this->buffer .= $block;
|
|
$this->currentUrlCount += $this->renderer->urlBlockCount();
|
|
$this->totalUrls += $this->renderer->urlBlockCount();
|
|
|
|
$lastmod = $entry['lastmod'] ?? null;
|
|
if (is_string($lastmod) && $lastmod !== '' && ($this->childLastmod === null || $lastmod > $this->childLastmod)) {
|
|
$this->childLastmod = $lastmod;
|
|
}
|
|
|
|
// static:: 바인딩 — 하위 클래스가 임계값을 낮춰 바이트 분할 분기를 검증할 수 있게 한다
|
|
// (45MB 를 실제로 채우지 않고 같은 경로를 태우기 위함).
|
|
if ($this->currentUrlCount >= $this->urlsPerFile || strlen($this->buffer) >= static::MAX_FILE_BYTES) {
|
|
$this->flushChild();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 기록을 마감합니다 (마지막 자식 flush + sitemapindex 기록).
|
|
*
|
|
* @return array{child_count: int, url_count: int, size_bytes: int, children: array} 기록 메타데이터
|
|
*/
|
|
public function close(): array
|
|
{
|
|
// 자식이 하나도 없으면(URL 0건) 빈 urlset 자식 1개를 남겨 인덱스를 유효하게 유지합니다.
|
|
if ($this->childOpen || $this->children === []) {
|
|
if (! $this->childOpen) {
|
|
$this->openNextChild();
|
|
}
|
|
$this->flushChild();
|
|
}
|
|
|
|
$indexXml = $this->renderer->sitemapIndex($this->children);
|
|
$this->putOrFail(SitemapFileStore::TMP_DIR.'/'.SitemapFileStore::INDEX_FILE, $indexXml);
|
|
|
|
return [
|
|
'child_count' => count($this->children),
|
|
'url_count' => $this->totalUrls,
|
|
'size_bytes' => $this->totalBytes,
|
|
'children' => $this->children,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 임시 디렉토리의 세트를 live 로 커밋합니다.
|
|
*
|
|
* 순서: (1) 기존 live 파일 목록 확보 → (2) _tmp 파일을 live 로 재배치 →
|
|
* (3) manifest.json 기록(커밋 마커) → (4) 새 세트에 없는 stale 파일 삭제 → (5) _tmp 정리.
|
|
*
|
|
* @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array}
|
|
* 커밋된 sitemap 메타데이터
|
|
*
|
|
* @throws \RuntimeException 파일 기록 실패 시
|
|
*/
|
|
public function commit(): array
|
|
{
|
|
$staleFiles = $this->liveFilenames();
|
|
|
|
$newFiles = [SitemapFileStore::INDEX_FILE];
|
|
foreach ($this->children as $child) {
|
|
$newFiles[] = $child['file'];
|
|
}
|
|
|
|
foreach ($newFiles as $filename) {
|
|
$content = $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/'.$filename);
|
|
if ($content === null) {
|
|
throw new \RuntimeException(__('exceptions.seo.sitemap_temp_file_unreadable', ['file' => $filename]));
|
|
}
|
|
|
|
$this->putOrFail(SitemapFileStore::LIVE_DIR.'/'.$filename, $content);
|
|
unset($content);
|
|
}
|
|
|
|
$manifest = [
|
|
'generated_at' => now()->toIso8601String(),
|
|
'child_count' => count($this->children),
|
|
'url_count' => $this->totalUrls,
|
|
'size_bytes' => $this->totalBytes,
|
|
'gzip' => $this->gzip,
|
|
'urls_per_file' => $this->urlsPerFile,
|
|
'children' => $this->children,
|
|
];
|
|
|
|
// manifest 는 마지막에 기록 — 존재 = 커밋 완료 신호
|
|
$this->putOrFail(
|
|
SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE,
|
|
json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
|
);
|
|
|
|
$this->deleteStaleFiles($staleFiles, $newFiles);
|
|
|
|
$this->purgeTmpDirectory();
|
|
|
|
return $manifest;
|
|
}
|
|
|
|
/**
|
|
* 임시 디렉토리를 정리합니다.
|
|
*
|
|
* 정리 실패는 치명적이지 않습니다 — 임시 파일은 이름 기준으로 덮어쓰이고,
|
|
* 커밋은 children 목록에 있는 파일만 live 로 옮기므로 잔여 파일이 결과에 섞이지 않습니다.
|
|
* 따라서 예외를 전파하지 않고 경고만 남겨 sitemap 생성 자체가 중단되지 않도록 합니다.
|
|
*/
|
|
private function purgeTmpDirectory(): void
|
|
{
|
|
try {
|
|
$this->storage->deleteDirectory(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('[SEO] Sitemap 임시 디렉토리 정리 실패 (생성은 계속 진행)', [
|
|
'directory' => SitemapFileStore::TMP_DIR,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 다음 자식 파일을 엽니다 (버퍼에 urlset 헤더 기록).
|
|
*/
|
|
private function openNextChild(): void
|
|
{
|
|
$this->childIndex++;
|
|
$this->buffer = $this->renderer->urlsetHeader();
|
|
$this->currentUrlCount = 0;
|
|
$this->childLastmod = null;
|
|
$this->childOpen = true;
|
|
}
|
|
|
|
/**
|
|
* 현재 자식 파일 버퍼를 임시 디렉토리에 기록하고 버퍼를 비웁니다.
|
|
*
|
|
* @throws \RuntimeException 파일 기록 실패 시
|
|
*/
|
|
private function flushChild(): void
|
|
{
|
|
$this->buffer .= $this->renderer->urlsetFooter();
|
|
|
|
$filename = SitemapFileStore::childFilename($this->childIndex, $this->gzip);
|
|
$content = $this->gzip ? gzencode($this->buffer, 6) : $this->buffer;
|
|
|
|
if ($content === false) {
|
|
throw new \RuntimeException(__('exceptions.seo.sitemap_gzip_failed', ['file' => $filename]));
|
|
}
|
|
|
|
$this->putOrFail(SitemapFileStore::TMP_DIR.'/'.$filename, $content);
|
|
|
|
$this->children[] = [
|
|
'file' => $filename,
|
|
'loc' => SitemapFileStore::childUrl($this->childIndex, $this->gzip),
|
|
'lastmod' => $this->childLastmod,
|
|
'url_count' => $this->currentUrlCount,
|
|
'size_bytes' => strlen($content),
|
|
];
|
|
$this->totalBytes += strlen($content);
|
|
|
|
$this->buffer = '';
|
|
$this->currentUrlCount = 0;
|
|
$this->childOpen = false;
|
|
}
|
|
|
|
/**
|
|
* live 디렉토리의 파일명 목록을 반환합니다.
|
|
*
|
|
* @return array<int, string> 파일명 배열 (디렉토리 제외)
|
|
*/
|
|
private function liveFilenames(): array
|
|
{
|
|
$paths = $this->storage->files(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR);
|
|
|
|
return array_map(static fn (string $path): string => basename($path), $paths);
|
|
}
|
|
|
|
/**
|
|
* 새 세트에 포함되지 않은 이전 자식 파일을 삭제합니다.
|
|
*
|
|
* @param array<int, string> $staleFiles 커밋 전 live 파일명 목록
|
|
* @param array<int, string> $newFiles 새 세트 파일명 목록
|
|
*/
|
|
private function deleteStaleFiles(array $staleFiles, array $newFiles): void
|
|
{
|
|
$keep = array_merge($newFiles, [SitemapFileStore::MANIFEST_FILE]);
|
|
|
|
foreach ($staleFiles as $filename) {
|
|
if (in_array($filename, $keep, true)) {
|
|
continue;
|
|
}
|
|
|
|
$this->storage->delete(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.$filename);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 파일을 기록하고 실패 시 예외를 던집니다.
|
|
*
|
|
* @param string $path 카테고리 하위 상대 경로
|
|
* @param string $content 파일 내용
|
|
*
|
|
* @throws \RuntimeException 기록 실패 시
|
|
*/
|
|
private function putOrFail(string $path, string $content): void
|
|
{
|
|
if (! $this->storage->put(SitemapFileStore::CATEGORY, $path, $content)) {
|
|
Log::error('[SEO] Sitemap 파일 기록 실패', ['path' => $path]);
|
|
|
|
throw new \RuntimeException(__('exceptions.seo.sitemap_write_failed', ['path' => $path]));
|
|
}
|
|
}
|
|
}
|