DEVLOG 가 1,900줄이 넘어 최근에 무엇을 왜 바꿨는지 찾기 어려웠다. - DEVLOG.md: 개발 이력상 남길 가치가 있는 항목만 요약 - RENDERING.md: 정적 사이트 · 미리보기 · 발행 세 경우의 흐름과 담당 파일 - README · AGENTS · PRODUCT: 삭제한 DEVELOPMENT_DIRECTION.md 링크 정리(삭제 자체는 앞 커밋), admin 은 필요한 규모가 되면 개발한다는 안내, 템플릿 문서 링크 문서만 바꿈 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
12 KiB
Markdown
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/<slug>"] --> B["nginx<br/>location ^~ /s/"]
|
|
B --> C["out/s/<slug><br/>(심볼릭 링크)"]
|
|
C --> D["out/versions/<slug>/<ver>/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/<slug>/img/*<br/>노래 /s/<slug>/*.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/<slug>.json"]
|
|
F --> G["render_service.render_site<br/>node prerender.js --stage-only"]
|
|
G --> H["mirrorMedia → prerenderSite<br/>out/versions/<slug>/<ver>/"]
|
|
H --> I["보고서<br/>payloads/.status/<slug>.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/<slug> 링크 전환<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) — 백엔드와 렌더러가 디렉토리 하나로만 만나는 이유
|