o2o-site-AEO/docs/ARCHITECTURE.md
Mina Choi e0d45eda97 [fix] postgres-init,solution/backend,docs: 스키마 재편이 안 닿은 자리를 전부 잡는다 — init.sql · ORM 인덱스 · 테스트
0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데, 문자열로 표 이름을 들고 있던 자리들이
따라오지 않았다. import 도 타입검사도 pyflakes 도 못 잡는 종류라 전부 **실행되는 순간에만**
터졌고, 그동안 pytest 는 569건이 통째로 죽어 있어 아무것도 못 잡고 있었다.

**init.sql 이 새 DB 를 옛 구조로 세우고 있었다**
64ce467 이 이 파일에 94줄을 더하기만 하고 삭제를 0줄 했다. 그래서 이 파일 한 벌로 세운 DB 는
`place.place_links`·`job.jobs` 를 갖고 ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 —
기동은 정상이고 첫 쿼리에서 죽는다. "init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을
유지한다"(migrations/README.md)는 계약이 깨져 있었다.
- public 한 벌 · 표 14개로 다시 썼다. 옛 스키마가 있는 DB 에서 다시 돌면 RAISE EXCEPTION 으로
  멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation already exists" 로 실패해
  데이터가 옛 스키마에 갇힌다
- 말미에 **마이그레이션 기준선**을 심는다. 없으면 새 DB 에서 migrate.py 가 0001 부터 다시 돌다가
  `schema "local" does not exist` 로 죽는다

**운영 버그 둘** — 두 DB(새로 세운 것 · 마이그레이션으로 따라온 것)를 pg_dump 로 찍어 비교해 찾았다
- `upsert_weather` 의 ON CONFLICT 술어에 `kind IS NULL` 이 빠져 **날씨 캐시 저장이 계속 실패**하고
  있었다(0007 이 인덱스에 그 조건을 더했다). 캐시라 화면이 안 죽고 로그에만 남았다.
  포스트그레스는 술어가 인덱스 술어를 함의하는지 보고 아니면 "no unique or exclusion constraint
  matching" 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보인다
- ORM 의 `area_contents` 인덱스 정의가 0004·0007·0008 을 하나도 안 따라왔다. 테스트 DB 는 이
  모델로 세워지므로 **테스트가 운영과 다른 제약 아래에서 돌고 있었다**

**0009** — 두 DB 비교에서 나온 어긋남 셋(데이터는 안 건드린다)
- `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔
- `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 틀린다
- RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …)

**테스트를 살린다**
- conftest 의 TRUNCATE 가 표 이름을 **손으로 나열**하고 있었다. 0005 가 이름을 옮기자 전 테스트가
  `relation "place_aliases" does not exist` 로 죽었다 — 이제 ORM 메타데이터에서 뽑아 다시 어긋날 수 없다
- `test_schema_ddl` 이 모델 표를 `"None.users"` 로 조회해 **한 표도 비교하지 않고 통과**하고 있었다.
  init.sql 이 조용히 어긋난 동안 이 테스트는 초록이었다. 비교한 표 수를 세는 단언을 더한다
- 테스트 SQL 15곳의 옛 표 이름, `_run_worker` 1틱 문제(수집 뒤 따라오는 LOCAL_SYNC 를 집어 가
  정작 기다리던 잡이 PENDING 으로 남았다), 지역 캐시 픽스처(읽는 코드가 옳게 거르는데 테스트가 빨개졌다)

**문서**
- `docs/DATA_MODEL.md` 신설 — 표 14개가 무엇을 담고 누가 쓰는지, 값 하나가 DB 에서 페이지까지
  가는 길, 두 번 도는 게이트, **DB 에 없는 것**
- `SERVERS.md` DB 절을 마이그레이션 체계로. 배포에 `migrate.py` 를 넣는다 — 코드만 갈면 컨테이너는
  정상으로 뜨고 가게 등록·수집·발행만 죽는다
- ARCHITECTURE 2절의 프리렌더 컨테이너가 `solution-frontend` 로 적혀 있었다. 굽는 건
  `solution-prerender` 고 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 그 혼동을
  문서가 만들고 있었다
- 옛 표 이름 잔재(`place_links`·`local_contents`·`job.jobs`·`company.users`·`fact.facts`·`ai_check_results`)

검증: 빈 컨테이너에 init.sql 로 세운 DB ↔ 마이그레이션으로 따라온 DB 를 `pg_dump --schema-only`
로 비교 — 표·인덱스·제약·컬럼 전부 동일. pytest 583건 중 581 통과(남은 2건은 `.env` 누수·
레이트리밋 카운터로 환경 문제다). 구글 로그인 21건 포함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:39:43 +09:00

19 KiB
Raw Blame History

ARCHITECTURE

제품 판단은 PRODUCT.md, 배포 절차는 DEPLOY.md, 에이전트가 밟기 쉬운 함정 목록은 AGENTS.md. 여기는 구조와 경계만 다룬다.


1. 핵심 경계 하나 — payload

백엔드는 HTML 을 만들지 않는다. payload JSON 을 디렉토리에 떨어뜨리고, Node 프리렌더가 그걸 읽어 정적 사이트를 굽는다. 두 쪽은 서로를 import 하지 않고 디렉토리 하나로만 만난다.

backend (Python)  ──쓴다──▶  out/payloads/<slug>.json  ◀──읽는다──  prerender (Node)
                             out/payloads/.status/<slug>.json  ──보고──▶

이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다. 둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다 — 붙이는 순간 파이썬 프로세스가 React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.

계약의 타입은 solution/shared/src/types/site-payload.ts 하나다.

⚠️ payload 는 화면·JSON-LD 뿐 아니라 하이드레이션 블롭으로도 HTML 에 통째로 박힌다. 그래서 거르는 자리가 solution/shared/src/lib/facts.ts 한 곳이다 — sanitizePayloadForPublish() 를 프리렌더가 먼저 통과시킨 뒤 렌더와 임베드 양쪽에 쓴다. 화면과 JSON-LD 만 걸렀더니 그 블롭에 미검증 값이 남아 원본 HTML 을 읽는 AI 가 그걸 읽었다 — 실제로 한 번 그렇게 샜고, 그래서 이 함수가 생겼다.

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/<slug>.json   ★ 백엔드의 유일한 산출물
  │
  │    ┌ [별도 컨테이너 solution-prerender] ★ solution-frontend 가 아니다(개발용)
  │    │  scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만
  │    │    └ node dist/prerender/prerender.js --payload=<file>
  │    │         → out/s/<slug>/**  +  out/assets, out/fonts, out/robots.txt …
  │    │         → out/payloads/.status/<slug>.json  (렌더 결과 보고서)
  │    └ ★ 기동 시에는 payload 전체를 다시 굽는다 (watch-payloads.mjs:225)
  │
  ├ render_report.wait_for()          → .status/<slug>.json 폴링
  ├ 2차 게이트 (**실제 구워진 HTML** 기준: JSON-LD 불일치 · 고유 콘텐츠 수)
  ├ azure_static.publish(slug)        → Azure Blob `$web` (설정됐을 때만 — 3절)
  ├ site_thumbnail.store(slug, …)     → Blob `<prefix>/thumbs/<slug>.<ext>` → sites.thumbnail_url
  └ indexnow.submit(slug)             → 네이버·Bing·Yandex 통보 (구글 미지원)

썸네일은 스크린샷이 아니라 그 사이트의 대표 사진(og:image) 이다 — 헤드리스 브라우저는 영구 금지고(DECISIONS 1-1) 워커·프리렌더 이미지에 Chromium 이 없다. 대표 사진 선정은 site_payload.primary_media() 한 곳뿐이라 og:image 와 항상 같은 사진이다. ★ 블롭 경로가 s/<slug>/ 인 이유: azure_static._remove_stale_site_files() 가 매 발행마다 사이트 경로를 프리렌더 산출물로 통째로 교체한다 — 그 안에 두면 다음 발행에서 조용히 사라진다.

게이트가 두 번 도는 게 핵심이다. 1차는 DB 의 사실을, 2차는 정말로 그렇게 구워졌는지를 본다. 1차만 있으면 "데이터는 맞는데 HTML 은 틀린" 상태를 발행한다.

3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다

결정 (2026-08-31). 발행 사이트는 서버 안에서 nginx 가 정적 파일로 서빙한다. Azure Blob 업로드 경로(azure_static.py)는 코드에 있고 동작하지만 지금은 켜지 않는다AZURE_STORAGE_CONNECTION_STRING 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다. 클라우드는 고도화 시점에 붙인다.

프리렌더(o2o-web4ai-solution-frontend) ──쓴다──▶  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절 실측). 비용으로 못 가르면 운영 편의로 가르고, 지금 편한 쪽은 서버다.
  2. 서버 디스크는 어차피 필요하다. 프리렌더가 파일시스템에서 도니 out/ 은 클라우드를 쓰든 안 쓰든 존재한다. 즉 지금 안 켜는 건 빼는 게 아니라 미루는 것이라 되돌리기 쉽다.
  3. 켜는 게 환경변수 하나다. 코드 변경 없이 위 표의 오른쪽으로 간다. 미리 할 이유가 없다.
  4. Blob 으로 프록시하지는 않는다. 파일이 이미 이 볼륨에 있는데 클라우드로 보냈다 되받으면 요청마다 왕복이 하나 더 붙고, Blob 단독으로는 커스텀 도메인 TLS 도 못 붙인다. Azure 는 배달 백업으로 남겨 둔다.

⚠️ 정적 서빙에서 지켜야 할 규칙은 하나다: 디렉토리 요청 → index.html. /s/<slug>끝 슬래시 없이 쳐도 열려야 한다 — 사장님이 주소창에 치는 형태가 그거다. python -m http.server 는 이걸 404 로 준다. 그래서 개발용 serve-sites.mjs 가 따로 있고, nginx 는 try_files $uri $uri/index.html =404 로 같은 규칙을 맞춘다 ($uri/ 를 거치면 301 이 붙어 크롤러가 리다이렉트를 한 번 더 탄다 — 그래서 $uri/ 를 안 쓴다).

⚠️ 클라우드를 안 켠 대가: 발행 사이트의 가용성이 이 서버 하나에 묶인다. 서버가 죽으면 전 사이트가 동시에 내려간다. 지금은 테스트 단계라 감수하는 리스크이고, 실사용 고객이 붙는 시점이 위 표의 오른쪽으로 넘어가는 트리거다.

4. 앱 경계 — 두 앱, 한 백엔드

2026-08-31 실행 완료. 아래는 "나눌 계획"이 아니라 지금 구조다.

o2o-web4ai/
├─ solution/                    사장님 — 사이트 만들기·관리
│   ├─ backend/                 FastAPI + 워커 · 이 레포의 유일한 백엔드
│   ├─ frontend/                빌더 (위저드 + 에디터 + 발행 게이트)
│   ├─ site/                    발행 정적 사이트 (SSR 엔트리 + 프리렌더)
│   └─ shared/                  frontend·site·백엔드 계약 (SitePayload · slug · 토큰)
│
├─ admin/                       우리 — 전체 사이트 운영
│   ├─ backend/                 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
│   └─ frontend/                내부 운영 화면
│
├─ docs/  nginx/  postgres-init/  docker-compose.yml  package.json(워크스페이스 루트)

최상단은 프로젝트 단위로 평평하다. frontend/ backend/ 를 최상단 묶음 폴더로 쓰지 않는다 — 사내 다른 레포(o2o-negosium)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다. negosium 대응: negodata/{backend, front} 가 프로젝트 안에서 f/b 를 가르는 선례, lps-admin/백엔드 없이 프론트만 가진 최상단 폴더의 선례다 — admin/ 이 후자다.

frontend(빌더)와 site(발행물)는 요구가 정반대다

같은 solution/ 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라 발행 사이트에 그대로 쓰면 크롤러가 <div id="root"></div> 만 읽고 떠난다.

frontend/ (빌더) site/ (발행 사이트)
사용자 사장님 손님 · 검색/AI 크롤러
렌더링 CSR SPA SSG (정적 HTML)
색인 noindex 색인·인용되라고 존재
런타임 Vite dev / 정적 호스팅 서버 없음. 파일만
데이터 편집 중 상태(미확인 값 포함) SitePayload확인된 값만

왜 갈랐나 — 취향이 아니라 셋 다 실제 문제였다

  1. 내부 기능이 사장님 번들에 실려 나갔다. 한 앱이면 /local-content, /places/:id/seo 같은 내부 라우트 이름과 화면 코드가 사장님 브라우저에 그대로 내려간다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다" 를 번들이 깨고 있었다. 라우트 가드는 화면을 가리지 번들은 못 가린다. ★ 이 문제는 코드 크기와 무관하다. 내부 화면이 814줄뿐이어도 내려가는 건 같다.
  2. 인증 모델이 갈라진다. 빌더는 위저드를 열어 두고 에디터 진입에서 한 번 받는다 ("만들어 보기도 전에 막힌다"). 내부 화면은 전부 RequireAuth 뒤다. 계정이 생기는 방식도 다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다(LoginPageselfServe 플래그가 그 차이를 한 곳에서 드러낸다). 한 앱에서 두 정책을 유지하면 실수는 늘 느슨한 쪽으로 난다. → 지금은 두 provider.tsx 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 / 내부: 실패가 곧 차단).
  3. 배포 리듬이 다르다. 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.

백엔드 — 코드 한 벌, 진입점 둘

포트 진입점 권한
솔루션 API 9800 solution/backend/web_main.py 엔드포인트별
어드민 API 9801 admin/backend/main.pyapp.py 앱 전체 role >= DEVELOPER

services·crud·models 은 공유한다 — admin/backend 는 진입점 두 파일뿐이고, 도메인 코드는 PYTHONPATH=/app/solution/backend 로 그대로 import 한다. admin 화면이 부르는 엔드포인트가 사장님 빌더가 쓰는 것과 거의 같기 때문이다 — 세어봤다:

useGetPlace · useListPlaces · useListLinks · useConfirmLink  →  router/v1/place
useListFacts · useGetSchema · useTransitionFact              →  router/v1/fact
/v1/admin/local-content (customFetch 직접 호출)               →  router/v1/local   ← 유일한 admin 전용

place·fact 는 사장님 빌더도 쓴다. 자체 백엔드에 엔드포인트를 새로 쓰면 같은 DB 의 같은 테이블을 두 벌 구현하는 것뿐이다. 그래서 같은 router 객체를 다시 마운트하고 앱 단위로 권한만 덧건다.

왜 경로 접두어(/v1/admin/...)가 아니라 포트인가. 접두어는 같은 프로세스 안이라 사장님이 닿는 서버에 내부 엔드포인트가 존재한다. 포트를 가르면 사장님이 닿는 네트워크에 아예 없다 — 가드보다 강하다. compose 의 ADMIN_API_BIND 기본값이 127.0.0.1 인 것도 같은 이유다. 0.0.0.0 으로 열면 가른 의미가 없다.

검증(role 별 /v1/place/list):

USER      role=1 → 403        DEVELOPER role=3 → 200
OWNER     role=2 → 403

OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다. auth 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). signup·google 도 같은 이유로 토큰 없이 열려 있다 — 여기서 만들어지는 계정은 언제나 role=USER 이고 places.owner_user_id 가 자기 계정인 사업장만 본다. 권한이 올라가는 경로는 이 문 뒤에 없다. (2026-09-08 전에는 이 스코프가 회사(테넌트)였다 — DECISIONS.md 2절)

⚠️ /v1/admin/local-content 는 아직 :9800 에도 마운트돼 있다(router/router.py). 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 RequireOwner 가 사장님(USER)을 막고 있을 뿐이라, 가른 의미가 여기서만 절반이다. 내리는 것이 남은 일이다.

→ 대가: solution/backend 의 코드에 묶인다. 배포는 갈리지만 소스는 한 벌이다.

두 앱이 코드를 나눠 갖는 방식 — @ 가 solution 을 가리킨다

admin/frontend/vite.config.tstsconfig.json 에서 @solution/frontend/src 다. admin 자기 파일만 @admin 이다.

왜 복제하지 않았나: 내부 화면이 쓰는 API 클라이언트·UI 프리미티브·수집 배선이 solution 에 한 벌만 있고, 그 파일들끼리도 @/... 로 서로를 부른다. admin 에서 @ 를 자기 src 로 잡으면 그 내부 참조가 전부 깨진다(실측: TS2307 14건). 그리고 수집 배선은 RecollectPanel 주석이 복제를 명시적으로 금지한다 — "수집 경로를 두 벌 만들면 확정 게이트" 가 갈라진다.

의존 방향은 adminsolution 한 쪽뿐이다. 이 방향이라 사장님 번들에는 내부 코드가 섞이지 않는다. 반대 방향이 하나라도 생기면 앱을 가른 의미가 사라진다.

백엔드는 쪼개지 않는다

마이크로서비스로 가르자는 제안은 지금 근거가 없다 — 팀 규모·트래픽 어느 쪽도 그 비용을 정당화하지 못한다. 그리고 router/v1/ 이 이미 도메인별로 갈려 있어 나중에 진짜 나눠야 할 때 그 선 따라 떨어진다 — 미룬다고 나중이 더 어려워지지 않는다.

청중별로 가르는 일은 포트로 끝냈다(위 표). 엔드포인트마다 if role >= ... 를 흩뿌리지 않고 RequireDeveloper 의존성 하나를 앱에 건다.

⚠️ 그 함수 이름의 admin 은 UserRole.OWNER(고객사 최상위) 를 뜻한다. 최상단 폴더 admin/(우리 내부)과 반대 뜻이므로 읽을 때 헷갈리지 않는다. 폴더 이름을 admin 으로 정할 때 알고 정한 충돌이다.

왜 레포는 안 쪼개나

슬러그 규칙이 site_payload.publish_slug()solution/shared/src/lib/slug.ts 두 곳에 있고 같아야 한다. SitePayload(252줄) 도 백엔드 출력과 프론트 입력이 짝이다. 한 레포에서는 어긋나면 타입 에러·테스트 실패로 잡히고, 레포를 쪼개면 같은 실수가 운영 404 로 나타난다 — 배포 시점이 달라 언제 깨졌는지도 모른다. 지금 만드는 건 세 개의 제품이 아니라 한 파이프라인의 세 창구다.

아직 안 한 것

  • 사장님 "내 사이트 관리" 화면(내 사업장 목록). 로그인 후 도착지가 아직 /builder?new=1(새로 만들기) 하나뿐이다.
  • 계정 연결 — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고 거절한다 → DECISIONS.md 1-5
  • 비밀번호 재설정 — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
  • 운영 배포에서 admin(:3002)을 내부망에만 여는 것. compose 는 ADMIN_BIND 기본값을 127.0.0.1 로 두었다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
  • 폰트 self-hostsolution/site/public/fonts/PretendardVariable.woff2 가 없어 Noto Sans KR 로 폴백된다. @font-face 가 조용히 실패하는 것이라 빌드는 안 깨진다.
  • 이미지 최적화 — 원본 URL 을 그대로 쓴다(srcset·WebP 없음). media 파이프라인이 우리 쪽 저장소를 갖게 된 뒤의 일이다 (DEPLOY.md 1절 의 핫링크 리스크와 같은 항목).

5. 산출물 — 사이트 하나 = 한 장

2026-08-31 결정: 사이트는 한 장이다(라우터 없음). 예전에는 홈·객실·객실상세·주변· 오시는길·FAQ 로 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.

왜 합쳤나 — 소상공인은 원래 내용이 적다. 쪼갤수록 페이지마다 얇아지고, 검색엔진은 얇은 페이지를 색인에서 버린다. 한 장에 모으면 알찬 페이지 하나가 된다. 경로형(/s/<slug>)이라 얇은 페이지의 평가가 도메인 전체로 번지는 것도 막는다. (근거 주석: prerender.ts:196)

사이트 1개당:

out/s/<slug>/index.html    사이트 전체 (한 장)
out/s/<slug>/llms.txt      확인된 사실 목록 (AEO)

오리진 루트 — 크롤러가 읽는 유일한 자리, 전 사이트가 공유:

out/robots.txt             ★ 크롤러는 오리진 루트에서만 읽는다 (RFC 9309)
out/sitemap.xml            전 사이트 URL 을 한 파일에 (사이트맵 1개 = URL 50,000개까지)
out/<indexnow-key>.txt     색인 통보용 키 파일
out/assets/index-<해시>.{css,js}   Vite 번들 (하이드레이션용, 약 372KB)
out/fonts/

사이트별 sitemap.xml·robots.txt 는 없앴다. 한 장짜리의 사이트맵은 URL 이 하나뿐이라 사이트 1,000개면 한 줄짜리 파일이 1,000개 생긴다. 그리고 <host>/s/<slug>/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-solution-backend 솔루션 API (solution/backend/web_main.py) 9800
o2o-web4ai-admin-frontend-backend 어드민 API (admin/backend/main.py) — 앱 전체 role >= DEVELOPER 9801 (기본 127.0.0.1)
o2o-web4ai-solution-worker 잡 러너 (BUILD·수집·생성) + 스케줄러
o2o-web4ai-solution-frontend 사장님 빌더 Vite + watch-payloads 프리렌더 + serve-sites(개발용 :3001) 3000
o2o-web4ai-admin-frontend 내부 운영 화면 Vite 3002 (기본 127.0.0.1)
o2o-web4ai-solution-site 발행 사이트 정적 서빙site-out 볼륨을 읽기 전용으로 80

DB(PostgreSQL)는 compose 밖이다 — 호스트에서 돌고 host.docker.internal 로 붙는다. 데이터 수명이 컨테이너 수명과 달라야 해서다.