대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 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)에 따라 마감 단계에서 일괄 수행한다.
280 lines
8.9 KiB
PHP
280 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Seo;
|
|
|
|
use App\Extension\TemplateManager;
|
|
use App\Seo\Contracts\SitemapContributorInterface;
|
|
use App\Services\TemplateService;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Sitemap XML 생성기
|
|
*
|
|
* 정적 라우트(TemplateRouteResolver 기반)와
|
|
* 등록된 SitemapContributorInterface 구현체로부터 URL을 수집하여
|
|
* sitemap.xml을 생성합니다.
|
|
*/
|
|
class SitemapGenerator
|
|
{
|
|
/** @var SitemapContributorInterface[] */
|
|
private array $contributors = [];
|
|
|
|
/**
|
|
* SitemapGenerator 생성자
|
|
*
|
|
* @param TemplateRouteResolver $routeResolver 템플릿 라우트 해석기
|
|
* @param TemplateService $templateService 템플릿 서비스
|
|
*/
|
|
public function __construct(
|
|
private readonly TemplateRouteResolver $routeResolver,
|
|
private readonly TemplateService $templateService,
|
|
) {}
|
|
|
|
/**
|
|
* Sitemap 기여자를 등록합니다.
|
|
*
|
|
* @param SitemapContributorInterface $contributor Sitemap 기여자
|
|
*/
|
|
public function registerContributor(SitemapContributorInterface $contributor): void
|
|
{
|
|
$this->contributors[$contributor->getIdentifier()] = $contributor;
|
|
}
|
|
|
|
/**
|
|
* 수집한 URL을 SitemapWriter로 스트리밍하여 분할 sitemap 파일을 생성합니다.
|
|
*
|
|
* 기여자를 하나씩 순회하며 URL을 writer 에 한 건씩 넘기므로
|
|
* 전체 URL 배열을 메모리에 병합(array_merge)하지 않습니다.
|
|
*
|
|
* @param SitemapWriter $writer 분할 기록기
|
|
* @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array}
|
|
* 커밋된 sitemap 메타데이터
|
|
*/
|
|
public function generateToWriter(SitemapWriter $writer): array
|
|
{
|
|
$writer->open();
|
|
|
|
foreach ($this->collectStaticRoutes() as $entry) {
|
|
$writer->addUrl($entry);
|
|
}
|
|
|
|
foreach ($this->contributors as $contributor) {
|
|
try {
|
|
foreach ($this->drain($contributor) as $entry) {
|
|
$writer->addUrl($this->normalizeEntry($entry));
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('[SEO] Sitemap contributor failed', [
|
|
'contributor' => $contributor->getIdentifier(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$writer->close();
|
|
|
|
return $writer->commit();
|
|
}
|
|
|
|
/**
|
|
* 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다.
|
|
*
|
|
* @param SitemapContributorInterface $contributor Sitemap 기여자
|
|
* @return iterable<array<string, mixed>> URL 항목 순회자
|
|
*/
|
|
private function drain(SitemapContributorInterface $contributor): iterable
|
|
{
|
|
return $contributor->getUrls();
|
|
}
|
|
|
|
/**
|
|
* 기여자 URL 항목의 'url' 키를 'loc'(절대 URL)으로 변환합니다.
|
|
*
|
|
* @param array<string, mixed> $entry URL 항목
|
|
* @return array<string, mixed> 변환된 URL 항목
|
|
*/
|
|
private function normalizeEntry(array $entry): array
|
|
{
|
|
if (isset($entry['url']) && ! isset($entry['loc'])) {
|
|
$entry['loc'] = url($entry['url']);
|
|
unset($entry['url']);
|
|
}
|
|
|
|
return $entry;
|
|
}
|
|
|
|
/**
|
|
* URL 항목 하나를 <url> 블록 XML로 렌더링합니다.
|
|
*
|
|
* XML 이스케이프의 단일 출처는 SitemapXmlRenderer 이며 이 메서드는 위임합니다.
|
|
*
|
|
* @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목
|
|
* @return string <url> 블록 문자열
|
|
*/
|
|
public function renderUrlBlock(array $entry): string
|
|
{
|
|
return SitemapXmlRenderer::fromConfig()->urlBlock($entry);
|
|
}
|
|
|
|
/**
|
|
* urlset 여는 태그(XML 선언 포함)를 반환합니다.
|
|
*
|
|
* @return string urlset 헤더 문자열
|
|
*/
|
|
public function buildUrlsetHeader(): string
|
|
{
|
|
return SitemapXmlRenderer::fromConfig()->urlsetHeader();
|
|
}
|
|
|
|
/**
|
|
* urlset 닫는 태그를 반환합니다.
|
|
*
|
|
* @return string urlset 푸터 문자열
|
|
*/
|
|
public function buildUrlsetFooter(): string
|
|
{
|
|
return SitemapXmlRenderer::fromConfig()->urlsetFooter();
|
|
}
|
|
|
|
/**
|
|
* 전체 Sitemap URL을 수집하여 XML을 생성합니다.
|
|
*
|
|
* 전체 URL을 메모리에 적재하므로 소규모 사이트 / 하위호환 용도로만 사용합니다.
|
|
* 대용량은 generateToWriter() 를 사용합니다.
|
|
*
|
|
* @return string sitemap XML 문자열
|
|
*/
|
|
public function generate(): string
|
|
{
|
|
$urls = [];
|
|
|
|
// 1. 정적 라우트 수집 (파라미터 없는 공개 라우트)
|
|
$urls = array_merge($urls, $this->collectStaticRoutes());
|
|
|
|
// 2. 기여자들의 동적 URL 수집
|
|
foreach ($this->contributors as $contributor) {
|
|
try {
|
|
// 기여자 URL의 'url' 키를 'loc'(절대 URL)으로 변환
|
|
$contributorUrls = array_map(
|
|
fn (array $entry): array => $this->normalizeEntry($entry),
|
|
$contributor->getUrls(),
|
|
);
|
|
|
|
$urls = array_merge($urls, $contributorUrls);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('[SEO] Sitemap contributor failed', [
|
|
'contributor' => $contributor->getIdentifier(),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $this->buildXml($urls);
|
|
}
|
|
|
|
/**
|
|
* 등록된 기여자 목록을 반환합니다.
|
|
*
|
|
* @return SitemapContributorInterface[] 기여자 배열
|
|
*/
|
|
public function getContributors(): array
|
|
{
|
|
return $this->contributors;
|
|
}
|
|
|
|
/**
|
|
* 정적 라우트를 수집합니다 (파라미터 없는 공개 라우트만).
|
|
*
|
|
* @return array<int, array{loc: string, changefreq?: string, priority?: float}> 정적 URL 배열
|
|
*/
|
|
private function collectStaticRoutes(): array
|
|
{
|
|
try {
|
|
$activeTemplate = TemplateManager::getActiveTemplate('user');
|
|
if (! $activeTemplate) {
|
|
return [];
|
|
}
|
|
|
|
$templateIdentifier = $activeTemplate['identifier'] ?? null;
|
|
if (! $templateIdentifier) {
|
|
return [];
|
|
}
|
|
|
|
$routesResult = $this->templateService->getRoutesDataWithModules($templateIdentifier);
|
|
if (! ($routesResult['success'] ?? false) || empty($routesResult['data']['routes'])) {
|
|
return [];
|
|
}
|
|
|
|
$urls = [];
|
|
foreach ($routesResult['data']['routes'] as $route) {
|
|
// auth_required 라우트 제외
|
|
if ($route['auth_required'] ?? false) {
|
|
continue;
|
|
}
|
|
|
|
// guest_only 라우트 제외
|
|
if ($route['guest_only'] ?? false) {
|
|
continue;
|
|
}
|
|
|
|
$routePath = $route['path'] ?? '';
|
|
|
|
// 동적 파라미터(:id, :slug) 포함 라우트 제외
|
|
if (str_contains($routePath, ':')) {
|
|
continue;
|
|
}
|
|
|
|
// 템플릿 표현식({{...}}) 포함 라우트 제외
|
|
if (str_contains($routePath, '{{')) {
|
|
continue;
|
|
}
|
|
|
|
// 와일드카드(*) 접두사 제거
|
|
$routePath = ltrim($routePath, '*/');
|
|
if (! str_starts_with($routePath, '/')) {
|
|
$routePath = '/'.$routePath;
|
|
}
|
|
|
|
$urls[] = [
|
|
'loc' => url($routePath),
|
|
'changefreq' => 'weekly',
|
|
'priority' => 0.5,
|
|
];
|
|
}
|
|
|
|
return $urls;
|
|
} catch (\Throwable $e) {
|
|
Log::warning('[SEO] Static route collection failed', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* URL 배열을 다국어 sitemap XML로 변환합니다.
|
|
*
|
|
* supported_locales가 2개 이상이면 각 기본 URL에 대해 로케일별 <url> 항목을 생성하고,
|
|
* 각 항목에 xhtml:link hreflang alternate 태그를 포함합니다.
|
|
* 로케일이 1개뿐이면 기존 단일 언어 형식을 유지합니다.
|
|
*
|
|
* @param array $urls URL 배열 (loc 키에 기본 로케일 절대 URL 포함)
|
|
* @return string XML 문자열
|
|
*/
|
|
private function buildXml(array $urls): string
|
|
{
|
|
$renderer = SitemapXmlRenderer::fromConfig();
|
|
|
|
$xml = $renderer->urlsetHeader();
|
|
|
|
foreach ($urls as $entry) {
|
|
$xml .= $renderer->urlBlock($entry);
|
|
}
|
|
|
|
$xml .= $renderer->urlsetFooter();
|
|
|
|
return $xml;
|
|
}
|
|
}
|