feat(seo): 사이트맵 기여자 지연 스트리밍 + hreflang 계산 선형화

AbstractSitemapContributor 브리지 base 로 인터페이스를 바꾸지 않고
board/ecommerce/page 기여자를 getUrlsLazy 지연 스트리밍으로 전환해,
게시글이 수백만 건인 사이트에서도 URL 배열을 통째로 적재하지 않는다.
SitemapGenerator 는 capability 를 감지해 지연 경로를 쓰고, base 를
상속하지 않은 제3자 구현체는 기존 getUrls 경로로 그대로 동작한다.

hreflang alternate 집합을 base URL 당 한 번만 계산해 로케일별 <url>
에 재사용하도록 바꿔 계산 비용을 로케일 수의 제곱에서 선형으로 줄였다
(정상 구성 출력은 불변). 다국어 대체 링크 사용 여부 토글과 로케일 수
상한을 추가하고 설정/검증/관리자 UI/다국어(ko·en·ja) 표면을 동반했다.
This commit is contained in:
HeuJung committed 2026-07-18 15:05:56 +09:00
1 parent 1c99547bc1
commit 1a6fca0d6b
28 files changed
+726 -179

No files matched your search

@@ -225,6 +225,7 @@ class SaveSettingsRequest extends FormRequest
'seo.sitemap_gzip' => ['nullable', 'boolean'],
'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'],
'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'],
'seo.sitemap_hreflang_enabled' => ['nullable', 'boolean'],
'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])],
'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'],
'seo.generator_enabled' => ['nullable', 'boolean'],
@@ -620,6 +621,7 @@ class SaveSettingsRequest extends FormRequest
'seo.sitemap_serve_stale_on_miss.boolean' => __('validation.settings.sitemap_serve_stale_on_miss_boolean'),
'seo.sitemap_max_urls_per_contributor.integer' => __('validation.settings.sitemap_max_urls_per_contributor_integer'),
'seo.sitemap_max_urls_per_contributor.min' => __('validation.settings.sitemap_max_urls_per_contributor_min'),
'seo.sitemap_hreflang_enabled.boolean' => __('validation.settings.sitemap_hreflang_enabled_boolean'),
'seo.sitemap_schedule.in' => __('validation.settings.sitemap_schedule_invalid'),
'seo.sitemap_schedule_time.regex' => __('validation.settings.sitemap_schedule_time_invalid'),
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Seo;
use App\Seo\Contracts\SitemapContributorInterface;
/**
* Sitemap 기여자 지연 스트리밍 브리지 base
*
* SitemapContributorInterface 는 손대지 않은 채(제3자 raw 구현체 무파손),
* 구현체가 getUrls()(배열) 또는 getUrlsLazy()(제너레이터) 중 하나만 구현하면
* 나머지 한쪽을 자동으로 제공하는 브리지를 놓습니다.
*
* - 대용량 기여자는 getUrlsLazy() 를 오버라이드해 URL 을 한 건씩 yield 하여
* 전체 배열을 메모리에 적재하지 않습니다(1.4M OOM 방지).
* - getUrls() 만 오버라이드한 소량 기여자는 getUrlsLazy() 가 그 배열을 순회합니다.
*
* SitemapGenerator 는 이 base 를 상속했거나 getUrlsLazy() 를 가진 기여자를
* 지연 경로로 소진합니다(capability 감지). base 를 상속하지 않은 raw 구현체는
* 기존 getUrls() 경로로 그대로 동작합니다.
*/
abstract class AbstractSitemapContributor implements SitemapContributorInterface
{
/**
* 확장 식별자를 반환합니다.
*
* @return string 확장 식별자 (예: 'sirsoft-ecommerce')
*/
abstract public function getIdentifier(): string;
/**
* Sitemap URL 항목 배열을 반환합니다.
*
* 지연 스트림을 배열로 실체화합니다. 대용량 기여자는 이 메서드 대신
* SitemapGenerator 가 소비하는 getUrlsLazy() 로 스트리밍되므로,
* 이 경로는 소규모/하위호환 용도입니다.
*
* @return array<int, array{loc?: string, url?: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
public function getUrls(): array
{
return iterator_to_array($this->getUrlsLazy(), false);
}
/**
* Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
* 기본 구현은 getUrls() 배열을 순회합니다. 대용량 기여자는 이 메서드를
* 오버라이드해 Repository 스트림을 직접 yield 하십시오.
*
* @return iterable<int, array{loc?: string, url?: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
public function getUrlsLazy(): iterable
{
yield from $this->getUrls();
}
}
+8
View File
@@ -79,11 +79,19 @@ class SitemapGenerator
/**
* 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다.
*
* 지연 스트리밍 capability(AbstractSitemapContributor 상속 또는 getUrlsLazy() 보유)를
* 감지해, 있으면 한 건씩 yield 되는 제너레이터를 사용합니다. 없으면 기존 getUrls()
* 배열을 그대로 순회합니다(제3자 raw 구현체 하위호환).
*
* @param SitemapContributorInterface $contributor Sitemap 기여자
* @return iterable<array<string, mixed>> URL 항목 순회자
*/
private function drain(SitemapContributorInterface $contributor): iterable
{
if ($contributor instanceof AbstractSitemapContributor || method_exists($contributor, 'getUrlsLazy')) {
return $contributor->getUrlsLazy();
}
return $contributor->getUrls();
}
+70 -11
View File
@@ -2,6 +2,8 @@
namespace App\Seo;
use Illuminate\Support\Facades\Log;
/**
* Sitemap XML 조각 렌더러
*
@@ -10,41 +12,75 @@ namespace App\Seo;
* SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다.
*
* 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 <url> 블록을 생성하고
* 각 블록에 xhtml:link hreflang alternate 를 포함합니다.
* 각 블록에 xhtml:link hreflang alternate 를 포함합니다. alternate 링크 집합은
* base URL 당 한 번만 계산해 로케일별 <url> 에 재사용하므로, alternate 생성 비용은
* 로케일 수의 제곱이 아니라 선형입니다.
*/
final class SitemapXmlRenderer
{
/**
* hreflang alternate 를 방출하는 지원 로케일 수 상한
*
* 로케일 수가 이 값을 넘으면 URL 하나당 alternate 링크가 과도하게 팽창하므로
* alternate 를 생략합니다(로케일별 <url> 자체는 유지). 정상 다국어 구성(2~수개)은
* 이 상한에 걸리지 않습니다.
*/
public const MAX_HREFLANG = 50;
/**
* 다국어 여부 (지원 로케일 2개 이상)
*/
private readonly bool $multilingual;
/**
* hreflang alternate 를 방출하는지 여부
*
* 다국어이면서 hreflang 이 켜져 있고 로케일 수가 상한 이하일 때만 true.
*/
private readonly bool $hreflangActive;
/**
* SitemapXmlRenderer 생성자
*
* @param array<int, string> $supportedLocales 지원 로케일 목록
* @param string $defaultLocale 기본 로케일
* @param bool $hreflangEnabled hreflang alternate 방출 여부 (기본 true)
* @param int $maxHreflang hreflang 을 방출할 로케일 수 상한
*/
public function __construct(
private readonly array $supportedLocales,
private readonly string $defaultLocale,
bool $hreflangEnabled = true,
int $maxHreflang = self::MAX_HREFLANG,
) {
$this->multilingual = count($this->supportedLocales) > 1;
$this->hreflangActive = $this->multilingual
&& $hreflangEnabled
&& count($this->supportedLocales) <= $maxHreflang;
}
/**
* 애플리케이션 로케일 설정으로 렌더러를 생성합니다.
*
* hreflang 방출 여부는 seo.sitemap_hreflang_enabled 설정(기본 true)을 따릅니다.
* 로케일 수가 상한을 초과하면 alternate 를 생략하고 경고를 남깁니다.
*
* @return self 설정 기반 렌더러 인스턴스
*/
public static function fromConfig(): self
{
$defaultLocale = (string) config('app.locale');
$supportedLocales = (array) config('app.supported_locales', [$defaultLocale]);
$hreflangEnabled = (bool) g7_core_settings('seo.sitemap_hreflang_enabled', true);
return new self(
(array) config('app.supported_locales', [$defaultLocale]),
$defaultLocale,
);
if ($hreflangEnabled && count($supportedLocales) > self::MAX_HREFLANG) {
Log::warning('[SEO] Sitemap hreflang alternate 링크가 로케일 수 상한을 초과하여 생략됩니다.', [
'locale_count' => count($supportedLocales),
'max_hreflang' => self::MAX_HREFLANG,
]);
}
return new self($supportedLocales, $defaultLocale, $hreflangEnabled);
}
/**
@@ -78,7 +114,7 @@ final class SitemapXmlRenderer
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
if ($this->multilingual) {
if ($this->hreflangActive) {
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'."\n";
$xml .= ' xmlns:xhtml="http://www.w3.org/1999/xhtml">'."\n";
} else {
@@ -117,9 +153,13 @@ final class SitemapXmlRenderer
return $this->renderSingleUrl($baseLoc, $entry);
}
// hreflang alternate 집합은 base URL 당 한 번만 계산해 로케일별 <url> 에 재사용합니다.
// (로케일별로 다시 계산하면 alternate 생성이 로케일 수의 제곱이 됩니다.)
$alternateLinks = $this->hreflangActive ? $this->buildAlternateLinks($baseLoc) : '';
$xml = '';
foreach ($this->supportedLocales as $locale) {
$xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale);
$xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale, $alternateLinks);
}
return $xml;
@@ -170,18 +210,39 @@ final class SitemapXmlRenderer
}
/**
* 특정 로케일의 <url> 블록(hreflang alternate 포함)을 렌더링합니다.
* 특정 로케일의 <url> 블록을 렌더링합니다.
*
* hreflang alternate 링크 문자열은 호출부에서 base URL 당 한 번 계산해 전달합니다.
*
* @param string $baseLoc 기본 로케일 절대 URL
* @param array $entry URL 항목
* @param string $locale 대상 로케일
* @param string $alternateLinks 미리 계산된 hreflang alternate 링크 문자열 (비활성 시 빈 문자열)
* @return string <url> 블록 문자열
*/
private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale): string
private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale, string $alternateLinks): string
{
$xml = ' <url>'."\n";
$xml .= ' <loc>'.$this->escape($this->localizedLoc($baseLoc, $locale)).'</loc>'."\n";
$xml .= $this->renderMetaFields($entry);
$xml .= $alternateLinks;
$xml .= ' </url>'."\n";
return $xml;
}
/**
* base URL 하나에 대한 hreflang alternate 링크 집합을 렌더링합니다.
*
* 모든 지원 로케일의 alternate + x-default 를 포함하며, 이 문자열은 base URL 의
* 모든 로케일 <url> 블록에 동일하게 재사용됩니다.
*
* @param string $baseLoc 기본 로케일 절대 URL
* @return string xhtml:link alternate 링크 문자열
*/
private function buildAlternateLinks(string $baseLoc): string
{
$xml = '';
// 모든 로케일의 hreflang alternate 링크
foreach ($this->supportedLocales as $altLocale) {
@@ -192,8 +253,6 @@ final class SitemapXmlRenderer
// x-default = 기본 로케일 URL
$xml .= ' <xhtml:link rel="alternate" hreflang="x-default" href="'.$this->escape($baseLoc).'"/>'."\n";
$xml .= ' </url>'."\n";
return $xml;
}
+2
View File
@@ -73,6 +73,7 @@
"sitemap_gzip": false,
"sitemap_serve_stale_on_miss": true,
"sitemap_max_urls_per_contributor": 0,
"sitemap_hreflang_enabled": true,
"sitemap_schedule": "daily",
"sitemap_schedule_time": "02:00",
"sitemap_last_updated_at": "",
@@ -221,6 +222,7 @@
"sitemap_gzip": { "type": "boolean", "sensitive": false },
"sitemap_serve_stale_on_miss": { "type": "boolean", "sensitive": false },
"sitemap_max_urls_per_contributor": { "type": "integer", "sensitive": false },
"sitemap_hreflang_enabled": { "type": "boolean", "sensitive": false },
"sitemap_schedule": { "type": "string", "sensitive": false },
"sitemap_schedule_time": { "type": "string", "sensitive": false },
"sitemap_last_updated_at": { "type": "string", "sensitive": false },
+5
View File
@@ -148,6 +148,11 @@ HTTP/1.1 200
"cache_ttl": 7200,
"sitemap_enabled": true,
"sitemap_cache_ttl": 86400,
"sitemap_urls_per_file": 50000,
"sitemap_gzip": false,
"sitemap_serve_stale_on_miss": true,
"sitemap_max_urls_per_contributor": 0,
"sitemap_hreflang_enabled": true,
"sitemap_schedule": "daily",
"sitemap_schedule_time": "02:00",
"sitemap_last_updated_at": "2026-07-08T03:14:49+00:00",
@@ -11,6 +11,7 @@
- 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- SEO 설정의 사이트맵 다국어 대체 링크(hreflang) 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.sitemap_hreflang_enabled_boolean`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.4] - 2026-07-16
@@ -939,6 +939,7 @@ return [
'identity_max_attempts_integer' => '最大試行回数は整数である必要があります。',
'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。',
'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。',
'sitemap_hreflang_enabled_boolean' => 'Sitemap 多言語代替リンク(hreflang) 設定は true または false 値である必要があります。',
],
'identity_policy' => [
'key_required' => 'ポリシーキーを入力してください。',
@@ -9,6 +9,7 @@
### Added
- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
### Changed
@@ -1433,7 +1433,9 @@
"sitemap_urls_per_file": "Sitemap ファイルあたりの URL 数",
"sitemap_urls_per_file_hint": "URL がこの数を超えると sitemap ファイルが複数に分割され、インデックスファイルが一緒に作成されます — 最小 1000 個、最大 50000 個(検索エンジン仕様上限)",
"sitemap_gzip": "Sitemap 圧縮(gzip)",
"sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。"
"sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。",
"sitemap_hreflang_enabled": "多言語代替リンク (hreflang)",
"sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。"
},
"security": {
"title": "セキュリティ 設定",
+1
View File
@@ -965,6 +965,7 @@ return [
'sitemap_serve_stale_on_miss_boolean' => 'Serving the previous sitemap setting must be true or false.',
'sitemap_max_urls_per_contributor_integer' => 'Maximum URLs per contributor must be an integer.',
'sitemap_max_urls_per_contributor_min' => 'Maximum URLs per contributor must be at least 0.',
'sitemap_hreflang_enabled_boolean' => 'The sitemap hreflang alternate links setting must be true or false.',
'sitemap_schedule_invalid' => 'Please select a valid sitemap generation schedule.',
'sitemap_schedule_time_invalid' => 'Sitemap generation time must be in HH:mm format.',
+1
View File
@@ -910,6 +910,7 @@ return [
'sitemap_serve_stale_on_miss_boolean' => '이전 Sitemap 제공 설정은 true 또는 false 값이어야 합니다.',
'sitemap_max_urls_per_contributor_integer' => '수집기당 최대 URL 수는 정수여야 합니다.',
'sitemap_max_urls_per_contributor_min' => '수집기당 최대 URL 수는 0 이상이어야 합니다.',
'sitemap_hreflang_enabled_boolean' => 'Sitemap 다국어 대체 링크(hreflang) 설정은 true 또는 false 값이어야 합니다.',
'sitemap_schedule_invalid' => '유효한 Sitemap 생성 주기를 선택해주세요.',
'sitemap_schedule_time_invalid' => 'Sitemap 생성 시각은 HH:mm 형식이어야 합니다.',
@@ -2,7 +2,7 @@
namespace Modules\Sirsoft\Board\Seo;
use App\Seo\Contracts\SitemapContributorInterface;
use App\Seo\AbstractSitemapContributor;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
@@ -11,9 +11,10 @@ use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface;
* Board 모듈 Sitemap 기여자
*
* 게시판 및 게시글 URL을 sitemap에 제공합니다.
* 데이터 접근은 Repository 인터페이스에 위임합니다.
* 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고
* 한 건씩 지연 yield 하여 대용량 게시글(수백만 건)에서도 메모리를 유계로 유지합니다.
*/
class BoardSitemapContributor implements SitemapContributorInterface
class BoardSitemapContributor extends AbstractSitemapContributor
{
/**
* BoardSitemapContributor 생성자
@@ -37,21 +38,41 @@ class BoardSitemapContributor implements SitemapContributorInterface
}
/**
* Sitemap URL 항목 배열을 반환합니다.
* Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
* 게시판 목록, 게시판별 페이지, 각 게시판의 게시글 URL을 생성합니다.
* 게시판 목록, 게시판별 페이지, 각 게시판의 게시글 URL을 순서대로 yield 합니다.
* 기여자당 안전 상한(seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고
* 경고를 남깁니다. 상한은 이 기여자가 내보내는 모든 URL 유형에 함께 적용됩니다.
*
* @return array<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
* @return iterable<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
public function getUrls(): array
public function getUrlsLazy(): iterable
{
$urls = [];
// 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에
// 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다.
$maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0);
$truncated = false;
$emitted = 0;
foreach ($this->collectUrls() as $entry) {
if ($maxUrls > 0 && $emitted >= $maxUrls) {
Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
'contributor' => $this->getIdentifier(),
'max_urls' => $maxUrls,
]);
return;
}
yield $entry;
$emitted++;
}
}
/**
* 상한 적용 이전의 URL 후보를 순서대로 지연 생성합니다.
*
* @return iterable<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
private function collectUrls(): iterable
{
// 'SEO 제공 페이지' 토글 — OFF 시 해당 URL 유형 제외 (기본 노출)
$seoBoards = (bool) g7_module_settings('sirsoft-board', 'seo.seo_boards', true);
$seoBoard = (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true);
@@ -59,32 +80,27 @@ class BoardSitemapContributor implements SitemapContributorInterface
// 게시판 목록 페이지
if ($seoBoards) {
$truncated = ! $this->appendUrl($urls, [
yield [
'url' => '/boards',
'changefreq' => 'weekly',
'priority' => 0.5,
], $maxUrls);
];
}
// 개별 게시판/게시글 URL이 모두 OFF면 게시판 조회 자체를 생략
if (! $seoBoard && ! $seoPostDetail) {
return $urls;
return;
}
// 게시판별 페이지
foreach ($this->boardRepository->streamActiveForSitemap() as $board) {
if ($truncated) {
break;
}
if ($seoBoard && ! $this->appendUrl($urls, [
'url' => "/board/{$board->slug}",
'lastmod' => $board->updated_at?->toW3cString(),
'changefreq' => 'daily',
'priority' => 0.6,
], $maxUrls)) {
$truncated = true;
break;
if ($seoBoard) {
yield [
'url' => "/board/{$board->slug}",
'lastmod' => $board->updated_at?->toW3cString(),
'changefreq' => 'daily',
'priority' => 0.6,
];
}
if (! $seoPostDetail) {
@@ -93,44 +109,13 @@ class BoardSitemapContributor implements SitemapContributorInterface
// 각 게시판의 공개된 게시글 (비밀글 제외, 게시 상태만)
foreach ($this->postRepository->streamPublishedForSitemap($board->id) as $post) {
if (! $this->appendUrl($urls, [
yield [
'url' => "/board/{$board->slug}/{$post->id}",
'lastmod' => $post->updated_at?->toW3cString(),
'changefreq' => 'monthly',
'priority' => 0.5,
], $maxUrls)) {
$truncated = true;
break;
}
];
}
}
if ($truncated) {
Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
'contributor' => $this->getIdentifier(),
'max_urls' => $maxUrls,
]);
}
return $urls;
}
/**
* 안전 상한을 지키며 URL 항목을 추가합니다.
*
* @param array<int, array<string, mixed>> $urls 누적 URL 배열 (참조)
* @param array<string, mixed> $entry 추가할 URL 항목
* @param int $maxUrls 기여자당 URL 상한 (0 = 무제한)
* @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false
*/
private function appendUrl(array &$urls, array $entry, int $maxUrls): bool
{
if ($maxUrls > 0 && count($urls) >= $maxUrls) {
return false;
}
$urls[] = $entry;
return true;
}
}
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Seo;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Seo\AbstractSitemapContributor;
use App\Seo\Contracts\SitemapContributorInterface;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
@@ -286,4 +287,23 @@ class BoardSitemapContributorTest extends BoardTestCase
$this->assertContains("/board/{$this->board->slug}", $urlPaths);
$this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
}
/**
* getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍)
*
* 소비 경로는 getUrls() 가 아니라 getUrlsLazy() 이므로, 이것이 Traversable 로
* 한 건씩 yield 되어야 대용량 게시글에서 메모리가 유계로 유지된다.
*/
public function test_get_urls_lazy_streams_entries(): void
{
$postId = $this->createTestPost(['status' => 'published', 'is_secret' => false]);
$this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor);
$lazy = $this->contributor->getUrlsLazy();
$this->assertInstanceOf(\Traversable::class, $lazy);
$urlPaths = array_column(iterator_to_array($lazy, false), 'url');
$this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths);
}
}
@@ -2,7 +2,7 @@
namespace Modules\Sirsoft\Ecommerce\Seo;
use App\Seo\Contracts\SitemapContributorInterface;
use App\Seo\AbstractSitemapContributor;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
@@ -11,9 +11,10 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
* Ecommerce 모듈 Sitemap 기여자
*
* 상품 및 카테고리 URL을 sitemap에 제공합니다.
* 데이터 접근은 Repository 인터페이스에 위임합니다.
* 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고
* 한 건씩 지연 yield 하여 대용량 상품(수만~수십만 건)에서도 메모리를 유계로 유지합니다.
*/
class EcommerceSitemapContributor implements SitemapContributorInterface
class EcommerceSitemapContributor extends AbstractSitemapContributor
{
/**
* EcommerceSitemapContributor 생성자
@@ -37,87 +38,74 @@ class EcommerceSitemapContributor implements SitemapContributorInterface
}
/**
* Sitemap URL 항목 배열을 반환합니다.
* Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
* 상품 목록, 카테고리별 페이지, 개별 상품 페이지의 URL을 생성합니다.
* 상품 목록, 카테고리별 페이지, 개별 상품 페이지의 URL을 순서대로 yield 합니다.
* 기여자당 안전 상한(seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고
* 경고를 남깁니다. 상한은 이 기여자가 내보내는 모든 URL 유형에 함께 적용됩니다.
*
* @return array<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
* @return iterable<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
public function getUrls(): array
public function getUrlsLazy(): iterable
{
$urls = [];
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop';
// 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에
// 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다.
$maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0);
$truncated = false;
$emitted = 0;
foreach ($this->collectUrls() as $entry) {
if ($maxUrls > 0 && $emitted >= $maxUrls) {
Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
'contributor' => $this->getIdentifier(),
'max_urls' => $maxUrls,
]);
return;
}
yield $entry;
$emitted++;
}
}
/**
* 상한 적용 이전의 URL 후보를 순서대로 지연 생성합니다.
*
* @return iterable<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
private function collectUrls(): iterable
{
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop';
// 상품 목록 페이지 — 'SEO 제공 페이지' 토글 OFF 시 제외
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) {
$truncated = ! $this->appendUrl($urls, [
yield [
'url' => "/{$routePath}/products",
'changefreq' => 'daily',
'priority' => 0.7,
], $maxUrls);
];
}
// 카테고리별 페이지 — 토글 OFF 시 제외
if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) {
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) {
foreach ($this->categoryRepository->streamActiveForSitemap() as $category) {
if (! $this->appendUrl($urls, [
yield [
'url' => "/{$routePath}/category/{$category->slug}",
'lastmod' => $category->updated_at?->toW3cString(),
'changefreq' => 'weekly',
'priority' => 0.6,
], $maxUrls)) {
$truncated = true;
break;
}
];
}
}
// 개별 상품 페이지 (전시 상태가 '전시'인 상품만) — 토글 OFF 시 제외
if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) {
if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) {
foreach ($this->productRepository->streamVisibleForSitemap() as $product) {
if (! $this->appendUrl($urls, [
yield [
'url' => "/{$routePath}/products/{$product->id}",
'lastmod' => $product->updated_at?->toW3cString(),
'changefreq' => 'weekly',
'priority' => 0.8,
], $maxUrls)) {
$truncated = true;
break;
}
];
}
}
if ($truncated) {
Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
'contributor' => $this->getIdentifier(),
'max_urls' => $maxUrls,
]);
}
return $urls;
}
/**
* 안전 상한을 지키며 URL 항목을 추가합니다.
*
* @param array<int, array<string, mixed>> $urls 누적 URL 배열 (참조)
* @param array<string, mixed> $entry 추가할 URL 항목
* @param int $maxUrls 기여자당 URL 상한 (0 = 무제한)
* @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false
*/
private function appendUrl(array &$urls, array $entry, int $maxUrls): bool
{
if ($maxUrls > 0 && count($urls) >= $maxUrls) {
return false;
}
$urls[] = $entry;
return true;
}
}
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Seo;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Seo\AbstractSitemapContributor;
use App\Seo\Contracts\SitemapContributorInterface;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Models\Category;
@@ -214,4 +215,20 @@ class EcommerceSitemapContributorTest extends ModuleTestCase
$this->assertNotContains("/{$this->routePath}/products/{$product->id}", $urlPaths);
}
/**
* getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍)
*/
public function test_get_urls_lazy_streams_entries(): void
{
$product = Product::factory()->create();
$this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor);
$lazy = $this->contributor->getUrlsLazy();
$this->assertInstanceOf(\Traversable::class, $lazy);
$urlPaths = array_column(iterator_to_array($lazy, false), 'url');
$this->assertContains("/{$this->routePath}/products/{$product->id}", $urlPaths);
}
}
@@ -2,7 +2,7 @@
namespace Modules\Sirsoft\Page\Seo;
use App\Seo\Contracts\SitemapContributorInterface;
use App\Seo\AbstractSitemapContributor;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
@@ -10,9 +10,10 @@ use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
* Page 모듈 Sitemap 기여자
*
* 발행된 페이지 URL을 sitemap에 제공합니다.
* 데이터 접근은 Repository 인터페이스에 위임합니다.
* 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고
* 한 건씩 지연 yield 하여 페이지가 많아도 메모리를 유계로 유지합니다.
*/
class PageSitemapContributor implements SitemapContributorInterface
class PageSitemapContributor extends AbstractSitemapContributor
{
/**
* PageSitemapContributor 생성자
@@ -34,58 +35,35 @@ class PageSitemapContributor implements SitemapContributorInterface
}
/**
* Sitemap URL 항목 배열을 반환합니다.
* Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
* 발행된 페이지의 URL을 생성합니다.
* 발행된 페이지의 URL을 순서대로 yield 합니다. 기여자당 안전 상한
* (seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고 경고를 남깁니다.
*
* @return array<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
* @return iterable<int, array{url: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
public function getUrls(): array
public function getUrlsLazy(): iterable
{
$urls = [];
// 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 적용된다.
$maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0);
$truncated = false;
$emitted = 0;
foreach ($this->pageRepository->streamPublishedForSitemap() as $page) {
if (! $this->appendUrl($urls, [
if ($maxUrls > 0 && $emitted >= $maxUrls) {
Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
'contributor' => $this->getIdentifier(),
'max_urls' => $maxUrls,
]);
return;
}
yield [
'url' => "/page/{$page->slug}",
'lastmod' => $page->updated_at?->toW3cString(),
'changefreq' => 'monthly',
'priority' => 0.5,
], $maxUrls)) {
$truncated = true;
break;
}
];
$emitted++;
}
if ($truncated) {
Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [
'contributor' => $this->getIdentifier(),
'max_urls' => $maxUrls,
]);
}
return $urls;
}
/**
* 안전 상한을 지키며 URL 항목을 추가합니다.
*
* @param array<int, array<string, mixed>> $urls 누적 URL 배열 (참조)
* @param array<string, mixed> $entry 추가할 URL 항목
* @param int $maxUrls 기여자당 URL 상한 (0 = 무제한)
* @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false
*/
private function appendUrl(array &$urls, array $entry, int $maxUrls): bool
{
if ($maxUrls > 0 && count($urls) >= $maxUrls) {
return false;
}
$urls[] = $entry;
return true;
}
}
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Page\Tests\Unit\Seo;
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Seo\AbstractSitemapContributor;
use App\Seo\Contracts\SitemapContributorInterface;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Page\Models\Page;
@@ -117,4 +118,20 @@ class PageSitemapContributorTest extends ModuleTestCase
$this->assertCount(2, $this->contributor->getUrls());
}
/**
* getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍)
*/
public function test_get_urls_lazy_streams_entries(): void
{
$page = Page::factory()->published()->create();
$this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor);
$lazy = $this->contributor->getUrlsLazy();
$this->assertInstanceOf(\Traversable::class, $lazy);
$urlPaths = array_column(iterator_to_array($lazy, false), 'url');
$this->assertContains("/page/{$page->slug}", $urlPaths);
}
}
@@ -9,6 +9,7 @@
### Added
- SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다.
- SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다.
- 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다.
### Changed
@@ -77,16 +77,29 @@ describe('환경설정 > SEO 탭 > Sitemap 생성 칸', () => {
expect(field.type).toBe('composite');
});
it('hreflang 칸이 seo.sitemap_hreflang_enabled Toggle 로 존재한다', () => {
const field = findFieldByName(seoTab, 'seo.sitemap_hreflang_enabled');
expect(field).toBeDefined();
expect(field.name).toBe('Toggle');
expect(field.type).toBe('composite');
});
it('신규 칸이 다국어 키로 라벨을 표시한다 (하드코딩 문구 없음)', () => {
const urlsPerFileField = collectNodes(
seoTab,
(n) => n.id === 'field_sitemap_urls_per_file'
)[0];
const gzipField = collectNodes(seoTab, (n) => n.id === 'field_sitemap_gzip')[0];
const hreflangField = collectNodes(
seoTab,
(n) => n.id === 'field_sitemap_hreflang_enabled'
)[0];
const labels = [
...collectNodes(urlsPerFileField, (n) => typeof n.text === 'string'),
...collectNodes(gzipField, (n) => typeof n.text === 'string'),
...collectNodes(hreflangField, (n) => typeof n.text === 'string'),
].map((n) => n.text);
expect(labels.length).toBeGreaterThan(0);
@@ -1429,7 +1429,9 @@
"sitemap_urls_per_file": "URLs per sitemap file",
"sitemap_urls_per_file_hint": "When URLs exceed this number, the sitemap is split into multiple files and an index file is created — minimum 1000, maximum 50000 (search engine limit)",
"sitemap_gzip": "Sitemap compression (gzip)",
"sitemap_gzip_desc": "Compresses split sitemap files to reduce transfer size. The index file is not compressed."
"sitemap_gzip_desc": "Compresses split sitemap files to reduce transfer size. The index file is not compressed.",
"sitemap_hreflang_enabled": "Multilingual alternate links (hreflang)",
"sitemap_hreflang_enabled_desc": "Adds hreflang alternate links pointing to each language version of a URL on multilingual sites. Turn off to omit the links and reduce sitemap size."
},
"security": {
"title": "Security Settings",
@@ -1433,7 +1433,9 @@
"sitemap_urls_per_file": "Sitemap 파일당 URL 수",
"sitemap_urls_per_file_hint": "URL 이 이 수를 넘으면 sitemap 파일이 여러 개로 나뉘고 색인 파일이 함께 만들어집니다 — 최소 1000개, 최대 50000개 (검색엔진 규격 상한)",
"sitemap_gzip": "Sitemap 압축 (gzip)",
"sitemap_gzip_desc": "나뉜 sitemap 파일을 압축해 전송량을 줄입니다. 색인 파일은 압축하지 않습니다."
"sitemap_gzip_desc": "나뉜 sitemap 파일을 압축해 전송량을 줄입니다. 색인 파일은 압축하지 않습니다.",
"sitemap_hreflang_enabled": "다국어 대체 링크 (hreflang)",
"sitemap_hreflang_enabled_desc": "다국어 사이트에서 URL 마다 각 언어 버전을 가리키는 hreflang 대체 링크를 넣습니다. 끄면 링크를 생략해 sitemap 크기를 줄입니다."
},
"security": {
"title": "보안 설정",
@@ -1038,6 +1038,46 @@
}
]
},
{
"id": "field_sitemap_hreflang_enabled",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-heading"
},
"text": "$t:admin.settings.seo.sitemap_hreflang_enabled"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle"
},
"text": "$t:admin.settings.seo.sitemap_hreflang_enabled_desc"
}
]
},
{
"type": "composite",
"name": "Toggle",
"props": {
"name": "seo.sitemap_hreflang_enabled",
"size": "md"
}
}
]
},
{
"id": "sitemap_last_updated_block",
"type": "basic",
@@ -489,6 +489,30 @@ class SaveSettingsRequestSeoTest extends TestCase
->assertJsonValidationErrors(['seo.sitemap_gzip']);
}
/**
* hreflang 사용 여부가 boolean 으로 저장되는지 확인
*/
public function test_sitemap_hreflang_enabled_is_saved(): void
{
$this->postSeoSettings(['sitemap_hreflang_enabled' => false])->assertStatus(200);
$seo = $this->storedSettings('seo');
$this->assertFalse((bool) $seo['sitemap_hreflang_enabled']);
}
/**
* hreflang 사용 여부가 boolean 이 아니면 422 응답
*/
public function test_sitemap_hreflang_enabled_non_boolean_fails_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_hreflang_enabled' => 'maybe',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_hreflang_enabled']);
}
// ========================================
// 고급 탭 Sitemap 캐시 기준값 (D19 의 메인 값)
// ========================================
@@ -18,6 +18,7 @@ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'
const URLS_PER_FILE = '#field_sitemap_urls_per_file';
const GZIP_ROW = '#field_sitemap_gzip';
const HREFLANG_ROW = '#field_sitemap_hreflang_enabled';
const ADVANCED_SITEMAP_CACHE = '#cache_seo_sitemap';
/** 관리자 환경설정 SEO 탭 진입 */
@@ -60,6 +61,51 @@ test('@smoke #481 - SEO 탭에 Sitemap 분할 기준/압축 칸이 번역문과
await expect(gzipRow).toBeAttached();
expect((await gzipRow.innerText()).trim()).not.toContain('$t:');
await expect(gzipRow.locator('input[type="checkbox"]').first()).toBeAttached();
const hreflangRow = page.locator(HREFLANG_ROW);
await expect(hreflangRow).toBeAttached();
expect((await hreflangRow.innerText()).trim()).not.toContain('$t:');
await expect(hreflangRow.locator('input[type="checkbox"]').first()).toBeAttached();
});
// @scenario tab=seo, permitted=yes
// @effects fields_persisted
test('#481 - hreflang 토글을 바꿔 저장하면 성공하고 재진입 시 유지된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoSeoTab(page);
const toggle = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first();
await expect(toggle).toBeAttached({ timeout: 15_000 });
const wasChecked = await toggle.isChecked();
// 토글을 반대 상태로 바꾼다
await toggle.click();
await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 });
const save = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
// 신규 boolean 키가 검증 규칙에 없으면 422 로 떨어진다 (배관 누락 회귀 가드)
expect((await save).status()).toBe(200);
await gotoSeoTab(page);
const reloaded = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first();
await expect.poll(() => reloaded.isChecked(), { timeout: 15_000 }).toBe(!wasChecked);
// 원상 복구 (E2E 는 실제 환경 설정을 건드린다)
await reloaded.click();
const restore = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
expect((await restore).status()).toBe(200);
});
// @scenario tab=seo, permitted=yes
@@ -0,0 +1,129 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\AbstractSitemapContributor;
use App\Seo\Contracts\SitemapContributorInterface;
use Tests\TestCase;
/**
* AbstractSitemapContributor 단위 테스트
*
* getUrls()(배열)와 getUrlsLazy()(지연 제너레이터) 사이의 브리지를 검증합니다.
* 구현체가 둘 중 하나만 오버라이드해도 나머지 한쪽이 자동으로 제공되어야 합니다.
*/
class AbstractSitemapContributorTest extends TestCase
{
/**
* getUrls() 만 구현한 구현체는 getUrlsLazy() 가 그 배열을 지연 순회한다
*/
public function test_lazy_bridges_to_array_when_only_get_urls_is_implemented(): void
{
$contributor = new class extends AbstractSitemapContributor
{
public function getIdentifier(): string
{
return 'array-only';
}
public function getUrls(): array
{
return [
['loc' => 'https://example.test/a'],
['loc' => 'https://example.test/b'],
];
}
};
$lazy = $contributor->getUrlsLazy();
$this->assertInstanceOf(\Traversable::class, $lazy);
$this->assertSame(
['https://example.test/a', 'https://example.test/b'],
array_column(iterator_to_array($lazy, false), 'loc'),
);
}
/**
* getUrlsLazy() 만 구현한 구현체는 getUrls() 가 그 스트림을 배열로 실체화한다
*/
public function test_array_bridges_to_lazy_when_only_get_urls_lazy_is_implemented(): void
{
$contributor = new class extends AbstractSitemapContributor
{
public function getIdentifier(): string
{
return 'lazy-only';
}
public function getUrlsLazy(): iterable
{
yield ['loc' => 'https://example.test/1'];
yield ['loc' => 'https://example.test/2'];
yield ['loc' => 'https://example.test/3'];
}
};
$urls = $contributor->getUrls();
$this->assertIsArray($urls);
$this->assertSame(
['https://example.test/1', 'https://example.test/2', 'https://example.test/3'],
array_column($urls, 'loc'),
);
}
/**
* getUrlsLazy() 는 실제 지연 제너레이터여야 한다 (전량 실체화 금지)
*
* 소비를 시작하기 전에는 본문이 실행되지 않아야 대용량에서 메모리가 유계로 유지된다.
*/
public function test_get_urls_lazy_is_a_generator_that_defers_execution(): void
{
$contributor = new class extends AbstractSitemapContributor
{
public bool $executed = false;
public function getIdentifier(): string
{
return 'deferred';
}
public function getUrlsLazy(): iterable
{
$this->executed = true;
yield ['loc' => 'https://example.test/x'];
}
};
$lazy = $contributor->getUrlsLazy();
// 아직 순회하지 않았으므로 제너레이터 본문이 실행되지 않았다.
$this->assertFalse($contributor->executed, '순회 전에는 제너레이터 본문이 실행되지 않아야 합니다.');
iterator_to_array($lazy, false);
$this->assertTrue($contributor->executed, '순회하면 제너레이터 본문이 실행되어야 합니다.');
}
/**
* base 는 SitemapContributorInterface 를 구현한다 (기존 등록 경로 호환)
*/
public function test_is_a_sitemap_contributor(): void
{
$contributor = new class extends AbstractSitemapContributor
{
public function getIdentifier(): string
{
return 'iface';
}
public function getUrlsLazy(): iterable
{
yield from [];
}
};
$this->assertInstanceOf(SitemapContributorInterface::class, $contributor);
}
}
+61
View File
@@ -2,6 +2,7 @@
namespace Tests\Unit\Seo;
use App\Seo\AbstractSitemapContributor;
use App\Seo\Contracts\SitemapContributorInterface;
use App\Seo\SitemapGenerator;
use App\Seo\SitemapWriter;
@@ -649,6 +650,66 @@ class SitemapGeneratorTest extends TestCase
$this->assertContains(url('/ok'), array_column($writer->entries, 'loc'));
}
// ─── drain capability 감지 (지연 스트리밍) ─────────────
/**
* getUrlsLazy 를 가진 기여자는 지연 경로로 소진된다 (getUrls 미호출).
*
* getUrls() 를 호출하면 예외를 던지는 기여자를 사용해, generateToWriter 가
* 지연 경로(getUrlsLazy)만 탄다는 것을 증명한다.
*/
public function test_generate_to_writer_uses_lazy_path_for_capable_contributor(): void
{
Log::shouldReceive('warning')->zeroOrMoreTimes();
$contributor = new class extends AbstractSitemapContributor
{
public function getIdentifier(): string
{
return 'lazy-capable';
}
public function getUrls(): array
{
throw new \LogicException('getUrls() 는 지연 경로에서 호출되면 안 됩니다.');
}
public function getUrlsLazy(): iterable
{
yield ['url' => '/lazy/1'];
yield ['url' => '/lazy/2'];
}
};
$this->generator->registerContributor($contributor);
$writer = new SpySitemapWriter;
$this->generator->generateToWriter($writer);
$locs = array_column($writer->entries, 'loc');
$this->assertContains(url('/lazy/1'), $locs);
$this->assertContains(url('/lazy/2'), $locs);
}
/**
* getUrlsLazy 가 없는 raw 기여자는 기존 getUrls() 경로로 소진된다 (하위호환).
*/
public function test_generate_to_writer_uses_get_urls_for_raw_contributor(): void
{
Log::shouldReceive('warning')->zeroOrMoreTimes();
$contributor = Mockery::mock(SitemapContributorInterface::class);
$contributor->shouldReceive('getIdentifier')->andReturn('raw-module');
$contributor->shouldReceive('getUrls')->once()->andReturn([['url' => '/raw/1']]);
$this->generator->registerContributor($contributor);
$writer = new SpySitemapWriter;
$this->generator->generateToWriter($writer);
$this->assertContains(url('/raw/1'), array_column($writer->entries, 'loc'));
}
// ─── 헬퍼 메서드 ──────────────────────────────────────
/**
+90 -6
View File
@@ -210,14 +210,14 @@ class SitemapXmlRendererTest extends TestCase
}
/**
* urlBlock: hreflang alternate 가 현재 N² 구조임을 고정한다 (S3 ⑮ 회귀 기준선)
* urlBlock: hreflang 이 켜져 있으면 로케일별 <url> 마다 전체 alternate 집합을 넣는다
*
* 현재 구현은 <url> 블록마다 모든 로케일의 alternate + x-default 를 넣는다.
* 즉 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 생성된다 — 이것이 S3 ⑮ 가
* 해소하려는 N² 팽창이다. S3 에서 이 수치가 바뀌면 의도된 변경이므로
* 이 테스트를 함께 갱신할 것.
* sitemaps.org 규격상 각 로케일 <url> 은 자기 자신을 포함한 모든 로케일의 alternate 와
* x-default 를 가져야 한다. 따라서 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 나온다
* (3 로케일 → 12). S3 ⑮ 는 이 alternate 집합을 base URL 당 한 번만 계산해 로케일별
* <url> 에 재사용하도록 바꿨다 — 계산 비용은 L² 에서 L 로 줄지만 정상 구성의 출력은 불변이다.
*/
public function test_url_block_currently_emits_quadratic_hreflang_links(): void
public function test_url_block_emits_full_hreflang_set_per_locale(): void
{
$xml = $this->multiLocale(['ko', 'en', 'ja'])->urlBlock(['loc' => 'https://example.test/a']);
@@ -226,6 +226,90 @@ class SitemapXmlRendererTest extends TestCase
$this->assertSame(3, substr_count($xml, 'hreflang="x-default"'));
}
/**
* urlBlock: 모든 로케일 <url> 의 alternate 집합이 동일하다 (재사용 검증)
*
* base URL 당 한 번 계산한 alternate 를 각 <url> 에 재사용하므로,
* 로케일별 <url> 에 들어가는 alternate 링크 목록은 완전히 같아야 한다.
*/
public function test_url_block_reuses_identical_alternate_set_across_locales(): void
{
$xml = $this->multiLocale(['ko', 'en', 'ja'])->urlBlock(['loc' => 'https://example.test/a']);
// <url> 블록별로 alternate 링크 집합을 추출해 모두 동일한지 확인한다.
preg_match_all('/<url>(.*?)<\/url>/s', $xml, $blocks);
$this->assertCount(3, $blocks[1]);
$alternateSets = array_map(static function (string $block): array {
preg_match_all('/<xhtml:link[^>]*\/>/', $block, $links);
return $links[0];
}, $blocks[1]);
$this->assertSame($alternateSets[0], $alternateSets[1]);
$this->assertSame($alternateSets[0], $alternateSets[2]);
$this->assertCount(4, $alternateSets[0], '각 <url> 은 3 로케일 alternate + x-default = 4 개를 가져야 합니다.');
}
/**
* urlBlock: hreflang 을 끄면 로케일별 <url> 은 유지하되 alternate 를 생략한다
*/
public function test_url_block_omits_hreflang_when_disabled(): void
{
$renderer = new SitemapXmlRenderer(['ko', 'en', 'ja'], 'ko', hreflangEnabled: false);
$xml = $renderer->urlBlock(['loc' => 'https://example.test/a']);
// 로케일별 <url> 은 그대로 3개
$this->assertSame(3, substr_count($xml, '<url>'));
$this->assertSame(3, $renderer->urlBlockCount());
// 로케일 URL 은 유지된다
$this->assertStringContainsString('<loc>https://example.test/a?locale=en</loc>', $xml);
// alternate 링크는 없다
$this->assertStringNotContainsString('xhtml:link', $xml);
}
/**
* urlsetHeader: hreflang 을 끄면 xhtml 네임스페이스를 선언하지 않는다 (미사용 방지)
*/
public function test_urlset_header_omits_xhtml_namespace_when_hreflang_disabled(): void
{
$header = (new SitemapXmlRenderer(['ko', 'en'], 'ko', hreflangEnabled: false))->urlsetHeader();
$this->assertStringNotContainsString('xmlns:xhtml', $header);
}
/**
* urlBlock: 로케일 수가 상한을 초과하면 alternate 를 생략한다 (N² 팽창 방지)
*/
public function test_url_block_omits_hreflang_when_locale_count_exceeds_cap(): void
{
$locales = array_map(static fn (int $i): string => 'l'.$i, range(1, 6));
$renderer = new SitemapXmlRenderer($locales, 'l1', maxHreflang: 5);
$xml = $renderer->urlBlock(['loc' => 'https://example.test/a']);
// 로케일별 <url> 은 그대로 6개 (상한은 alternate 방출만 막는다)
$this->assertSame(6, substr_count($xml, '<url>'));
$this->assertStringNotContainsString('xhtml:link', $xml);
// 헤더도 xhtml 네임스페이스를 선언하지 않는다
$this->assertStringNotContainsString('xmlns:xhtml', $renderer->urlsetHeader());
}
/**
* urlBlock: 상한 이하 로케일 수는 alternate 를 정상 방출한다 (경계값)
*/
public function test_url_block_emits_hreflang_at_cap_boundary(): void
{
$locales = array_map(static fn (int $i): string => 'l'.$i, range(1, 5));
$renderer = new SitemapXmlRenderer($locales, 'l1', maxHreflang: 5);
$xml = $renderer->urlBlock(['loc' => 'https://example.test/a']);
// 5 로케일 → 5 × (5 + 1) = 30
$this->assertSame(30, substr_count($xml, 'xhtml:link'));
}
/**
* urlBlock: x-default 는 항상 기본 로케일 URL 을 가리킨다
*/