DEVLOG 가 1,900줄이 넘어 최근에 무엇을 왜 바꿨는지 찾기 어려웠다. - DEVLOG.md: 개발 이력상 남길 가치가 있는 항목만 요약 - RENDERING.md: 정적 사이트 · 미리보기 · 발행 세 경우의 흐름과 담당 파일 - README · AGENTS · PRODUCT: 삭제한 DEVELOPMENT_DIRECTION.md 링크 정리(삭제 자체는 앞 커밋), admin 은 필요한 규모가 되면 개발한다는 안내, 템플릿 문서 링크 문서만 바꿈 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 KiB
렌더링 한눈에 보기
사이트가 화면에 그려지는 경우는 세 가지다. 셋 다 그리는 코드는 solution/site/src/App.tsx 하나이고,
누가 언제 그리느냐만 다르다.
| 경우 | 누가 그리나 | 입력 |
|---|---|---|
| 정적 사이트 | 워커가 미리 구운 HTML → 브라우저가 이어받음 | HTML 안에 심어 둔 payload |
| 미리보기 | 브라우저가 처음부터 | API 가 그때그때 만든 payload |
| 발행 | 워커가 Node 렌더러를 돌려 HTML 을 굽는다 | DB → payload 파일 |
경로는 레포 루트 기준이다. site/ 는 solution/site/, backend/ 는 solution/backend/ 를 줄인 것이다.
1. 정적 사이트 — 손님·크롤러가 /s/<slug> 를 받을 때
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=…
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. 발행 — 무엇을 읽고 무엇을 쓰나
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).
템플릿·레이아웃은 어디서 골라지나
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 — 템플릿 추가, 세 폴더(frontend · shared · site)의 역할
- PUBLISH_VERSION.md — 발행 버전, 공개 링크 전환, 롤백
- ARCHITECTURE.md — 백엔드와 렌더러가 디렉토리 하나로만 만나는 이유