대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 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)에 따라 마감 단계에서 일괄 수행한다.
250 lines
7.6 KiB
PHP
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');
|
|
}
|
|
}
|