# SEO 페이지 생성기 시스템 (SEO Page Generator)
> 그누보드7 SEO 시스템의 전체 규정 문서 (SSoT)
## TL;DR (5초 요약)
```text
1. SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → SeoRenderer가 정적 HTML 생성 (캐시 우선)
2. 봇 감지: jaybizzle/crawler-detect 라이브러리(약 1,000종) + G7 보강 패턴(미커버 3종) + 운영자 커스텀 패턴 + core.seo.resolve_is_bot 훅 — 4-레이어 체인
3. 다국어 SEO: ?locale=en 쿼리 기반 + hreflang 태그 + 다국어 sitemap (supported_locales 2개 이상 시 자동)
4. meta.seo: 레이아웃 JSON에서 SEO 렌더링 대상 선언 (enabled, og, structured_data, vars, page_type, toggle_setting)
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 등)
7. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap
```
## 아키텍처 개요
```
Request → web.php catch-all → SeoMiddleware (봇 감지)
│
┌─────────────┴─────────────┐
│ │
[검색 봇] [일반 사용자]
│ │
[캐시 확인] 기존 SPA 응답
/ \ (app.blade.php)
HIT MISS
│ │
캐시 HTML SeoRenderer 실행
반환 1. TemplateRouteResolver (URL→레이아웃)
2. LayoutService.getLayout() (병합된 JSON)
3. DataSourceResolver (API 호출)
─── 훅: core.seo.filter_context ───
4. ExpressionEvaluator (바인딩 평가)
5. SeoMetaResolver.resolve (배열 형태 og/twitter/structured_data)
6. 모듈/플러그인 declaration cascade
(seoOgDefaults / seoTwitterDefaults / seoStructuredData)
─── 훅: core.seo.filter_og_data / filter_twitter_data / filter_structured_data ───
7. HTML/JSON 재렌더 (renderOgHtml / renderTwitterHtml / renderStructuredJson)
─── 훅: core.seo.filter_meta (통합) ───
8. ComponentHtmlMapper (컴포넌트→HTML)
─── 훅: core.seo.filter_view_data ───
9. seo.blade.php (최종 HTML — og + twitter + jsonLd 슬롯)
10. SeoCacheManager (결과 캐시)
```
## 코어 클래스 및 인터페이스
### Contracts (`app/Seo/Contracts/`)
| 인터페이스 | 용도 |
|-----------|------|
| SeoRendererInterface | SEO HTML 렌더링 |
| SeoCacheManagerInterface | 캐시 저장/조회/무효화 |
| SitemapContributorInterface | 확장별 Sitemap URL 기여 |
### 코어 클래스 (`app/Seo/`)
| 클래스 | 역할 |
|--------|------|
| SeoRenderer | 전체 렌더링 파이프라인 오케스트레이션 |
| SeoCacheManager | 캐시 관리 (file/redis) |
| BotDetector | User-Agent 기반 봇 감지 |
| ExpressionEvaluator | `{{}}` 바인딩 표현식 평가 |
| ComponentHtmlMapper | 컴포넌트→HTML 태그 매핑 |
| DataSourceResolver | 내부 HTTP API 호출 |
| TemplateRouteResolver | URL→레이아웃 매핑 |
| SeoMetaResolver | 3계층 캐스케이드 메타 해석 |
| SeoMiddleware | 미들웨어 (봇 감지 → 렌더링) |
| SeoServiceProvider | DI 바인딩 |
| SitemapGenerator | Sitemap 수집기 |
| SeoInvalidationRegistry | 무효화 규칙 레지스트리 |
| SeoDeclarationCollector | 레이아웃 SEO 선언 수집 |
| SeoCacheStatsService | 캐시 통계 집계 |
### Admin API (`app/Http/Controllers/Api/Admin/SeoCacheController.php`)
| URL | 메서드 | 라우트명 | 설명 |
|-----|--------|---------|------|
| /api/admin/seo/stats | GET | api.admin.seo.stats | 캐시 통계 |
| /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 목록 |
## SeoMiddleware 동작 규칙
| 항목 | 값 |
|------|-----|
| 클래스 | `App\Seo\SeoMiddleware` |
| 별칭 | `seo` |
| 등록 위치 | User catch-all 라우트 그룹에만 |
| 금지 | 전역 등록 / Admin 라우트 부착 |
- 봇 감지: `BotDetector` 4-레이어 체인 (아래 "봇 감지 구조" 섹션 참조)
- 렌더링 실패 시: SPA fallback (기존 응답 통과)
## 봇 감지 구조
`BotDetector::isBot()` 는 다음 체인을 순서대로 평가합니다 — 어느 레이어에서든 결정이 나면 즉시 반환:
| # | 레이어 | 동작 | 비고 |
| - | ----- | ---- | ---- |
| 1 | `seo.bot_detection_enabled` | false 면 즉시 false | 전역 비활성 |
| 2 | `_escaped_fragment_` 쿼리 | 존재하면 true | 구형 크롤러 호환 |
| 3 | UA 빈 문자열 | false | UA 없는 요청 |
| 4 | `core.seo.resolve_is_bot` 훅 | 결과가 non-null 이면 즉시 결정 | 확장 슬롯 |
| 5 | `seo.bot_detection_library_enabled` (기본 true) | jaybizzle/crawler-detect + G7 보강 + 사용자 패턴을 단일 정규식으로 평가 | 주 경로 |
| 6 | 라이브러리 비활성 시 | `seo.bot_user_agents` stripos 매칭만 | 레거시 모드 |
### jaybizzle/crawler-detect 통합
- 라이브러리: `jaybizzle/crawler-detect` ^1.3.9 — MIT, 약 1,000종의 검색·링크 미리보기·AI 봇 패턴 + 76종 정상 브라우저 Exclusions
- 유지보수: `composer update`로 상류 패치 흡수 (월 단위 릴리스)
- 확장 지점: `App\Seo\BotDetectorCustomProvider` 가 `CrawlerDetect` 를 서브클래싱하여 `Crawlers` fixture 의 `$data` 배열에 G7 보강 패턴(미커버 3종) + 사용자 입력 패턴(`preg_quote()` 리터럴 이스케이프)을 병합
- G7 보강 패턴 (jaybizzle 미커버, 상류 PR 후 단계적 제거 예정):
- `kakaotalk-scrap` — 카카오톡 링크 미리보기
- `Meta-ExternalAgent` — Meta(Facebook) 학습 크롤러
- `ChatGPT-User` — ChatGPT 브라우징
### `seo.bot_user_agents` 의 역할
운영자가 "추가 봇 패턴" UI 에 입력하는 값. 라이브러리가 놓치는 조직별 봇만 등록. UA 부분 문자열로 처리되며, 라이브러리 정규식 배열에 `preg_quote()` 로 이스케이프 후 병합되므로 정규식 메타문자(`.`, `+`, `?` 등)는 리터럴로 취급.
### 라이브러리 비활성화 (레거시)
`seo.bot_detection_library_enabled = false` 설정 시 라이브러리 경로를 우회하고 `seo.bot_user_agents` stripos 매칭만 수행. 잘 알려진 봇이라도 사용자 목록에 없으면 감지하지 못함.
### 확장 훅 `core.seo.resolve_is_bot`
플러그인이 IP 범위 검증·역방향 DNS·Cloudflare 봇 점수 등을 주입할 수 있는 슬롯:
```php
HookManager::addFilter('core.seo.resolve_is_bot', function ($prev, array $ctx) {
/** @var \Illuminate\Http\Request $request */
$request = $ctx['request'];
$userAgent = $ctx['userAgent'];
if (ipInGoogleRange($request->ip())) {
return true; // 즉시 봇 결정
}
return $prev; // null 반환 시 다음 레이어로 fallthrough
});
```
반환 규약:
- `true` / `false` → 즉시 봇 결정 (체인 종료)
- `null` → 다음 레이어로 진행 (jaybizzle 라이브러리 평가)
- 여러 리스너가 등록되면 `applyFilters` 우선순위 순으로 평가, 마지막 non-null 값이 채택됨
## SeoRenderer `_global` 컨텍스트 주입
SEO 렌더링 시 `_global` 컨텍스트에 프론트엔드 설정 데이터를 주입합니다. Header/Footer 등 공통 영역이 `_global` 경로의 데이터(사이트명, 네비게이션 등)를 참조할 수 있도록 합니다.
### 자동 주입 데이터
| 키 | 소스 | 설명 |
|----|------|------|
| `_global.settings` | `SettingsService` | 코어 프론트엔드 설정 (사이트명, 로고 등) |
| `_global.modules` | `SettingsService` | 활성 모듈 목록 |
| `_global.pluginSettings` | `PluginSettingsService` | 플러그인 설정 데이터 |
| `_global.site_name` | `general.site_name` | 사이트 이름. `resolveLocalizedValue` 통과 (다국어 array 안전) |
| `_global.site_url` | `general.site_url` | 사이트 URL |
### site_name SSoT 체인 (다국어 일관성)
`general.site_name` 은 사이트 식별 기준값(SSoT)이며, 다음 경로에서 사용된다. 운영자가 다국어 JSON array 로 저장할 수 있으므로 **모든 경로가 현재 로케일 string 으로 정규화**해야 한다 (array 를 그대로 `(string)`/Blade `e()` 캐스팅하면 "Array to string conversion" TypeError 발생).
| 경로 | 출처 | 정규화 |
|------|------|--------|
| OG `og:site_name` | `seo.og_default_site_name ?? general.site_name` (`SeoMetaResolver`) | `resolveLocalizedValue` ✅ |
| JSON-LD `WebSite.name` / `{site_name}` 치환 | `_global.site_name` ← `general.site_name` (`SeoRenderer::buildGlobalContext`) | `resolveLocalizedValue` ✅ |
| SPA `
` | `config('app.name')` ← `general.site_name` (`SettingsServiceProvider::applyAppConfig`) | `localizeSettingValue` ✅ |
| canonical | URL 기반, site_name 미사용 (`seo.blade.php`) | N/A (무관) |
> **OG 직접입력은 의도된 override**: `seo.og_default_site_name` 에 값을 입력하면 그 값이 우선하고, 비우면 `general.site_name` 을 따른다. admin SEO 탭의 OG 입력칸 라벨/안내가 이 "선택 재정의" 위상을 명시한다. 자동 동기화/칸 제거는 하지 않는다 (drift 는 명시적 override 로 허용).
>
> `resolveLocalizedValue` 의 SSoT 는 `app/Seo/Concerns/LocalizesSeoValues` 트레이트. `SettingsServiceProvider` 는 `register()` 단계(DI 전)라 트레이트 대신 동일 의미의 `localizeSettingValue` 를 인라인한다.
### initGlobal 매핑
데이터소스의 `initGlobal` 설정에 따라 API 응답 데이터를 `_global` 경로에 매핑합니다.
```json
// 레이아웃 data_source 예시
{
"id": "boards",
"endpoint": "/api/boards",
"initGlobal": "boards"
}
```
위 설정은 `boards` API 응답의 `data` 키를 `_global.boards`에 매핑합니다.
| initGlobal 값 | 동작 |
|---------------|------|
| 문자열 (`"boards"`) | API 응답 `data` 키 전체를 `_global.{값}`에 매핑 |
| 객체 (`{ "settingsAbilities": "data.abilities" }`) | API 응답의 특정 경로를 `_global.{키}`에 개별 매핑 |
## 레이아웃 JSON meta.seo 스키마
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| seo.enabled | boolean | X | SEO 렌더링 활성화 (기본: false) |
| seo.data_sources | string[] | X | SEO 시 사전 로드할 data_sources ID |
| seo.page_type | string | X | 모듈 설정 템플릿 키 결정 (예: `"product"` → `seo.meta_product_title`) |
| seo.toggle_setting | string | X | SEO 활성화 토글 설정 경로 (예: `"$module_settings:seo.seo_product_detail"`) |
| seo.vars | object | X | SEO 변수 선언 — 모듈 설정 title/description 템플릿의 `{key}` 치환용 |
| seo.priority | number | X | sitemap priority (0.0~1.0) |
| seo.changefreq | string | X | sitemap changefreq (daily/weekly 등) |
| seo.og | object | X | Open Graph 메타태그 |
| seo.structured_data | object | X | JSON-LD 구조화 데이터 |
### structured_data 빈 객체 자동 제거
`structured_data`에서 `@type`이 있는 하위 객체 중 **하나라도 빈 값(`""` 또는 `null`)인 필드가 있으면** 해당 객체 전체가 JSON-LD에서 제거됩니다. Google 구조화 데이터 검증기가 필수 필드 누락을 에러로 처리하기 때문입니다.
```json
// 리뷰가 없는 상품 → aggregateRating 자동 제거
"structured_data": {
"@type": "Product",
"name": "{{product.data.name}}",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{reviews.data.rating_stats.avg ?? ''}}",
"reviewCount": "{{reviews.data.reviews.total ?? ''}}",
"bestRating": "5",
"worstRating": "1"
}
}
// ratingValue="" → aggregateRating 블록 전체 제거됨
```
- `@type`이 없는 일반 객체는 제거 대상 아님
- 모든 필드가 채워진 경우 정상 출력
### 상속 규칙
| 키 유형 | 병합 전략 | 예시 |
|---------|----------|------|
| 스칼라 (enabled, priority, changefreq, page_type) | 자식 우선 오버라이드 | 부모 `0.5` + 자식 `0.8` → `0.8` |
| 연관 배열 (og, vars, structured_data) | deep merge (array_replace_recursive) | 부모 `og.type` + 자식 `og.title` → 양쪽 보존 |
| data_sources (숫자 배열) | 합집합 + 중복 제거 (permissions와 동일) | 부모 `["stats"]` + 자식 `["product"]` → `["stats", "product"]` |
- 부모 base 레이아웃에서 공통 SEO 기본값 정의 가능 (`enabled: false` + `og.type: "website"`)
- 자식이 `enabled: true`로 오버라이드하여 SEO 활성화
- 3단계 이상 상속 시 각 레벨의 data_sources가 누적됨
- Partial에 meta.seo 정의 금지 (무시됨)
- meta.seo 추가/변경 시 `UpdateLayoutContentRequest` 검증 규칙 동기화 필수
## 3계층 캐스케이드 메타 해석
```
우선순위: 리소스 개별 설정 > 모듈 설정 > 코어 설정 + 코어 suffix 항상 추가
상품 상세 페이지 title 결정:
1순위: 상품 meta_title 있으면 → 사용
2순위: 모듈 meta_product_title 템플릿 → "{commerce_name} - {product_name}" 치환
3순위: 코어 meta_description → fallback
최종: + 코어 meta_title_suffix 항상 추가
```
| 페이지 | title 우선순위 | description 우선순위 |
|--------|---------------|---------------------|
| 상품 상세 | 상품 `meta_title` → 모듈 `meta_product_title` → 코어 | 상품 `meta_description` → 모듈 → 코어 |
| 카테고리 | 카테고리 `meta_title` → 모듈 `meta_category_title` → 코어 | 카테고리 `meta_description` → 모듈 → 코어 |
| 검색 결과 | 모듈 `meta_search_title` → 코어 | 모듈 `meta_search_description` → 코어 |
| 페이지 | 페이지 `seo_meta.title` → 코어 | 페이지 `seo_meta.description` → 코어 |
| 메인/게시판 | 코어 title (레이아웃 meta.title) | 코어 meta_description |
## 레이아웃 meta.seo 선언적 설정
SEO 엔진은 특정 모듈/템플릿 지식을 갖지 않습니다. 모든 SEO 변수, 페이지 유형, 토글 설정은 **레이아웃 JSON이 `meta.seo`에 선언**합니다.
### vars — SEO 변수 선언
모듈 설정 title/description 템플릿의 `{key}` 플레이스홀더를 치환할 변수를 선언합니다.
```json
"seo": {
"vars": {
"product_name": "{{product.data.name ?? ''}}",
"product_description": "{{product.data.short_description ?? product.data.description ?? ''}}",
"commerce_name": "$module_settings:basic_info.shop_name",
"site_name": "$core_settings:general.site_name",
"keyword_name": "$query:q"
}
}
```
#### vars 접두사 문법
| 접두사 | 의미 | 예시 |
|--------|------|------|
| `{{expr}}` | 표현식 (context 데이터) | `"{{product.data.name ?? ''}}"` |
| `$module_settings:` | 모듈 설정 값 (컨텍스트 모듈) | `"$module_settings:basic_info.shop_name"` |
| `$module_settings:ID:` | 모듈 설정 값 (명시적 모듈 ID) | `"$module_settings:sirsoft-ecommerce:basic_info.shop_name"` |
| `$plugin_settings:` | 플러그인 설정 값 (컨텍스트 플러그인) | `"$plugin_settings:basic.payment_name"` |
| `$plugin_settings:ID:` | 플러그인 설정 값 (명시적 플러그인 ID) | `"$plugin_settings:sirsoft-payment:basic.payment_name"` |
| `$core_settings:` | 코어 설정 값 | `"$core_settings:general.site_name"` |
| `$query:` | 쿼리 파라미터 | `"$query:q"` |
- `vars` 미선언 시: 치환 없이 원본 템플릿 반환 (빈 문자열)
- 다국어 객체 반환 시: 현재 로케일 값 자동 해석
- 명시적 확장 ID (`$module_settings:MODULE_ID:key`): `moduleIdentifier`가 null인 템플릿 레벨 레이아웃에서도 특정 모듈 설정 참조 가능
### page_type — 확장 설정 키 결정
모듈/플러그인 설정에서 어떤 meta title/description 템플릿을 사용할지 결정합니다.
```json
"seo": {
"page_type": "product"
}
```
| page_type | 모듈/플러그인 설정 키 |
|-----------|----------------------|
| `product` | `seo.meta_product_title`, `seo.meta_product_description` |
| `category` | `seo.meta_category_title`, `seo.meta_category_description` |
| `search` | `seo.meta_search_title`, `seo.meta_search_description` |
| `checkout` | `seo.meta_checkout_title`, `seo.meta_checkout_description` (플러그인 예시) |
- `page_type` 미선언 시: Tier 2 (확장 설정 템플릿) 스킵, Tier 1 (코어) fallback 사용
- 모듈 레이아웃 → `g7_module_settings()`, 플러그인 레이아웃 → `g7_plugin_settings()` 사용
### toggle_setting — 확장별 SEO 토글
확장(모듈/플러그인) 관리자 설정에서 특정 페이지의 SEO를 비활성화할 수 있습니다.
```json
// 모듈 레이아웃 예시
"seo": {
"toggle_setting": "$module_settings:seo.seo_product_detail"
}
// 플러그인 레이아웃 예시
"seo": {
"toggle_setting": "$plugin_settings:seo.enabled"
}
```
- 설정값이 `false` → SEO 렌더링 건너뜀 (null 반환)
- `toggle_setting` 미선언 → 무조건 활성
- `$module_settings:` / `$plugin_settings:` / `$core_settings:` 접두사 지원
- 명시적 모듈 ID도 지원: `"$module_settings:sirsoft-ecommerce:seo.seo_index"` (템플릿 레벨 레이아웃용)
### 레이아웃 예시
```json
{
"meta": {
"seo": {
"enabled": true,
"data_sources": ["product"],
"page_type": "product",
"toggle_setting": "$module_settings:seo.seo_product_detail",
"vars": {
"product_name": "{{product.data.name ?? ''}}",
"commerce_name": "$module_settings:basic_info.shop_name",
"site_name": "$core_settings:general.site_name"
},
"og": { ... },
"structured_data": { ... }
}
}
}
```
## SEO 렌더러 훅 시스템
확장(모듈/플러그인)이 SEO 렌더링 파이프라인에 개입할 수 있는 Filter 훅 3종.
선언적 커스터마이징(`seo-config.json`, `meta.seo`)으로 불가능한 **런타임 데이터 변환**이 필요한 경우에만 사용.
### 훅 목록
| 훅 이름 | 타입 | 필터 대상 | 위치 |
|---------|------|----------|------|
| `core.seo.filter_context` | Filter | 데이터 컨텍스트 전체 | DataSource 해석 후, 메타 해석 전 |
| `core.seo.filter_og_data` | Filter | OG 데이터 배열 (분기별) | resolveOgData + 모듈 declaration 후 |
| `core.seo.filter_twitter_data` | Filter | Twitter 카드 배열 (분기별) | resolveTwitterData + 모듈 declaration 후 |
| `core.seo.filter_structured_data` | Filter | JSON-LD 배열 (분기별) | resolveStructuredDataArray + 모듈 declaration 후 |
| `core.seo.filter_meta` | Filter | 메타 태그 통합 배열 | 모든 분기 결합 후 |
| `core.seo.filter_view_data` | Filter | View 변수 배열 | View::make() 직전 |
**확장 변경 슬롯 선택 가이드**:
- OG 만 손대고 싶다 → `filter_og_data`
- Twitter 카드만 → `filter_twitter_data`
- JSON-LD review 배열 추가 등 → `filter_structured_data`
- 통합 결과를 한 번에 → `filter_meta` (마지막 단계)
### core.seo.filter_context
**위치**: `SeoRenderer.render()` — DataSource + initGlobal + _local 초기화 완료 후, vars 해석 전
```php
$context = HookManager::applyFilters('core.seo.filter_context', $context, [
'layoutName' => $layoutName,
'moduleIdentifier' => $moduleIdentifier,
'pluginIdentifier' => $pluginIdentifier,
'routeParams' => $routeParams,
'locale' => $locale,
]);
```
- **필터 대상**: `$context` — 전체 데이터 컨텍스트 (`_global`, `_local`, DataSource 결과, `route`, `query`)
- **추가 인수**: 메타 정보 (읽기 전용 참고용)
- **유즈케이스**: 리뷰 플러그인이 `$context['reviews_aggregate']` 추가, 쿠폰 플러그인이 상품 데이터에 `priceValidUntil` 보강
### core.seo.filter_meta
**위치**: `SeoRenderer.render()` — SeoMetaResolver.resolve() 직후
```php
$meta = HookManager::applyFilters('core.seo.filter_meta', $meta, [
'layoutName' => $layoutName,
'moduleIdentifier' => $moduleIdentifier,
'pluginIdentifier' => $pluginIdentifier,
'context' => $context,
'locale' => $locale,
]);
```
- **필터 대상**: `$meta` — `title`, `titleSuffix`, `description`, `keywords`, `og` (배열), `twitter` (배열), `structured_data` (배열|null), `ogTags`/`twitterTags`/`jsonLd` (HTML/JSON 직렬화 결과), `googleAnalyticsId` 등
- **유즈케이스**: SEO 플러그인이 title suffix 변경, 리뷰 플러그인이 JSON-LD에 review 배열 주입
- **재렌더 규칙**: 청취자가 `og`/`twitter`/`structured_data` 배열을 수정하면 SeoRenderer 가 `ogTags`/`twitterTags`/`jsonLd` 를 자동 재렌더 (수정한 키만 보면 됨)
### core.seo.filter_og_data / filter_twitter_data / filter_structured_data
**위치**: `SeoRenderer.render()` — 모듈/플러그인 `seoOgDefaults`/`seoTwitterDefaults`/`seoStructuredData` cascade 후, `filter_meta` 전
```php
$meta['og'] = HookManager::applyFilters('core.seo.filter_og_data', $meta['og'], [
'layoutName' => $layoutName,
'moduleIdentifier' => $moduleIdentifier,
'pluginIdentifier' => $pluginIdentifier,
'context' => $context,
'locale' => $locale,
'pageType' => $pageType,
]);
// 동일 페이로드 + ctx 로 filter_twitter_data, filter_structured_data 호출
```
**`$og` 배열 스키마**:
| 키 | 타입 | 설명 |
|----|-----|-----|
| `type` | string | og:type (product/article/website 등) |
| `title` / `description` | string | 텍스트 |
| `image` | string (절대 URL) | og:image |
| `image_secure_url` | string | og:image:secure_url (HTTPS) |
| `image_width` / `image_height` | int\|null | 픽셀 크기 |
| `image_type` | string | image/jpeg 등 MIME |
| `image_alt` | string | og:image:alt |
| `site_name` | string | og:site_name |
| `locale` | string | og:locale |
| `extra` | array<{property,content}> | 자유 og:* 메타태그 (예: product:price:amount) |
**`$twitter` 배열 스키마**:
| 키 | 타입 | 설명 |
|----|-----|-----|
| `card` | string | summary / summary_large_image / app / player |
| `site` / `creator` | string | @핸들 |
| `title` / `description` / `image` / `image_alt` | string | OG fallback |
| `extra` | array<{name,content}> | 자유 twitter:* |
**`$structured_data`**: Schema.org JSON-LD 배열 (`@type` 필수). 청취자가 자유롭게 키 추가/수정 가능. SeoRenderer 가 `@context: https://schema.org` 자동 prepend.
**유즈케이스**:
```php
HookManager::addFilter('core.seo.filter_og_data', function (array $og, array $ctx) {
if ($ctx['pageType'] === 'product') {
$og['extra'][] = ['property' => 'product:availability', 'content' => 'in stock'];
}
return $og;
}, 10, 2);
```
### core.seo.filter_view_data
**위치**: `SeoRenderer.render()` — View::make() 직전
```php
$viewData = HookManager::applyFilters('core.seo.filter_view_data', $viewData, [
'layoutName' => $layoutName,
'moduleIdentifier' => $moduleIdentifier,
'pluginIdentifier' => $pluginIdentifier,
]);
```
- **필터 대상**: View 변수 배열 전체 (`locale`, `title`, `bodyHtml`, `extraHeadTags`, `extraBodyEnd` 등)
- **유즈케이스**: Analytics 플러그인이 `extraHeadTags`에 추적 스크립트 삽입, PWA 플러그인이 manifest 링크 주입
### seo.blade.php 확장 슬롯
| 변수명 | 위치 | 용도 |
|--------|------|------|
| `extraHeadTags` | `` 직전 | 커스텀 메타 태그, 스크립트, 스타일 |
| `extraBodyEnd` | `