표 14개가 무엇을 담고 누가 쓰는지 적어 둔 곳이 없었다. 컬럼 주석은 models.py 에만 있고, "이 값이 왜 화면에 안 나오나" 를 짚으려면 snapshot.py·build_service.py·site_payload.py 를 차례로 열어야 했다. - docs/DATA_MODEL.md 신설. 흐름(등록→검증→수집→에디터→빌드→발행) · 표별 칸과 쓰임 · 값 하나가 페이지까지 가는 길(두 번 도는 게이트) · **DB 에 없는 것** · 표를 고칠 때 - 옛 표 이름이 남아 있던 자리를 현재 이름으로. 재편(0005~0008)이 지나간 뒤로 문서만 옛 이름을 들고 있었다 — `place_links`(COLLECTION) · `local_contents`·`job.jobs`· `company.users`·`fact.facts`(DECISIONS) · `ai_check_results`(DEVELOPMENT_DIRECTION, 0006 이 뗀 표다) - ARCHITECTURE 2절의 프리렌더 컨테이너 이름이 `solution-frontend` 였다. 실제로 굽는 것은 `solution-prerender` 고, 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 바로 그 혼동을 문서가 만들고 있었다 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
304 lines
19 KiB
Markdown
304 lines
19 KiB
Markdown
# ARCHITECTURE
|
||
|
||
제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md),
|
||
에이전트가 밟기 쉬운 함정 목록은 [AGENTS.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](DECISIONS.md)) 워커·프리렌더 이미지에 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절](DEPLOY.md) 실측).
|
||
비용으로 못 가르면 **운영 편의**로 가르고, 지금 편한 쪽은 서버다.
|
||
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 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
|
||
한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
|
||
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
|
||
내부: 실패가 곧 차단).
|
||
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
|
||
|
||
### 백엔드 — 코드 한 벌, 진입점 둘
|
||
|
||
| | 포트 | 진입점 | 권한 |
|
||
|---|---|---|---|
|
||
| 솔루션 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
|
||
| 어드민 API | **9801** | `admin/backend/main.py` → `app.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.ts` 와 `tsconfig.json` 에서 **`@` 는 `solution/frontend/src`** 다.
|
||
admin 자기 파일만 `@admin` 이다.
|
||
|
||
왜 복제하지 않았나: 내부 화면이 쓰는 API 클라이언트·UI 프리미티브·수집 배선이 solution 에
|
||
한 벌만 있고, **그 파일들끼리도 `@/...` 로 서로를 부른다.** admin 에서 `@` 를 자기 src 로
|
||
잡으면 그 내부 참조가 전부 깨진다(실측: `TS2307` 14건). 그리고 수집 배선은
|
||
`RecollectPanel` 주석이 복제를 명시적으로 금지한다 — *"수집 경로를 두 벌 만들면 확정 게이트"* 가
|
||
갈라진다.
|
||
|
||
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** 이 방향이라 사장님 번들에는 내부 코드가
|
||
섞이지 않는다. 반대 방향이 하나라도 생기면 앱을 가른 의미가 사라진다.
|
||
|
||
### 백엔드는 쪼개지 않는다
|
||
|
||
마이크로서비스로 가르자는 제안은 지금 근거가 없다 — 팀 규모·트래픽 어느 쪽도 그 비용을
|
||
정당화하지 못한다. 그리고 `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](DECISIONS.md)
|
||
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
|
||
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
|
||
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
|
||
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로
|
||
폴백된다. `@font-face` 가 조용히 실패하는 것이라 빌드는 안 깨진다.
|
||
- **이미지 최적화** — 원본 URL 을 그대로 쓴다(`srcset`·WebP 없음). media 파이프라인이 우리 쪽
|
||
저장소를 갖게 된 뒤의 일이다 ([DEPLOY.md 1절](DEPLOY.md) 의 핫링크 리스크와 같은 항목).
|
||
|
||
## 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` 로 붙는다.
|
||
데이터 수명이 컨테이너 수명과 달라야 해서다.
|