Files
Gnuboard7/app/Seo/SitemapXmlRenderer.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

250 lines
7.6 KiB
PHP

<?php
namespace App\Seo;
/**
* Sitemap XML 조각 렌더러
*
* urlset 헤더/푸터, URL 단위 <url> 블록, sitemapindex 를 생성합니다.
* Sitemap XML 의 이스케이프 로직을 이 클래스 한 곳에서만 수행하며(단일 출처),
* SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다.
*
* 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 <url> 블록을 생성하고
* 각 블록에 xhtml:link hreflang alternate 를 포함합니다.
*/
final class SitemapXmlRenderer
{
/**
* 다국어 여부 (지원 로케일 2개 이상)
*/
private readonly bool $multilingual;
/**
* SitemapXmlRenderer 생성자
*
* @param array<int, string> $supportedLocales 지원 로케일 목록
* @param string $defaultLocale 기본 로케일
*/
public function __construct(
private readonly array $supportedLocales,
private readonly string $defaultLocale,
) {
$this->multilingual = count($this->supportedLocales) > 1;
}
/**
* 애플리케이션 로케일 설정으로 렌더러를 생성합니다.
*
* @return self 설정 기반 렌더러 인스턴스
*/
public static function fromConfig(): self
{
$defaultLocale = (string) config('app.locale');
return new self(
(array) config('app.supported_locales', [$defaultLocale]),
$defaultLocale,
);
}
/**
* 다국어 모드 여부를 반환합니다.
*
* @return bool 지원 로케일이 2개 이상이면 true
*/
public function isMultilingual(): bool
{
return $this->multilingual;
}
/**
* URL 항목 하나가 생성하는 <url> 블록 개수를 반환합니다.
*
* 분할 임계(파일당 URL 수) 계산에 사용됩니다.
*
* @return int 다국어면 로케일 수, 단일 언어면 1
*/
public function urlBlockCount(): int
{
return $this->multilingual ? count($this->supportedLocales) : 1;
}
/**
* urlset 여는 태그(XML 선언 포함)를 반환합니다.
*
* @return string urlset 헤더 문자열
*/
public function urlsetHeader(): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
if ($this->multilingual) {
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'."\n";
$xml .= ' xmlns:xhtml="http://www.w3.org/1999/xhtml">'."\n";
} else {
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
}
return $xml;
}
/**
* urlset 닫는 태그를 반환합니다.
*
* @return string urlset 푸터 문자열
*/
public function urlsetFooter(): string
{
return '</urlset>';
}
/**
* URL 항목 하나를 <url> 블록으로 렌더링합니다.
*
* 다국어면 로케일 수만큼의 <url> 블록을 이어붙여 반환합니다.
*
* @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 (loc 은 절대 URL)
* @return string <url> 블록 문자열 (loc 이 없으면 빈 문자열)
*/
public function urlBlock(array $entry): string
{
$baseLoc = $entry['loc'] ?? '';
if ($baseLoc === '') {
return '';
}
if (! $this->multilingual) {
return $this->renderSingleUrl($baseLoc, $entry);
}
$xml = '';
foreach ($this->supportedLocales as $locale) {
$xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale);
}
return $xml;
}
/**
* 자식 sitemap 목록을 sitemapindex XML 로 렌더링합니다.
*
* @param array<int, array{loc: string, lastmod?: ?string}> $children 자식 sitemap 목록
* @return string sitemapindex XML 문자열
*/
public function sitemapIndex(array $children): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
$xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
foreach ($children as $child) {
$xml .= ' <sitemap>'."\n";
$xml .= ' <loc>'.$this->escape($child['loc']).'</loc>'."\n";
if (! empty($child['lastmod'])) {
$xml .= ' <lastmod>'.$this->escape($child['lastmod']).'</lastmod>'."\n";
}
$xml .= ' </sitemap>'."\n";
}
$xml .= '</sitemapindex>';
return $xml;
}
/**
* 단일 언어 <url> 블록을 렌더링합니다.
*
* @param string $loc 절대 URL
* @param array $entry URL 항목
* @return string <url> 블록 문자열
*/
private function renderSingleUrl(string $loc, array $entry): string
{
$xml = ' <url>'."\n";
$xml .= ' <loc>'.$this->escape($loc).'</loc>'."\n";
$xml .= $this->renderMetaFields($entry);
$xml .= ' </url>'."\n";
return $xml;
}
/**
* 특정 로케일의 <url> 블록(hreflang alternate 포함)을 렌더링합니다.
*
* @param string $baseLoc 기본 로케일 절대 URL
* @param array $entry URL 항목
* @param string $locale 대상 로케일
* @return string <url> 블록 문자열
*/
private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale): string
{
$xml = ' <url>'."\n";
$xml .= ' <loc>'.$this->escape($this->localizedLoc($baseLoc, $locale)).'</loc>'."\n";
$xml .= $this->renderMetaFields($entry);
// 모든 로케일의 hreflang alternate 링크
foreach ($this->supportedLocales as $altLocale) {
$altHref = $this->localizedLoc($baseLoc, (string) $altLocale);
$xml .= ' <xhtml:link rel="alternate" hreflang="'.$this->escape((string) $altLocale).'" href="'.$this->escape($altHref).'"/>'."\n";
}
// x-default = 기본 로케일 URL
$xml .= ' <xhtml:link rel="alternate" hreflang="x-default" href="'.$this->escape($baseLoc).'"/>'."\n";
$xml .= ' </url>'."\n";
return $xml;
}
/**
* lastmod / changefreq / priority 필드를 렌더링합니다.
*
* @param array $entry URL 항목
* @return string 메타 필드 문자열
*/
private function renderMetaFields(array $entry): string
{
$xml = '';
if (! empty($entry['lastmod'])) {
$xml .= ' <lastmod>'.$this->escape((string) $entry['lastmod']).'</lastmod>'."\n";
}
if (! empty($entry['changefreq'])) {
$xml .= ' <changefreq>'.$this->escape((string) $entry['changefreq']).'</changefreq>'."\n";
}
if (isset($entry['priority'])) {
$xml .= ' <priority>'.number_format((float) $entry['priority'], 1).'</priority>'."\n";
}
return $xml;
}
/**
* 로케일별 URL 을 생성합니다.
*
* @param string $baseLoc 기본 로케일 절대 URL
* @param string $locale 대상 로케일
* @return string 로케일 URL (기본 로케일이면 원본 그대로)
*/
private function localizedLoc(string $baseLoc, string $locale): string
{
return $locale === $this->defaultLocale
? $baseLoc
: $baseLoc.'?locale='.$locale;
}
/**
* XML 특수문자를 이스케이프합니다.
*
* @param string $value 원본 문자열
* @return string 이스케이프된 문자열
*/
private function escape(string $value): string
{
return htmlspecialchars($value, ENT_XML1, 'UTF-8');
}
}