# ARCHITECTURE 제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md), 에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다. --- ## 1. 핵심 경계 하나 — payload **백엔드는 HTML 을 만들지 않는다.** payload JSON 을 디렉토리에 떨어뜨리고, Node 프리렌더가 그걸 읽어 정적 사이트를 굽는다. 두 쪽은 서로를 import 하지 않고 **디렉토리 하나로만 만난다.** ``` backend (Python) ──쓴다──▶ out/payloads/.json ◀──읽는다── prerender (Node) out/payloads/.status/.json ──보고──▶ ``` 이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다. **둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가 React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다. 계약의 타입은 `frontend/shared/src/types/site-payload.ts` 하나다. ## 2. 발행 파이프라인 ``` BUILD 잡 (worker) ─ services/build_service.py:99 run_build() ├ build_snapshot → site_versions 행 insert (원본 데이터, JSONB) ├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate() ├ site_payload.emit_payload() → out/payloads/.json ★ 백엔드의 유일한 산출물 │ │ ┌ [별도 컨테이너 o2o-web4ai-web] │ │ scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만 │ │ └ node dist/prerender/prerender.js --payload= │ │ → out/s//** + out/assets, out/fonts, out/robots.txt … │ │ → out/payloads/.status/.json (렌더 결과 보고서) │ └ ★ 기동 시에는 payload 전체를 다시 굽는다 (watch-payloads.mjs:225) │ ├ render_report.wait_for() → .status/.json 폴링 ├ 2차 게이트 (**실제 구워진 HTML** 기준: JSON-LD 불일치 · 고유 콘텐츠 수) ├ azure_static.publish(slug) → Azure Blob `$web` (설정됐을 때만 — 3절) └ indexnow.submit(slug) → 네이버·Bing·Yandex 통보 (구글 미지원) ``` 게이트가 **두 번** 도는 게 핵심이다. 1차는 DB 의 사실을, 2차는 **정말로 그렇게 구워졌는지**를 본다. 1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다. ## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다 **결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.** Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** — `AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다. 클라우드는 고도화 시점에 붙인다. ``` 프리렌더(o2o-web4ai-web) ──쓴다──▶ named volume `site-out` ◀──읽는다(ro)── nginx(:80) ``` 굽는 쪽과 서빙하는 쪽이 **볼륨 하나를 사이에 두고 서로를 모른다.** 그래서 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다 (호스트 경로가 등장하지 않는다). | | 지금 | 고도화 때 | |---|---|---| | 굽기 | 서버 로컬 `out/` (named volume) | 같음 — 프리렌더는 파일시스템에서 돈다 | | 서빙 | **nginx `:80` → `/srv/sites`** (`nginx/site.conf`) | Blob `$web` + CDN, 또는 그대로 유지 | | 켜는 법 | `AZURE_STORAGE_CONNECTION_STRING` **비움** | 채우면 발행 잡이 자동 업로드 | **왜 이 순서인가** 1. **트래픽 비용이 판단 근거가 아니다.** 사이트 1,000개 × 월 100뷰 ≈ 월 24GB — Azure 무료 egress 한도 안이다. 어느 쪽을 골라도 돈이 안 갈린다 ([DEPLOY.md 1절](DEPLOY.md) 실측). 비용으로 못 가르면 **운영 편의**로 가르고, 지금 편한 쪽은 서버다. 2. **서버 디스크는 어차피 필요하다.** 프리렌더가 파일시스템에서 도니 `out/` 은 클라우드를 쓰든 안 쓰든 존재한다. 즉 지금 안 켜는 건 **빼는 게 아니라 미루는 것**이라 되돌리기 쉽다. 3. **켜는 게 환경변수 하나다.** 코드 변경 없이 위 표의 오른쪽으로 간다. 미리 할 이유가 없다. 4. **Blob 으로 프록시하지는 않는다.** 파일이 이미 이 볼륨에 있는데 클라우드로 보냈다 되받으면 요청마다 왕복이 하나 더 붙고, Blob 단독으로는 커스텀 도메인 TLS 도 못 붙인다. Azure 는 **배달 백업**으로 남겨 둔다. ⚠️ **정적 서빙에서 지켜야 할 규칙은 하나다: 디렉토리 요청 → `index.html`.** `/s/` 를 **끝 슬래시 없이** 쳐도 열려야 한다 — 사장님이 주소창에 치는 형태가 그거다. `python -m http.server` 는 이걸 404 로 준다. 그래서 개발용 `serve-sites.mjs` 가 따로 있고, nginx 는 `try_files $uri $uri/index.html =404` 로 같은 규칙을 맞춘다 (`$uri/` 를 거치면 301 이 붙어 크롤러가 리다이렉트를 한 번 더 탄다 — 그래서 `$uri/` 를 안 쓴다). ⚠️ **클라우드를 안 켠 대가**: 발행 사이트의 가용성이 이 서버 하나에 묶인다. 서버가 죽으면 **전 사이트가 동시에 내려간다.** 지금은 테스트 단계라 감수하는 리스크이고, **실사용 고객이 붙는 시점**이 위 표의 오른쪽으로 넘어가는 트리거다. ## 4. 앱 경계 — 지금 구조와 나눌 지점 ### 지금 ``` frontend/ admin/ Vite CSR SPA ── /builder 사장님 위저드·에디터 (로그인 안 걸림) │ └ /places, /places/:id/seo, /local-content │ 내부 운영 화면 (RequireAuth) site/ SSR 엔트리 + 프리렌더 → 정적 HTML 발행 사이트 shared/ 타입·slug·디자인 토큰 (npm workspace) ``` **`admin/` 이 성격이 반대인 두 앱을 겸하고 있다.** ([PRODUCT.md 4절](PRODUCT.md) 표) ### 나눠야 하는 이유 — 취향이 아니라 셋 다 실제 문제다 1. **내부 기능이 사장님 번들에 실려 나간다.** 한 앱이면 `/local-content`, `/places/:id/seo` 같은 내부 라우트 이름과 화면 코드가 사장님 브라우저에 그대로 내려간다. `UserRole.DEVELOPER` 는 코드 주석에 **"고객사에 존재를 노출하지 않는다"** 고 적혀 있는데, 번들이 그 약속을 깨고 있다. 라우트 가드는 화면을 가리지 **번들은 못 가린다**. 2. **인증 모델이 정반대다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다"). 운영 화면은 엄격해야 한다. 한 앱 안에서 두 정책을 유지하면 실수는 항상 **느슨한 쪽으로** 난다. 3. **배포 리듬이 다르다.** 사장님 화면은 조심스럽게, 내부 화면은 매일 고쳐도 된다. 한 번들이면 내부 화면 수정 때문에 사장님 화면을 재배포한다. ### 권고 구조 — 레포는 하나, 배포물은 셋 ``` o2o-web4ai/ ← 레포 하나 (모노레포). 쪼개지 않는다 ├─ backend/ FastAPI 모듈러 모놀리스 + 워커 ├─ frontend/ │ ├─ console/ (신설) 사장님용. 현 admin 의 /builder 계열 │ ├─ admin/ (남김) 내부 운영용. 현 /places, /local-content, /seo │ ├─ site/ (그대로) 발행 정적 사이트 │ └─ shared/ (그대로) 세 앱이 공유하는 타입·slug·토큰 └─ docs/ ``` **핵심: 소스는 합치고, 배포 단위만 쪼갠다.** 2026 기준 이 형태(모노레포 + 다중 배포물)가 이 규모에서 기본값이다. **왜 레포를 안 쪼개나 — 이 프로젝트에는 특히 강한 이유가 있다.** 슬러그 규칙이 `site_payload.publish_slug()` 와 `shared/lib/slug.ts` **두 곳에 있고 같아야 한다.** `SitePayload` 타입도 백엔드 출력과 프론트 입력이 짝이다. 한 레포에서는 이게 어긋나면 **타입 에러·테스트 실패**로 잡힌다. 레포를 쪼개면 같은 실수가 **운영 404** 로 나타난다. 게다가 지금 만드는 건 세 개의 제품이 아니라 **한 파이프라인의 세 창구**다. **백엔드는 쪼개지 않는다.** 마이크로서비스로 가르자는 제안은 지금 근거가 없다 — 팀 규모·트래픽 어느 쪽도 그 비용을 정당화하지 못한다. 대신 **라우터 표면을 청중별로 가른다**: ``` /api/v1/... 사장님 (console) — 자기 리소스만 /api/v1/admin/... 내부 (admin) — 라우터 레벨에서 role >= DEVELOPER 강제 ``` 엔드포인트마다 `if role >= ...` 를 흩뿌리지 않고 **의존성 하나로 라우터에 건다.** 지금 `common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니, 그걸 라우터 의존성으로 올리면 된다. ### 마이그레이션 — 하루짜리, 되돌리기 쉬운 순서 1. `frontend/console/` 생성 — `admin/` 의 `vite.config.ts`·`tsconfig.json`·`index.html` 복제 2. `frontend/package.json` workspaces 에 `console` 추가, `dev:console`·`build:console` 스크립트 3. `pages/BuilderPage.tsx` + `features/builder`, `features/onboarding`, `features/publish` → `console/` 로 이동. `components/ui`·`lib`·`stores` 중 **양쪽이 쓰는 것은 `shared/` 로 올린다** 4. `admin/src/app/router.tsx` 에서 `/builder` 라우트 제거, `/places` 를 기본 진입으로 5. `docker-compose.yml` 에 console 서비스 추가 (또는 nginx 에서 경로/서브도메인 분기) 6. 백엔드 `/api/v1/admin/*` 라우터 분리 + role 의존성 ★ 3번이 유일하게 시간이 드는 단계다. 공용으로 올릴 것과 한쪽 전용인 것을 가르는 작업이고, 여기서 대충 하면 `shared/` 가 쓰레기통이 된다. **아직 실행하지 않았다.** 지금 구조로도 동작하고, 위 3가지 문제는 실사용 고객이 붙기 전까지는 터지지 않는다. 다만 **사장님에게 계정을 열어주기 전에는 반드시 끝내야 한다** — 1번(번들 노출)이 그때부터 실제 유출이 되기 때문이다. ## 5. 산출물 — 사이트 하나 = 한 장 **2026-08-31 결정: 사이트는 한 장이다(라우터 없음).** 예전에는 홈·객실·객실상세·주변· 오시는길·FAQ 로 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다. **왜 합쳤나** — 소상공인은 원래 내용이 적다. 쪼갤수록 페이지마다 얇아지고, **검색엔진은 얇은 페이지를 색인에서 버린다.** 한 장에 모으면 알찬 페이지 하나가 된다. 경로형(`/s/`)이라 얇은 페이지의 평가가 도메인 전체로 번지는 것도 막는다. (근거 주석: `prerender.ts:196`) 사이트 1개당: ``` out/s//index.html 사이트 전체 (한 장) out/s//llms.txt 확인된 사실 목록 (AEO) ``` **오리진 루트** — 크롤러가 읽는 유일한 자리, 전 사이트가 공유: ``` out/robots.txt ★ 크롤러는 오리진 루트에서만 읽는다 (RFC 9309) out/sitemap.xml 전 사이트 URL 을 한 파일에 (사이트맵 1개 = URL 50,000개까지) out/.txt 색인 통보용 키 파일 out/assets/index-<해시>.{css,js} Vite 번들 (하이드레이션용, 약 372KB) out/fonts/ ``` ★ **사이트별 `sitemap.xml`·`robots.txt` 는 없앴다.** 한 장짜리의 사이트맵은 URL 이 하나뿐이라 사이트 1,000개면 한 줄짜리 파일이 1,000개 생긴다. 그리고 `/s//robots.txt` 는 **아무도 읽지 않는다** — RFC 9309 상 크롤러는 오리진 루트만 본다. ★ **자산은 사이트마다 복사하지 않는다.** 같은 해시 번들을 1,000벌 복사하면 디스크도 낭비지만, 더 나쁜 건 브라우저 캐시가 사이트마다 따로 잡혀 매번 새로 받는다는 점이다. (**커스텀 도메인** 사이트만 예외 — 호스트가 달라 공용 경로를 공유할 수 없다.) **이미지는 굽지 않는다** — 네이버 CDN(`*.pstatic.net`) URL 을 그대로 참조한다. 지도는 OSM iframe. DB 에 HTML 컬럼은 없다 (`site_versions.snapshot` JSONB 가 원본). ⚠️ **산출물 크기 실측값은 아직 없다.** 예전 수치(20개 사이트 = 10MB, 최대 2.1MB)는 **라우트를 갈라 굽던 시절의 것**이라 지금은 맞지 않는다. 인용하지 말고, 한 장 구조에서 다시 측정해 여기 적는다. ## 6. 프로세스 구성 | 컨테이너 | 무엇 | 포트 | |---|---|---| | `o2o-web4ai-api` | FastAPI — 화면이 부르는 API | 9800 | | `o2o-web4ai-worker` | 잡 러너 (BUILD·수집·생성) + 스케줄러 | — | | `o2o-web4ai-web` | admin Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 | | `o2o-web4ai-nginx` | **발행 사이트 정적 서빙** — `site-out` 볼륨을 읽기 전용으로 | 80 | DB(PostgreSQL)는 **compose 밖**이다 — 호스트에서 돌고 `host.docker.internal` 로 붙는다. 데이터 수명이 컨테이너 수명과 달라야 해서다.