diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index d1ab767c..eb03e9bb 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -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'), diff --git a/app/Seo/AbstractSitemapContributor.php b/app/Seo/AbstractSitemapContributor.php new file mode 100644 index 00000000..a66cd36d --- /dev/null +++ b/app/Seo/AbstractSitemapContributor.php @@ -0,0 +1,57 @@ + + */ + public function getUrls(): array + { + return iterator_to_array($this->getUrlsLazy(), false); + } + + /** + * Sitemap URL 항목을 한 건씩 지연 순회합니다. + * + * 기본 구현은 getUrls() 배열을 순회합니다. 대용량 기여자는 이 메서드를 + * 오버라이드해 Repository 스트림을 직접 yield 하십시오. + * + * @return iterable + */ + public function getUrlsLazy(): iterable + { + yield from $this->getUrls(); + } +} diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index b843fc1d..0bf8acab 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -79,11 +79,19 @@ class SitemapGenerator /** * 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다. * + * 지연 스트리밍 capability(AbstractSitemapContributor 상속 또는 getUrlsLazy() 보유)를 + * 감지해, 있으면 한 건씩 yield 되는 제너레이터를 사용합니다. 없으면 기존 getUrls() + * 배열을 그대로 순회합니다(제3자 raw 구현체 하위호환). + * * @param SitemapContributorInterface $contributor Sitemap 기여자 * @return iterable> URL 항목 순회자 */ private function drain(SitemapContributorInterface $contributor): iterable { + if ($contributor instanceof AbstractSitemapContributor || method_exists($contributor, 'getUrlsLazy')) { + return $contributor->getUrlsLazy(); + } + return $contributor->getUrls(); } diff --git a/app/Seo/SitemapXmlRenderer.php b/app/Seo/SitemapXmlRenderer.php index b2dcb7a1..ba3952d8 100644 --- a/app/Seo/SitemapXmlRenderer.php +++ b/app/Seo/SitemapXmlRenderer.php @@ -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 하나당 로케일별 블록을 생성하고 - * 각 블록에 xhtml:link hreflang alternate 를 포함합니다. + * 각 블록에 xhtml:link hreflang alternate 를 포함합니다. alternate 링크 집합은 + * base URL 당 한 번만 계산해 로케일별 에 재사용하므로, alternate 생성 비용은 + * 로케일 수의 제곱이 아니라 선형입니다. */ final class SitemapXmlRenderer { + /** + * hreflang alternate 를 방출하는 지원 로케일 수 상한 + * + * 로케일 수가 이 값을 넘으면 URL 하나당 alternate 링크가 과도하게 팽창하므로 + * alternate 를 생략합니다(로케일별 자체는 유지). 정상 다국어 구성(2~수개)은 + * 이 상한에 걸리지 않습니다. + */ + public const MAX_HREFLANG = 50; + /** * 다국어 여부 (지원 로케일 2개 이상) */ private readonly bool $multilingual; + /** + * hreflang alternate 를 방출하는지 여부 + * + * 다국어이면서 hreflang 이 켜져 있고 로케일 수가 상한 이하일 때만 true. + */ + private readonly bool $hreflangActive; + /** * SitemapXmlRenderer 생성자 * * @param array $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 = ''."\n"; - if ($this->multilingual) { + if ($this->hreflangActive) { $xml .= 'renderSingleUrl($baseLoc, $entry); } + // hreflang alternate 집합은 base 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 } /** - * 특정 로케일의 블록(hreflang alternate 포함)을 렌더링합니다. + * 특정 로케일의 블록을 렌더링합니다. + * + * hreflang alternate 링크 문자열은 호출부에서 base URL 당 한 번 계산해 전달합니다. * * @param string $baseLoc 기본 로케일 절대 URL * @param array $entry URL 항목 * @param string $locale 대상 로케일 + * @param string $alternateLinks 미리 계산된 hreflang alternate 링크 문자열 (비활성 시 빈 문자열) * @return string 블록 문자열 */ - private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale): string + private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale, string $alternateLinks): string { $xml = ' '."\n"; $xml .= ' '.$this->escape($this->localizedLoc($baseLoc, $locale)).''."\n"; $xml .= $this->renderMetaFields($entry); + $xml .= $alternateLinks; + $xml .= ' '."\n"; + + return $xml; + } + + /** + * base URL 하나에 대한 hreflang alternate 링크 집합을 렌더링합니다. + * + * 모든 지원 로케일의 alternate + x-default 를 포함하며, 이 문자열은 base 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 .= ' '."\n"; - $xml .= ' '."\n"; - return $xml; } diff --git a/config/settings/defaults.json b/config/settings/defaults.json index b31216af..275d8b12 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -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 }, diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index 984b62cb..f9ee3392 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -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", diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index d685ff80..19ce0b01 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index 01538594..d65e8387 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -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' => 'ポリシーキーを入力してください。', diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 5098c122..d3753650 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. +- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 3b5049bc..fa7c4bb7 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -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": "セキュリティ 設定", diff --git a/lang/en/validation.php b/lang/en/validation.php index 141fca60..43bd1c3d 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -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.', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index d02c4e40..e7469a5f 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -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 형식이어야 합니다.', diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index 69d72cf1..fc3c8076 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -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 + * @return iterable */ - 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 + */ + 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> $urls 누적 URL 배열 (참조) - * @param array $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; } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php index 93c1129e..98a3dc84 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php @@ -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); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index c1624102..be602fa7 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -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 + * @return iterable */ - 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 + */ + 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> $urls 누적 URL 배열 (참조) - * @param array $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; } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php index 53a62b26..ce9765dc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php @@ -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); + } } diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index 4860da75..6bdca007 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -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 + * @return iterable */ - 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> $urls 누적 URL 배열 (참조) - * @param array $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; } } diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php index fb4bb299..180b89b5 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php @@ -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); + } } diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index f7d4adeb..ffd388fd 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다. +- SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다. - 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다. ### Changed diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx index ed98afff..d27d5434 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx @@ -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); diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index 60a9968b..e7791867 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -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", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 1557fb68..415e2d8d 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -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": "보안 설정", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json index 9fb2daae..15bac572 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json @@ -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", diff --git a/tests/Feature/Seo/SaveSettingsRequestSeoTest.php b/tests/Feature/Seo/SaveSettingsRequestSeoTest.php index 806f61e0..b785bd41 100644 --- a/tests/Feature/Seo/SaveSettingsRequestSeoTest.php +++ b/tests/Feature/Seo/SaveSettingsRequestSeoTest.php @@ -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 의 메인 값) // ======================================== diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts index adf79981..8e370b9c 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -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 diff --git a/tests/Unit/Seo/AbstractSitemapContributorTest.php b/tests/Unit/Seo/AbstractSitemapContributorTest.php new file mode 100644 index 00000000..68ef7c7e --- /dev/null +++ b/tests/Unit/Seo/AbstractSitemapContributorTest.php @@ -0,0 +1,129 @@ + '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); + } +} diff --git a/tests/Unit/Seo/SitemapGeneratorTest.php b/tests/Unit/Seo/SitemapGeneratorTest.php index 60082e70..a4dd538e 100644 --- a/tests/Unit/Seo/SitemapGeneratorTest.php +++ b/tests/Unit/Seo/SitemapGeneratorTest.php @@ -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')); + } + // ─── 헬퍼 메서드 ────────────────────────────────────── /** diff --git a/tests/Unit/Seo/SitemapXmlRendererTest.php b/tests/Unit/Seo/SitemapXmlRendererTest.php index 0c613c3c..eb17528f 100644 --- a/tests/Unit/Seo/SitemapXmlRendererTest.php +++ b/tests/Unit/Seo/SitemapXmlRendererTest.php @@ -210,14 +210,14 @@ class SitemapXmlRendererTest extends TestCase } /** - * urlBlock: hreflang alternate 가 현재 N² 구조임을 고정한다 (S3 ⑮ 회귀 기준선) + * urlBlock: hreflang 이 켜져 있으면 로케일별 마다 전체 alternate 집합을 넣는다 * - * 현재 구현은 블록마다 모든 로케일의 alternate + x-default 를 넣는다. - * 즉 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 생성된다 — 이것이 S3 ⑮ 가 - * 해소하려는 N² 팽창이다. S3 에서 이 수치가 바뀌면 의도된 변경이므로 - * 이 테스트를 함께 갱신할 것. + * sitemaps.org 규격상 각 로케일 은 자기 자신을 포함한 모든 로케일의 alternate 와 + * x-default 를 가져야 한다. 따라서 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 나온다 + * (3 로케일 → 12). S3 ⑮ 는 이 alternate 집합을 base 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: 모든 로케일 의 alternate 집합이 동일하다 (재사용 검증) + * + * base URL 당 한 번 계산한 alternate 를 각 에 재사용하므로, + * 로케일별 에 들어가는 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']); + + // 블록별로 alternate 링크 집합을 추출해 모두 동일한지 확인한다. + preg_match_all('/(.*?)<\/url>/s', $xml, $blocks); + $this->assertCount(3, $blocks[1]); + + $alternateSets = array_map(static function (string $block): array { + preg_match_all('/]*\/>/', $block, $links); + + return $links[0]; + }, $blocks[1]); + + $this->assertSame($alternateSets[0], $alternateSets[1]); + $this->assertSame($alternateSets[0], $alternateSets[2]); + $this->assertCount(4, $alternateSets[0], '각 은 3 로케일 alternate + x-default = 4 개를 가져야 합니다.'); + } + + /** + * urlBlock: hreflang 을 끄면 로케일별 은 유지하되 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']); + + // 로케일별 은 그대로 3개 + $this->assertSame(3, substr_count($xml, '')); + $this->assertSame(3, $renderer->urlBlockCount()); + // 로케일 URL 은 유지된다 + $this->assertStringContainsString('https://example.test/a?locale=en', $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']); + + // 로케일별 은 그대로 6개 (상한은 alternate 방출만 막는다) + $this->assertSame(6, substr_count($xml, '')); + $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 을 가리킨다 */