From 3dd34104cce394e535556a63793c01f6d13677bb Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 18 Jul 2026 20:56:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EB=8C=80=EC=9A=A9=EB=9F=89=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=20+=20=EC=BD=94=EC=96=B4=207.1.0=20=EB=A7=88=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S6(하네스 + 마감): 큐/생성기의 볼륨 붕괴 재발을 정적으로 강제하고, S1~S5 사이트맵 개편의 공개 표면을 문서화하며 코어를 7.1.0 으로 확정한다. 하네스(주안점 5): - test-scenario 매니페스트에 scale 축(n≥100000 + assert) 도입. 내장 YAML fallback 파서 제약으로 block-array of flow objects 형태. - 룰 2종: scenario-scale-axis-required(batch 매니페스트 scale 강제), job-generator-needs-scale-test(Jobs·*Generator 변경 시 scale 시나리오 요구). *Command 은 볼륨 비의존이라 제외 — tags:[batch] opt-in 으로 커버. - tests/scenarios/sitemap-generation.yaml + @scale 마킹 3(Writer/Progress/ManagerMode). 문서: seo-system(증분 저장소·모드·진행상황·lazy contributor)·testing-guide(scale 절)· broadcasting(sitemap 채널)·hooks(2훅)·database-guide(loc_hash)·cheatsheet. 버전: 코어 7.0.5→7.1.0(config+env+README+INSTALL+CHANGELOG) + 모듈 board/ecommerce/page requires.g7_version >=7.1.0. --- .env.example | 2 +- .env.testing.example | 2 +- CHANGELOG.md | 15 +++ INSTALL.md | 2 +- README.md | 2 +- config/app.php | 2 +- docs/backend/broadcasting.md | 16 ++- docs/backend/seo-system.md | 78 +++++++++++-- docs/cheatsheet.md | 6 +- docs/database-guide.md | 20 ++++ docs/extension/hooks.md | 10 ++ docs/testing-guide.md | 36 ++++++ modules/_bundled/sirsoft-board/CHANGELOG.md | 1 + modules/_bundled/sirsoft-board/module.json | 2 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 1 + .../_bundled/sirsoft-ecommerce/module.json | 2 +- modules/_bundled/sirsoft-page/CHANGELOG.md | 1 + modules/_bundled/sirsoft-page/module.json | 2 +- tests/Feature/Seo/SitemapManagerModeTest.php | 2 + tests/Unit/Seo/SitemapProgressTest.php | 2 + tests/Unit/Seo/SitemapWriterTest.php | 2 + tests/scenarios/sitemap-generation.yaml | 103 ++++++++++++++++++ 22 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 tests/scenarios/sitemap-generation.yaml diff --git a/.env.example b/.env.example index 27d61180..03a6f2e9 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.5 +APP_VERSION=7.1.0 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index bec41e3b..622261bc 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.5 +APP_VERSION=7.1.0 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7ce37e..755e89b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.1.0] - 2026-07-18 + +### Added + +- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.) +- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다. +- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다. +- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다. +- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다. +- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다. + +### Fixed + +- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다. + ## [7.0.5] - 2026-07-16 ### Added diff --git a/INSTALL.md b/INSTALL.md index 6c55f3cf..a43b314f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.5 g7 +# (필요 시) mv g7-7.1.0 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index 8a4c6494..883a5789 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/config/app.php b/config/app.php index 986558af..468cfaad 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.5'), + 'version' => env('APP_VERSION', '7.1.0'), /* |-------------------------------------------------------------------------- diff --git a/docs/backend/broadcasting.md b/docs/backend/broadcasting.md index bc0f77de..0a6e5174 100644 --- a/docs/backend/broadcasting.md +++ b/docs/backend/broadcasting.md @@ -211,10 +211,19 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re | 소스 | 패턴 | 예시 | |------|------|------| -| 코어 | `core.*` | `core.admin.dashboard`, `core.user.notifications.{id}` | +| 코어 | `core.*` | `core.admin.dashboard`, `core.admin.seo.sitemap`, `core.user.notifications.{id}` | | 모듈 | `module.{identifier}.*` | `module.sirsoft-ecommerce.orders.{id}` | | 플러그인 | `plugin.{identifier}.*` | `plugin.sirsoft-payment.status.{id}` | +### 코어 방송 채널 목록 + +| 채널 | 이벤트 | 인증 권한 | 페이로드 | +|------|--------|----------|----------| +| `core.admin.dashboard` | `dashboard.stats.updated` | `core.dashboard.read` | `{type, data}` | +| `core.admin.seo.sitemap` | `sitemap.progress.updated` | `core.settings.read` | `{realtime_enabled, progress}` (상태 API `data` 와 동형) | + +> `core.admin.seo.sitemap` 은 Sitemap 생성 진행상황을 실시간 전달합니다. Reverb OFF 면 방송이 자동 skip 되고 프론트가 상태 API 폴링으로 폴백합니다. payload 가 상태 API 응답 `data` 와 동형이라 websocket 데이터소스 `target_source` 교체 후에도 UI 바인딩이 유지됩니다. + --- ## 모듈/플러그인 채널 등록 @@ -314,6 +323,11 @@ Broadcast::channel('core.admin.dashboard', function ($user) { return $user->hasPermission('core.dashboard.read'); }); +// 관리자 Sitemap 생성 진행상황 채널 - SEO 설정 읽기 권한 +Broadcast::channel('core.admin.seo.sitemap', function ($user) { + return $user->hasPermission('core.settings.read'); +}); + // 모듈 리소스 채널 (모듈의 getChannels()로 자동 등록 — 참고용 예시) // Broadcast::channel('module.sirsoft-ecommerce.orders.{orderId}', function ($user, $orderId) { // return $user->hasPermission('sirsoft-ecommerce.orders.read'); diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index ca72c66f..86bfd9ab 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -12,8 +12,8 @@ 5. seo-config.json: 텍스트 추출(text_props), 속성 매핑(attr_map), 허용 속성(allowed_attrs), 컴포넌트→HTML 매핑 — 모두 템플릿 선언 6. 훅 시스템: core.seo.filter_context/filter_og_data/filter_twitter_data/filter_structured_data/filter_meta/filter_view_data + 봇 감지 훅 core.seo.resolve_is_bot 7. 도메인 ownership: 모듈/플러그인이 `seoOgDefaults`/`seoTwitterDefaults`/`seoStructuredData` 메서드로 자기 도메인 OG/Twitter/JSON-LD 를 owned (이커머스 Product, 게시판 Article 등) -8. Sitemap: 비공개 디스크에 분할 커밋(sitemapindex + sitemap-{n}.xml) 후 스트리밍 서빙 — 요청 스레드 생성 없음, 미스 시 잡 디스패치 + stale/503 -9. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap +8. Sitemap: 스트리밍 writer(유계 메모리)로 비공개 디스크에 분할 커밋(sitemapindex + sitemap-{n}.xml) 후 서빙 — 요청 스레드 생성 없음, 미스 시 잡 디스패치 + stale/503. 증분 저장소(sitemap_urls) + 재생성 모드(SitemapGenerationMode full/auto/incremental) + 진행상황(SitemapProgress, Reverb 실시간/OFF 폴링) +9. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap(--rebuild/--mode) ``` ## 아키텍처 개요 @@ -71,7 +71,14 @@ Request → web.php catch-all → SeoMiddleware (봇 감지) | SeoMetaResolver | 3계층 캐스케이드 메타 해석 | | SeoMiddleware | 미들웨어 (봇 감지 → 렌더링) | | SeoServiceProvider | DI 바인딩 | -| SitemapGenerator | Sitemap 수집기 | +| SitemapGenerator | Sitemap 수집기 (contributor 드레인 → writer 스트리밍) | +| SitemapManager | Sitemap 재생성 오케스트레이션 (모드 분기 + 진행상황 + 훅) | +| SitemapWriter | 자식파일당 in-memory 버퍼 flush + 분할 + atomic commit | +| SitemapFileStore | 디스크 세트 읽기/서빙 (manifest·index·child 응답) | +| SitemapXmlRenderer | XML escape/`` 블록/hreflang 단일 출처 | +| SitemapIndexer | 리소스→sitemap_urls index/deindex (리스너 경유) | +| SitemapProgress | 진행상황 스토어 (phase 기반 + 방송) | +| AbstractSitemapContributor | contributor 브리지 base (getUrls↔getUrlsLazy) | | SeoInvalidationRegistry | 무효화 규칙 레지스트리 | | SeoDeclarationCollector | 레이아웃 SEO 선언 수집 | | SeoCacheStatsService | 캐시 통계 집계 | @@ -84,6 +91,8 @@ Request → web.php catch-all → SeoMiddleware (봇 감지) | /api/admin/seo/clear-cache | POST | api.admin.seo.clear-cache | 캐시 삭제 | | /api/admin/seo/warmup | POST | api.admin.seo.warmup | 워밍업 | | /api/admin/seo/cached-urls | GET | api.admin.seo.cached-urls | 캐시 URL 목록 | +| /api/admin/seo/sitemap/regenerate | POST | api.admin.seo.sitemap.regenerate | Sitemap 전체 재생성 (큐 예약, mode=full) | +| /api/admin/seo/sitemap/status | GET | api.admin.seo.sitemap.status | Sitemap 생성 진행상황 + realtime_enabled | ## SeoMiddleware 동작 규칙 @@ -578,17 +587,31 @@ class EcommerceModule extends AbstractModule **레이아웃 활성화 조건**: 모듈 declaration 이 호출되려면 레이아웃 `meta.seo.extensions` 에 `[{ "type": "module", "id": "sirsoft-ecommerce" }]` 선언 + `meta.seo.page_type` 명시 필요. -### SitemapContributorInterface 구현 +### SitemapContributorInterface 구현 (지연 스트리밍 권장) + +대용량 도메인은 `AbstractSitemapContributor` 를 상속해 `getUrlsLazy()`(제너레이터)를 구현합니다. base 가 `getUrls()↔getUrlsLazy()` 를 양방향 브리지하므로 둘 중 하나만 구현하면 됩니다. 인터페이스 자체는 변경되지 않아 base 를 상속하지 않은 제3자 raw 구현체(`implements SitemapContributorInterface` + `getUrls()`)도 그대로 동작합니다. ```php // modules/_bundled/[module]/src/Seo/[Module]SitemapContributor.php -class EcommerceSitemapContributor implements SitemapContributorInterface +class EcommerceSitemapContributor extends AbstractSitemapContributor { - public function getUrls(): array { /* 상품/카테고리 URL 반환 */ } public function getIdentifier(): string { return 'sirsoft-ecommerce'; } + + // 한 건씩 yield — 1.4M 배열을 메모리에 만들지 않음. 쿼리는 Repository 의 + // stream*ForSitemap() 위임(lazyById, service-direct-data-access 규율). + // 리소스 단위 증분(SitemapIndexer) 매칭을 위해 resource_type/resource_id 를 함께 emit. + public function getUrlsLazy(): iterable + { + foreach ($this->products->streamVisibleForSitemap() as $p) { + yield ['loc' => url("/shop/{$p->id}"), 'lastmod' => $p->updated_at?->toW3cString(), + 'resource_type' => 'product', 'resource_id' => (string) $p->id]; + } + } } ``` +`SitemapGenerator::drain()` 이 `instanceof AbstractSitemapContributor || method_exists($c, 'getUrlsLazy')` 로 capability 를 감지해 지연 경로를 우선합니다. + ### ServiceProvider 등록 ```php @@ -629,10 +652,17 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만 php artisan seo:clear # 전체 SEO 캐시 삭제 php artisan seo:clear --layout=home # 특정 레이아웃만 php artisan seo:stats # 캐시 통계 출력 -php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치) +php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto) php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 +php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당) +php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정 ``` +- `--mode=full`: 도메인 전량을 다시 읽어 `sitemap_urls` 를 전면 replace 후 파일 재작성(자식 균등 재분배). +- `--mode=incremental`: 도메인 재쿼리 없이 `sitemap_urls` 스트림만으로 파일 재작성(리스너가 반영한 델타 그대로). +- `--mode=auto`(기본): 저장소가 비어 있으면 full, 채워져 있으면 incremental. +- `--rebuild` 은 `--mode=full` 의 별칭입니다. + ## Sitemap 분할 생성과 서빙 Sitemap 은 비공개 디스크(StorageInterface, `cache` 카테고리)에 분할 파일로 커밋되고, 컨트롤러가 스트리밍으로 서빙합니다. `public/storage` 심볼릭 링크에 의존하지 않으며 자식 파일을 메모리에 적재하지 않습니다. @@ -661,7 +691,38 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) **분할 기준**: `sitemap_urls_per_file`(기본 50000) 또는 파일 크기 임계(`SitemapWriter::MAX_FILE_BYTES`, 45MB) 중 먼저 도달하는 쪽. 둘 다 sitemaps.org 프로토콜 제한(50,000 URL / 50MB)을 지키기 위한 것입니다. -**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 예약만 하고 즉시 응답합니다(`status: dispatched`). 완료 여부는 `sitemap_last_updated_at` 갱신으로 확인합니다. +**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 `mode=full` 로 예약만 하고 즉시 진행상황(`getStatus()`)을 응답합니다. 완료 여부는 진행상황(아래) 또는 `sitemap_last_updated_at` 갱신으로 확인합니다. + +## 증분 저장소 (sitemap_urls) + +리소스 단위 공개/비공개/삭제를 사이트맵에 반영하기 위해 URL 을 `sitemap_urls` 테이블에 지속합니다(주안점 1·3). 전체 재생성 없이 바뀐 리소스 행만 upsert/remove 합니다. + +- **테이블**: `resource_type`/`resource_id`/`loc`/`loc_hash`(=`sha256(loc)`)/`lastmod`/`changefreq`/`priority`/`contributor`/`is_visible`. unique 는 `(resource_type, resource_id, loc_hash)` — `loc string(2048)` utf8mb4 는 InnoDB 키 길이(3072 byte)를 초과하므로 ascii 64 해시로 identity 를 유지합니다(상세: docs/database-guide.md). +- **Repository**: `SitemapUrlRepositoryInterface`(`upsertForResource`=delete-후-insert 멱등 / `removeForResource` / `streamVisible`(lazyById) / `countVisible` / `replaceAllForContributor`). `SeoServiceProvider` 가 singleton 바인딩. +- **인덱서**: `SitemapIndexer::indexResource($type,$model,$entries)` / `deindexResource($type,$model)` — 리스너가 직접 모델/DB 를 만지지 않고 이 서비스를 경유합니다(`service-direct-data-access` 규율). 제3자 확장은 filter 훅 `sitemap.index.collect_for_resource` 로 리소스→entries 를 가공/추가할 수 있습니다(docs/extension/hooks.md). +- **리스너**: 모듈 SEO 리스너가 `after_create/update/delete` 에서 공개상태를 판정해 index/deindex 하고 `GenerateSitemapJob::dispatch()`(유니크 락 디바운스)를 겁니다. 리소스 단위 증분에서 자식 파일 URL 수가 임계 미만으로 줄어드는 것(5000→4999)은 감안하며, 균등 재분배(리밸런싱)는 full 재생성 때만 합니다. + +## 재생성 모드 (SitemapGenerationMode) + +`App\Enums\SitemapGenerationMode`(Backed Enum: `full`/`auto`/`incremental`)가 재생성 정책을 캡슐화합니다. `resolve(int $visibleCount)` 가 `auto` 를 저장소 상태로 full/incremental 판정합니다. + +| 트리거 | 모드 | 동작 | +|--------|------|------| +| 관리자 수동 재생성 | 항상 `full` | 도메인 전량 → 테이블 replace → 파일 전량 재작성 | +| 스케줄러 | `auto` | 테이블 비었으면 full, 아니면 incremental | +| 리소스 변경(리스너) | 해당 리소스만 | 행 upsert/remove 후 잡 디스패치 | + +`Command → GenerateSitemapJob → SitemapManager::regenerate(SitemapGenerationMode)` 전 경로가 enum 시그니처를 공유합니다(잡 직렬화는 enum 프로퍼티 그대로). + +## 진행상황 가시화 (Reverb 실시간 / OFF 폴링) + +`SitemapProgress`(캐시 키 `seo.sitemap.progress`, TTL 3600)가 phase 기반 진행상황을 기록합니다: `queued → running(기여자별 phase + 누적 URL) → writing → completed/failed`. 사전 count 쿼리 없이 스트림 누적치를 표기합니다(1.4M 에서 count 자체가 부담). + +- **상태 API**: `GET /api/admin/seo/sitemap/status`(`core.settings.read`)가 `{last_updated_at, progress, realtime_enabled}` 를 반환합니다. `realtime_enabled = drivers.websocket_enabled 설정 && config('broadcasting.default') !== 'null'`(설정 SSoT + 실제 적용 config 양쪽 확인). +- **방송**: `SitemapProgress` 가 `core.admin.seo.sitemap` 채널로 `sitemap.progress.updated` 를 방송합니다(payload 는 상태 API `data` 와 동형). Reverb OFF 면 `HookManager::broadcast` 가 자동 skip 하고 캐시만 기록되어 폴링 폴백이 성립합니다. 방송은 N URL(5000) 간격으로 스로틀합니다. +- **채널 인증**: `routes/channels.php` 에 `core.admin.seo.sitemap` → `core.settings.read`(docs/backend/broadcasting.md). +- **잡 실패**: `GenerateSitemapJob::failed()` 가 `SitemapProgress::fail()` 을 호출해 무한 running 을 방지합니다(TTL 만료 시 idle 복귀). +- **프론트**: SEO 탭이 상태 API 초기 로드 후 `realtime_enabled` 로 분기합니다 — true 면 websocket 데이터소스(`target_source`)로 갱신, false 면 `startInterval`/`stopInterval` 로 3초 폴링(완료/실패 전이 시 중단). ## 설정값 (코어 seo.*) @@ -677,6 +738,7 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) | sitemap_gzip | boolean | false | 자식 파일 gzip 압축. 인덱스 파일은 항상 비압축 | | sitemap_serve_stale_on_miss | boolean | true | 신선도 만료 시 기존 세트를 그대로 서빙(true) / 503 반환(false) | | sitemap_max_urls_per_contributor | integer | 0 | 수집기당 URL 상한 (0=무제한). 초과 시 경고 로그 + truncate | +| sitemap_hreflang_enabled | boolean | true | 다국어 alternate(hreflang) 링크 출력 ON/OFF. 로케일 수가 `SitemapXmlRenderer::MAX_HREFLANG`(50) 초과 시 alternate 생략 | | sitemap_schedule | string | "daily" | 생성 주기 (hourly/daily/weekly) | | sitemap_schedule_time | string | "02:00" | 생성 시각 | | og_default_site_name | string | "" | og:site_name 기본값. 비면 `general.site_name` fallback | diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 905f9d48..cf333c2e 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -249,8 +249,10 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만 php artisan seo:clear # 전체 SEO 캐시 삭제 php artisan seo:clear --layout=home # 특정 레이아웃만 php artisan seo:stats # 캐시 통계 출력 -php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치) -php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 +php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto) +php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 +php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full) +php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정 ``` ### API 문서 Artisan 커맨드 diff --git a/docs/database-guide.md b/docs/database-guide.md index a81030dd..f93d1e2e 100644 --- a/docs/database-guide.md +++ b/docs/database-guide.md @@ -204,6 +204,26 @@ php artisan migrate --pretend --path=database/migrations/2026_05_05_172526_add_l --- +### 긴 문자열 컬럼의 unique — 해시 컬럼 패턴 (sitemap_urls 사례) + +utf8mb4(4 byte/char) 긴 문자열 컬럼을 unique 인덱스에 직접 넣으면 InnoDB 키 길이 제한(3072 byte)을 넘겨 마이그레이션이 실패한다. 예: `sitemap_urls` 의 identity 는 `(resource_type, resource_id, loc)` 인데 `loc string(2048)` 을 넣으면 `256+256+8192 = 8704 byte` 로 초과한다. + +해결: **identity 문자열의 고정 길이 해시 컬럼**을 두고 그것을 unique 에 넣는다. + +```php +$table->string('loc', 2048); // 실제 값 (utf8mb4) +$table->char('loc_hash', 64)->charset('ascii'); // sha256(loc) — ascii 64 byte +$table->unique(['resource_type', 'resource_id', 'loc_hash']); // 64+256+256 = 576 byte +``` + +- 해시는 app 레벨(`hash('sha256', $loc)`)에서 계산한다 — raw SQL/DB 함수 비의존(다국어·DB 이식성 규율). +- `loc` 는 실제 서빙/표시에 쓰고, `loc_hash` 는 오직 identity 매칭용이다. +- upsert 는 delete-후-insert 로 멱등을 보장하고, 이 unique 가 중복 삽입(1062)을 차단한다. + +`sitemap_urls` 스키마: `resource_type(64,index)` · `resource_id(64,nullable)` · `loc(2048)` · `loc_hash(64,ascii)` · `lastmod` · `changefreq(16)` · `priority(2,1)` · `contributor(64,index)` · `is_visible(bool,index)` + `unique(resource_type, resource_id, loc_hash)` + `index(contributor, is_visible, id)`(리빌드 스트리밍 커서). 모든 컬럼 한국어 `->comment()`, `down()` 구현, `Schema::hasTable` 멱등 가드. + +--- + ### 모듈/플러그인 마이그레이션 down() 메서드 ``` diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index 86e2a40e..a7ec6362 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -157,8 +157,18 @@ core.seo.filter_view_data # View 직전 ($viewData) — extraHeadTags / ex # SEO 봇 감지 훅 (Filter) — null 반환 시 라이브러리 평가로 fallthrough core.seo.resolve_is_bot + +# Sitemap 증분 인덱싱 훅 (Filter) — 리소스→sitemap 항목 가공/추가 +sitemap.index.collect_for_resource # SitemapIndexer 가 리소스 index 시 발화 — applyFilters($entries, $type, $id, $contributor) + +# Sitemap 재생성 훅 (Action) +core.seo.sitemap.before_regenerate # 재생성 시작 직전 +core.seo.sitemap.after_regenerate # 재생성 성공 후 ($meta) +core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'failed', 'success'=>false, ...]) ``` +> `sitemap.index.collect_for_resource` 는 제3자 확장이 리소스 하나에 대한 sitemap 항목을 추가/보정할 때 씁니다(filter — `'type' => 'filter'` 명시 필수). `core.seo.sitemap.after_regenerate_failed` 는 재생성 잡이 실패했을 때 확장이 알림/복구를 걸 수 있는 action 훅입니다. + --- ## 리스너 구현 diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 8333db3b..bffed930 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -349,6 +349,42 @@ audit rule `test-scenario-coverage` 가 매니페스트 cross product 와 effect 2. **Pairwise (all-pairs)** 전략 허용: 매니페스트에 `coverage_strategy: pairwise` 명시 시 모든 axis 쌍의 모든 조합만 커버 (n축 → O(n²) 케이스). 단순 분기형 axis(true/false) 가 많을 때 유효 3. 그래도 폭발하면 **상위 axis 분리**: 매니페스트를 도메인별로 분할 (예: `order_payment_pg_toss_member.yaml`, `order_payment_pg_toss_guest.yaml`) +### 대규모 시나리오 축(scale) + +큐/스케줄러/배치 생성처럼 데이터가 누적되면 붕괴할 수 있는 기능은 대용량(scale) 축을 매니페스트에 선언하고, 규모 의존 결함(유계 메모리·분할 정확성·전량 적재 회피 등)을 회귀 가드로 고정한다. 배경: 사이트맵 생성 잡이 1.4M 게시글을 전량 in-memory 적재해 반복 OOM 붕괴한 사례(#79). + +**스키마** (`scale` = block-array of flow objects — 내장 YAML fallback 파서가 flow 객체만 중첩 파싱하므로 `- { ... }` 형태): + +```yaml +scale: + - { n: 1500000, dataset: posts+products, assert: [bounded_peak_memory, child_count_correct, no_full_table_load] } +``` + +- `n`: 데이터셋 규모(건수). 10만 이상이어야 scale 축의 의미가 있다. +- `dataset`: 대용량 데이터셋 설명(선택). +- `assert`: 이 규모에서 검증할 단언(snake_case). 실 대용량 DB 대신 합성 iterator·계측(예: `memory_get_peak_usage`, `DB::listen`, 방송 스로틀 카운트)으로 성질을 고정해도 된다. + +**테스트 마킹** — `effects` 처럼 테스트 docblock `@scale` 마킹으로 각 assert 를 커버: + +```php +/** + * @scale n=1500000 asserts=bounded_peak_memory, child_count_correct + */ +public function test_large_volume_splits_and_keeps_memory_bounded(): void { ... } +``` + +TypeScript 테스트는 `// @scale ...` 라인 주석 동일 사용. + +**강제 룰 2종** (자동 차단): + +| 룰 | 대상 | 검출 | +|----|------|------| +| `scenario-scale-axis-required` | `tests/scenarios/**/*.yaml` | batch/generation 매니페스트(test_files 에 `/Jobs/`·`Generator` 또는 tags `[batch]`/`[generation]`)인데 scale 축 없음 / n<100000 / assert 미커버 | +| `job-generator-needs-scale-test` | `app/Jobs/**`·`**/*Generator.php` | 소스 변경 시 그 클래스를 언급하고 scale 축을 가진 매니페스트가 없음 | + +- `*Command.php` 는 흔하고 볼륨 비의존이라 `job-generator-needs-scale-test` 대상에서 제외한다. 배치 커맨드는 매니페스트 `tags: [batch]` + `scenario-scale-axis-required` 로 커버한다. +- 면제: 매니페스트 헤더 `# audit:allow scenario-scale-axis-required reason: ...` / 소스 헤더 `// audit:allow job-generator-needs-scale-test <사유>`. + ### 작성 절차 1. 영향 변수 카탈로그 식별 diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 087c00c0..43cb5bb9 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed - 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 0847a707..e21da48b 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -11,7 +11,7 @@ "ko": "게시판 관리를 위한 모듈", "en": "Module for board management" }, - "g7_version": ">=7.0.0", + "g7_version": ">=7.1.0", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index fa2706f7..84353f0e 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 7f383341..4afa939d 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -11,7 +11,7 @@ "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", "en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management" }, - "g7_version": ">=7.0.5", + "g7_version": ">=7.1.0", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 21db7ade..b624481d 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed - 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. ## [1.0.1] - 2026-07-08 diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json index 78007a38..cf7b196e 100644 --- a/modules/_bundled/sirsoft-page/module.json +++ b/modules/_bundled/sirsoft-page/module.json @@ -11,7 +11,7 @@ "ko": "정적 페이지(정보/정책/안내) 관리 모듈", "en": "Static page (info/policy/guide) management module" }, - "g7_version": ">=7.0.0", + "g7_version": ">=7.1.0", "dependencies": { "modules": {}, "plugins": {} diff --git a/tests/Feature/Seo/SitemapManagerModeTest.php b/tests/Feature/Seo/SitemapManagerModeTest.php index 1a4dd793..3c0ffa47 100644 --- a/tests/Feature/Seo/SitemapManagerModeTest.php +++ b/tests/Feature/Seo/SitemapManagerModeTest.php @@ -171,6 +171,8 @@ class SitemapManagerModeTest extends TestCase /** * incremental: 기여자 재쿼리 없이 저장소 스트림만으로 파일을 재작성한다. + * + * @scale n=1500000 asserts=no_full_table_load */ public function test_incremental_mode_streams_store_without_domain_requery(): void { diff --git a/tests/Unit/Seo/SitemapProgressTest.php b/tests/Unit/Seo/SitemapProgressTest.php index 90feda5e..cdfa73dd 100644 --- a/tests/Unit/Seo/SitemapProgressTest.php +++ b/tests/Unit/Seo/SitemapProgressTest.php @@ -144,6 +144,8 @@ class SitemapProgressTest extends TestCase /** * 같은 단계에서 urls 만 증가하면 방송은 간격 스로틀되지만 캐시는 항상 기록된다. + * + * @scale n=1500000 asserts=progress_broadcast_throttled */ public function test_phase_broadcast_is_throttled_but_cache_always_written(): void { diff --git a/tests/Unit/Seo/SitemapWriterTest.php b/tests/Unit/Seo/SitemapWriterTest.php index 1bbe3f02..37dedf5c 100644 --- a/tests/Unit/Seo/SitemapWriterTest.php +++ b/tests/Unit/Seo/SitemapWriterTest.php @@ -392,6 +392,8 @@ class SitemapWriterTest extends TestCase * 대용량(120,001건) 생성 시 자식 수가 정확하고 메모리가 유계인지 확인합니다. * * 전체 in-memory 적재(회귀)라면 URL 수에 비례해 메모리가 증가합니다. + * + * @scale n=1500000 asserts=bounded_peak_memory, child_count_correct */ public function test_large_volume_splits_and_keeps_memory_bounded(): void { diff --git a/tests/scenarios/sitemap-generation.yaml b/tests/scenarios/sitemap-generation.yaml new file mode 100644 index 00000000..d03e9fea --- /dev/null +++ b/tests/scenarios/sitemap-generation.yaml @@ -0,0 +1,103 @@ +# audit:allow test-scenario-coverage reason: 이슈 #481(공개 #79) 사이트맵 생성 확장성 SSoT. 6축 cross product(216 조합)는 docblock 전수 @scenario 매핑이 비현실적이라 면제하고, 회귀 가드는 test_files(백엔드 PHPUnit + 레이아웃 Vitest + Playwright E2E)로 커버한다. 대용량 축(scale)은 scenario-scale-axis-required / job-generator-needs-scale-test 룰이 별도 강제(면제 대상 아님) — scale.cases[].assert 는 @scale 마킹으로 전수 커버. + +feature: sitemap_generation + +description: | + 1.4M 게시글 + 20K 상품 규모에서 사이트맵 생성이 전체 DB in-memory 적재로 OOM 붕괴하던 것을, + 스트리밍 청크 writer(유계 메모리) + sitemapindex 분할 + 증분 저장소(sitemap_urls) + 큐 전환 + + 진행상황 가시화(Reverb 실시간 / OFF 폴링)로 재설계한다. + + 핵심 설계: + - 파일 생성은 SitemapWriter 가 자식파일당 in-memory 버퍼 후 flush — URL/바이트 임계 도달 시 분할. + - 서빙은 StorageInterface 비공개 디스크 stale/503 — 봇 요청 동기 생성 제거. + - 재생성 모드(SitemapGenerationMode): 관리자=full / 스케줄러=auto(빈 테이블 full, 아니면 incremental) / 리소스 변경=해당 리소스만. + - incremental 은 도메인 재쿼리 없이 sitemap_urls 스트림만으로 파일 재작성(리밸런싱 없음). + - 진행상황은 phase 기반(queued→running→writing→completed/failed), Reverb ON 방송 / OFF 캐시+폴링. + - 대용량 경로는 유계 메모리 + 분할 정확성 + 방송 스로틀을 scale 축으로 회귀 가드. + +axes: + multilingual: [off, on] + split_threshold: [under, over] + serve_mode: [fresh, stale, absent] + regen_mode: [full, auto, incremental] + trigger: [admin, scheduler, listener] + realtime: [reverb_on, reverb_off] + +exclusions: + - { trigger: admin, regen_mode: auto, reason: "관리자 수동 재생성은 항상 full (D7) — auto/incremental 조합 무의미" } + - { trigger: admin, regen_mode: incremental, reason: "관리자 수동 재생성은 항상 full (D7)" } + - { trigger: listener, regen_mode: full, reason: "리소스 변경 리스너는 해당 리소스만 증분 — full 재생성 아님 (D7)" } + - { trigger: listener, regen_mode: auto, reason: "리소스 변경 리스너는 증분 경로 — auto 판정은 스케줄러 담당" } + +effects: + # ── 생성 / 분할 / 서빙 (P0) ── + - index_emitted + - child_split_at_threshold + - stale_served_on_miss + - job_unique_lock + - async_dispatch_from_admin + # ── 재생성 모드 (P2) ── + - admin_regen_is_always_full + - scheduler_full_when_table_empty + - scheduler_incremental_when_table_populated + - incremental_append_on_publish + - incremental_remove_on_unpublish + - incremental_skips_rebalance + # ── 다국어 (P1) ── + - hreflang_bounded + # ── 진행상황 / 실시간 (P3) ── + - progress_recorded_per_phase + - progress_broadcast_when_reverb_on + - progress_cached_when_reverb_off + - polling_starts_only_when_reverb_off_and_running + - polling_stops_on_completed + - job_failure_marks_progress_failed + +# scale: 대용량 회귀 축. block-array of flow objects (내장 fallback 파서 제약 — flow 객체만 중첩 파싱) +scale: + - { n: 1500000, dataset: posts+products, assert: [bounded_peak_memory, child_count_correct, no_full_table_load, progress_broadcast_throttled] } + +test_files: + # 백엔드 — 스트리밍 코어 / 분할 / 렌더 (P0/P1) + - tests/Unit/Seo/SitemapWriterTest.php + - tests/Unit/Seo/SitemapGeneratorTest.php + - tests/Unit/Seo/SitemapFileStoreTest.php + - tests/Unit/Seo/SitemapManagerTest.php + - tests/Unit/Seo/SitemapXmlRendererTest.php + - tests/Unit/Seo/AbstractSitemapContributorTest.php + # 백엔드 — 잡 / 커맨드 (P0/P2) + - tests/Unit/Jobs/GenerateSitemapJobTest.php + - tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php + # 백엔드 — 서빙 / 증분 저장소 / 모드 (P0/P2) + - tests/Feature/Seo/SitemapTest.php + - tests/Feature/Seo/SitemapUrlRepositoryTest.php + - tests/Feature/Seo/SitemapIndexerTest.php + - tests/Feature/Seo/SitemapManagerModeTest.php + - tests/Feature/Seo/SitemapBackfillTest.php + # 백엔드 — 진행상황 / 상태 API / 채널 (P3) + - tests/Unit/Seo/SitemapProgressTest.php + - tests/Feature/Api/Admin/SeoCacheControllerTest.php + - tests/Feature/Api/BroadcastingAuthTest.php + # 모듈 — 리소스 단위 증분(리스너) 회귀 + - modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php + - modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php + # 프론트엔드 — SEO 탭 진행상황 UI (레이아웃 렌더링) + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx + # E2E — SEO 탭 분할기준/진행상황 + - tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts + +notes: | + P0(스트리밍/분할/서빙/잡) + P1(lazy/다국어) + P2(증분 저장소/모드) + P3(진행상황/Reverb)를 + 세션 S1~S5 에 걸쳐 구현했다(S6 하네스 마감). 6축 cross product 는 규모가 커 @scenario 전수 + 마킹 대신 test_files 로 회귀를 커버하고, effects 는 각 계층 테스트가 검증한다. + + 대용량(scale) 축은 실 1.5M DB 대신 합성 iterator(writer 120001) + drainCount 계측(incremental) + + 방송 스로틀 계측으로 유계성/정확성을 고정한다: + - bounded_peak_memory / child_count_correct → SitemapWriterTest::test_large_volume_splits_and_keeps_memory_bounded + - no_full_table_load → SitemapManagerModeTest::test_incremental_mode_streams_store_without_domain_requery + - progress_broadcast_throttled → SitemapProgressTest::test_phase_broadcast_is_throttled_but_cache_always_written + + scale 강제 대상 소스: app/Jobs/GenerateSitemapJob.php, app/Seo/SitemapGenerator.php + (job-generator-needs-scale-test 룰이 변경 시 이 매니페스트의 scale 블록을 요구).