o2o-site-AEO/docs/RENDERING.md
Mina Choi 4ff92fb0a6 [docs] docs: 문서 정리 — DEVLOG 요약 · 렌더링 흐름 문서 추가 · 삭제한 방향 문서 링크 정리
DEVLOG 가 1,900줄이 넘어 최근에 무엇을 왜 바꿨는지 찾기 어려웠다.

- DEVLOG.md: 개발 이력상 남길 가치가 있는 항목만 요약
- RENDERING.md: 정적 사이트 · 미리보기 · 발행 세 경우의 흐름과 담당 파일
- README · AGENTS · PRODUCT: 삭제한 DEVELOPMENT_DIRECTION.md 링크 정리(삭제 자체는 앞 커밋),
  admin 은 필요한 규모가 되면 개발한다는 안내, 템플릿 문서 링크

문서만 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:45:24 +09:00

158 lines
12 KiB
Markdown

# 렌더링 한눈에 보기
사이트가 화면에 그려지는 경우는 세 가지다. 셋 다 그리는 코드는 `solution/site/src/App.tsx` 하나이고,
**누가 언제 그리느냐**만 다르다.
| 경우 | 누가 그리나 | 입력 |
|---|---|---|
| 정적 사이트 | 워커가 미리 구운 HTML → 브라우저가 이어받음 | HTML 안에 심어 둔 payload |
| 미리보기 | 브라우저가 처음부터 | API 가 그때그때 만든 payload |
| 발행 | 워커가 Node 렌더러를 돌려 HTML 을 굽는다 | DB → payload 파일 |
경로는 레포 루트 기준이다. `site/` 는 `solution/site/`, `backend/` 는 `solution/backend/` 를 줄인 것이다.
---
## 1. 정적 사이트 — 손님·크롤러가 `/s/<slug>` 를 받을 때
```mermaid
flowchart TD
A["손님 · 크롤러<br/>GET /s/&lt;slug&gt;"] --> B["nginx<br/>location ^~ /s/"]
B --> C["out/s/&lt;slug&gt;<br/>(심볼릭 링크)"]
C --> D["out/versions/&lt;slug&gt;/&lt;ver&gt;/index.html"]
D -->|크롤러는 여기까지| E["HTML · JSON-LD · meta"]
D --> F["브라우저가 /assets/index-해시.js · .css 를 받음"]
F --> G["entry-client.tsx<br/>window.__SITE_PAYLOAD__ 있음"]
G --> H["hydrateRoot(App)<br/>버튼·달력 등 동작이 붙는다"]
D --> I["사진 /s/&lt;slug&gt;/img/*<br/>노래 /s/&lt;slug&gt;/*.mp3"]
```
| 단계 | 하는 일 | 파일 |
|---|---|---|
| 요청 받기 | `/s/<slug>` 는 구운 파일을 그대로 준다. `/s` 는 목록, `/s/` 는 `/s` 로 301 | `nginx/site.conf.example` (`location = /s`, `location ^~ /s/`) |
| 공개 버전 찾기 | `out/s/<slug>` 는 지금 공개 중인 버전 폴더를 가리키는 링크다 | `site/scripts/prerender.ts` `publishVersion` |
| HTML | 본문·`<head>`(title·canonical·JSON-LD)·심어 둔 payload 가 한 파일에 있다 | `out/versions/<slug>/<ver>/index.html` |
| 번들 | 해시 이름의 JS·CSS. 1년 캐시 | `nginx/site.conf.example` `location ^~ /assets/` → `out/assets/` |
| 이어받기 | 심어 둔 payload 로 같은 화면을 다시 만들어 마크업에 동작을 붙인다 | `site/src/entry-client.tsx` (`hydrateRoot`) |
| 그리기 | 템플릿의 레이아웃을 고르고 섹션을 순서대로 그린다 | `site/src/App.tsx` → `site/src/pages/SectionList.tsx` |
검색엔진이 읽는 건 구운 HTML 이다. 렌더러를 고쳐도 이미 구운 HTML 은 사장님이 다시 발행하기 전까지 그대로다.
---
## 2. 미리보기 — 빌더 iframe `/preview?placeId=…`
```mermaid
flowchart TD
A["빌더에서 템플릿·색·섹션 저장<br/>POST …/site/template · …/site/theme"] --> B["onSiteThemeSaved 신호"]
B --> C["SitePreview.tsx<br/>iframe 다시 로드"]
C --> D["GET /preview?placeId=…<br/>nginx location = /preview"]
D --> E["out/preview/index.html<br/>빈 껍데기 + 번들"]
E --> F["entry-client.tsx renderPreview"]
F --> G["GET /v1/place/{id}/site/preview"]
G --> H["SiteService.preview_payload<br/>build_snapshot → prepare_site_payload"]
H --> F
F --> I["themeVars · 폰트 로드"]
I --> J["createRoot(App)"]
J --> K["postMessage o2o:preview-painted"]
K --> L["빌더가 스피너를 걷는다<br/>(12초 상한)"]
```
| 단계 | 하는 일 | 파일 |
|---|---|---|
| 다시 그릴 때를 안다 | 저장이 끝나면 iframe 을 새로 고친다. 보던 스크롤 위치는 지킨다 | `solution/frontend/src/features/builder/SitePreview.tsx`, `solution/frontend/src/features/publish/siteTheme.ts` `onSiteThemeSaved` |
| 껍데기 받기 | 본문이 빈 HTML. `noindex` 가 붙어 있다 | `nginx/site.conf.example` `location = /preview` → `out/preview/index.html` (`prerender.ts` `writePreviewShell`) |
| payload 받기 | 로그인 토큰을 붙여 API 를 부른다 | `site/src/entry-client.tsx` `renderPreview` |
| payload 만들기 | 발행과 같은 함수로 만든다. 버전도 파일도 만들지 않는다 | `backend/router/v1/site/site.py` `site_preview` → `backend/services/site_service.py` `preview_payload` → `services/snapshot.py` `build_snapshot` → `services/site_payload.py` `prepare_site_payload` |
| 템플릿 확인 | 모르는 id 면 API 가 422, 화면은 에러 문구로 멈춘다 | `backend/common/template_catalog.py`, `solution/shared/src/lib/catalog.ts` `templateOf` |
| 그리기 | 색 변수·폰트를 먼저 넣고 처음부터 그린다 | `entry-client.tsx` (`themeVars`, `fontHref` ← `site/src/seo/head.ts`), `App.tsx` |
| 완료 알림 | 두 프레임 뒤 부모 창에 알린다. 빌더는 출처와 iframe 을 확인한다 | `entry-client.tsx` `signalPreviewPainted`, `SitePreview.tsx` `PAINT_TIMEOUT_MS` |
미리보기는 사진을 내려받지 않는다. 원래 주소를 그대로 쓴다.
---
## 3. 발행 — 무엇을 읽고 무엇을 쓰나
```mermaid
flowchart TD
A["사장님 '발행하기'<br/>POST /v1/place/{id}/site/build"] --> B["SiteService.start_build<br/>jobs 에 BUILD"]
B --> C["워커 worker/handlers.py<br/>build_service.run_build"]
C --> D["build_snapshot<br/>DB 값 모으기 · site_versions 행 추가"]
D --> E{"1차 게이트<br/>상호·업종·사실 확인 · 템플릿 id"}
E -->|실패| X["버전 FAILED · 발행 로그"]
E --> F["emit_payload<br/>payloads/&lt;slug&gt;.json"]
F --> G["render_service.render_site<br/>node prerender.js --stage-only"]
G --> H["mirrorMedia → prerenderSite<br/>out/versions/&lt;slug&gt;/&lt;ver&gt;/"]
H --> I["보고서<br/>payloads/.status/&lt;slug&gt;.json"]
I --> J{"2차 게이트<br/>publish_gate.evaluate"}
J -->|실패| X
J --> K["render_service.activate_site<br/>node prerender.js --activate=slug:ver"]
K --> L["out/s/&lt;slug&gt; 링크 전환<br/>루트 sitemap · robots · llms 갱신"]
L --> M["Azure 업로드 · 썸네일 · IndexNow"]
M --> N["DB 기록<br/>버전 BUILT · sites PUBLISHED · 발행 로그"]
```
| 단계 | 하는 일 | 파일 |
|---|---|---|
| 잡 넣기 | 검증 안 된 사업장은 막는다. 같은 사업장 BUILD 는 겹치지 않는다 | `backend/router/v1/site/site.py` `start_build` → `services/site_service.py` `start_build` |
| 잡 집기 | BUILD 잡을 `run_build` 로 넘긴다 | `backend/worker/handlers.py` |
| 스냅샷 | DB 값을 한 벌로 모아 `site_versions.snapshot` 에 박제한다 | `services/snapshot.py` `build_snapshot`, `services/build_service.py` `run_build` |
| 1차 게이트 | 상호명·업종·사실 확인 여부, 템플릿 id | `services/publish_gate.py` `check_facts_verified`, `common/template_catalog.py` `resolve_template_id` |
| payload | JSON 으로 쓴다. 임시 파일에 쓰고 이름을 바꾼다 | `services/site_payload.py` `emit_payload` → `write_payload` |
| 굽기 | Node 를 직접 실행한다. 파일 잠금으로 한 번에 하나만 돈다 | `services/render_service.py` `render_site` (`.render.lock`) |
| 렌더 | 공개 금지 값 걸러내기 → 사진 내려받기 → HTML·JSON-LD·llms.txt → 대조 | `site/scripts/prerender.ts` `sanitizePayloadForPublish` · `mirrorMedia` · `prerenderSite` · `verifyJsonLd` |
| 2차 게이트 | 보고서의 대조 결과·고유 콘텐츠 건수로 판정 | `services/publish_gate.py` `evaluate`, `services/render_report.py` |
| 공개 전환 | 검증된 버전인지 보고서로 다시 보고 링크를 바꾼다 | `render_service.activate_site` → `prerender.ts` `publishVersion` · `writeRootMachineFiles` |
| 바깥 알리기 | 설정된 경우만 돈다 | `services/azure_static.py` `publish`, `services/site_thumbnail.py` `store`, `services/indexnow.py` `submit` |
| DB 기록 | 버전·사이트·사업장 상태와 발행 로그를 남긴다 | `services/build_service.py` `run_build` · `_log` |
### 입력
| 무엇 | 어디서 | 읽는 쪽 |
|---|---|---|
| DB 값 | `place_facts` `place_units` `place_faqs` `place_photos` `place_songs` `place_posts` `place_reviews` `place_social_posts` `area_contents` `site_sections` `sites` | `services/snapshot.py` `build_snapshot` |
| 템플릿 목록 | `solution/shared/src/data/templates.json` | 백엔드 `common/template_catalog.py`, 렌더러 `shared/src/lib/catalog.ts` |
| payload | `site/payloads/<slug>.json` (`SITE_PAYLOAD_DIR`) | `prerender.ts` `loadOne` — `schemaVersion` 1 · 슬러그 · 버전을 본다 |
| 번들 목록 | `site/dist/client/.vite/manifest.json` | `prerender.ts` `readAssets` — 엔트리 JS·CSS 파일명 |
| 번들 파일 | `site/dist/client/assets/`, `site/public/fonts/` | `prerender.ts` `writeSharedAssets` |
| 사진 | `payload.media[].url` 이 가리키는 바깥 주소 | `prerender.ts` `mirrorMedia` (15초 · 8MB) |
| 노래 | `site/songs/*.mp3` | `prerender.ts` `copySongs` |
### 출력
| 무엇 | 어디에 | 누가 쓰나 |
|---|---|---|
| payload | `site/payloads/<slug>.json` | `site_payload.py` `write_payload` |
| 렌더 보고서 | `site/payloads/.status/<slug>.json` | `prerender.ts` `writeReport` (백엔드가 `render_report.py` 로 읽는다) |
| HTML | `out/versions/<slug>/<ver>/index.html` — JSON-LD 는 따로 파일이 없고 `<head>` 안 `<script type="application/ld+json">` 이다 | `prerender.ts` `prerenderSite`, `site/src/seo/head.ts` `renderHead` |
| llms.txt | `out/versions/<slug>/<ver>/llms.txt` | `prerenderSite` → `renderLlmsTxt` |
| 사진 | `out/versions/<slug>/<ver>/img/<주소해시>.<확장자>` | `mirrorMedia` |
| 노래 | `out/versions/<slug>/<ver>/*.mp3` | `copySongs` |
| 버전 보고서 | `out/versions/<slug>/<ver>/.render-report.json` — 있으면 그 버전은 다시 굽지 않는다 | `prerender.ts` `main` |
| 공개 링크 | `out/s/<slug>` → `../versions/<slug>/<ver>` (상대 링크) | `publishVersion`. 옛 일반 폴더는 `versions/<slug>/legacy` 로 옮긴다 |
| 옛 버전 정리 | 최근 5개·30일은 남긴다 | `pruneOldVersions` |
| 공용 번들 | `out/assets/`, `out/fonts/`, 대장 `out/assets/.builds.json` | `writeSharedAssets` · `pruneAssets` (HTML 이 참조하는 건 안 지운다 — `referencedAssets`) |
| 미리보기 껍데기 | `out/preview/index.html` | `writePreviewShell` |
| 루트 파일 | `out/robots.txt` `out/sitemap.xml` `out/llms.txt` `out/s/index.html`(목록) `out/<INDEXNOW_KEY>.txt` | `writeRootMachineFiles` · `writeIndexNowKey` |
| DB | `site_versions`(스냅샷·빌드 상태·JSON-LD·고유 콘텐츠 수), `sites`(PUBLISHED·`current_version_id`·`published_at`·썸네일), `places.status`, `place_posts`·`place_reviews` 발행 표시, `site_publish_logs` | `services/build_service.py` `run_build` |
`--stage-only` 로 굽는 단계는 `out/s/<slug>` 를 건드리지 않는다. 공개 주소가 바뀌는 건 2차 게이트를 통과한 뒤
`--activate` 한 번뿐이다. 공개 전환과 DB 기록은 한 트랜잭션이 아니다([PUBLISH_VERSION.md](PUBLISH_VERSION.md)).
---
## 템플릿·레이아웃은 어디서 골라지나
`sites.template_id` 에 저장된 id 를 `solution/shared/src/data/templates.json` 에서 찾아 그 템플릿의 `layout`
(`basic` `paper` `round` `cinema` `bigtype` `boutique` `graphic`)을 얻는다(`shared/src/lib/catalog.ts` `templateOf`).
`site/src/App.tsx` 가 그 값으로 `site/src/layouts/index.ts` 의 `LAYOUTS` 에서 뼈대를 꺼내 `Frame` 으로 감싸고,
안쪽은 `site/src/pages/SectionList.tsx` 가 켜진 섹션을 순서대로 그린다. 레이아웃이 `sections` 에 따로 등록한
섹션은 그 컴포넌트를, 등록하지 않은 섹션은 `site/src/sections/` 의 공용 컴포넌트를 쓴다.
## 관련 문서
- [TEMPLATES.md](TEMPLATES.md) — 템플릿 추가, 세 폴더(frontend · shared · site)의 역할
- [PUBLISH_VERSION.md](PUBLISH_VERSION.md) — 발행 버전, 공개 링크 전환, 롤백
- [ARCHITECTURE.md](ARCHITECTURE.md) — 백엔드와 렌더러가 디렉토리 하나로만 만나는 이유