o2o-site-AEO/docs/ARCHITECTURE.md
Mina Choi c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00

236 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 하나다.
## 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 ★ 백엔드의 유일한 산출물
│
│ ┌ [별도 컨테이너 o2o-web4ai-web]
│ │ 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절)
└ 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/<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 + 워커 · 이 레포의 유일한 백엔드
│ ├─ front/ 빌더 (위저드 + 에디터 + 발행 게이트)
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
│ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰)
│
├─ admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
│
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
```
최상단은 **프로젝트 단위**로 평평하다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,
`lps-admin/` 이 **백엔드 없이 프론트만 가진 최상단 폴더**의 선례다 — `admin/` 이 후자다.
### 왜 갈랐나 — 취향이 아니라 셋 다 실제 문제였다
1. **내부 기능이 사장님 번들에 실려 나갔다.** 한 앱이면 `/local-content`, `/places/:id/seo`
같은 내부 라우트 이름과 화면 코드가 사장님 브라우저에 그대로 내려간다.
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다").
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### admin 에 백엔드를 두지 않은 이유
내부 화면이 부르는 것이 전부 지금 백엔드에 이미 있다 — `useGetPlace` `useListPlaces`
`useListFacts` `useListLinks` `useGetSchema` `useConfirmLink` `useTransitionFact`
→ `router/v1/{place, fact, local, validator}`. 새로 만들 게 없고, 자체 백엔드를 두면
`place`·`fact`·`link` 도메인을 **같은 DB 에 대고 두 번** 구현하게 된다.
→ 대가: `solution/backend` 가 죽으면 admin 도 멈춘다. **내부 도구라 감수한다.**
### 두 앱이 코드를 나눠 갖는 방식 — `@` 가 solution 을 가리킨다
`admin/vite.config.ts` 와 `tsconfig.json` 에서 **`@` 는 `solution/frontend/src`** 다.
admin 자기 파일만 `@admin` 이다.
왜 복제하지 않았나: 내부 화면이 쓰는 API 클라이언트·UI 프리미티브·수집 배선이 solution 에
한 벌만 있고, **그 파일들끼리도 `@/...` 로 서로를 부른다.** admin 에서 `@` 를 자기 src 로
잡으면 그 내부 참조가 전부 깨진다(실측: `TS2307` 14건). 그리고 수집 배선은
`RecollectPanel` 주석이 복제를 명시적으로 금지한다 — *"수집 경로를 두 벌 만들면 확정 게이트"* 가
갈라진다.
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** 이 방향이라 사장님 번들에는 내부 코드가
섞이지 않는다. 반대 방향이 하나라도 생기면 앱을 가른 의미가 사라진다.
### 백엔드는 쪼개지 않는다
마이크로서비스로 가르자는 제안은 지금 근거가 없다 — 팀 규모·트래픽 어느 쪽도 그 비용을
정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로 갈려 있어 **나중에 진짜 나눠야 할 때
그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다.
남은 일은 **라우터 표면을 청중별로 가르는 것**뿐이다:
```
/api/v1/... 사장님 (solution/frontend) — 자기 리소스만
/api/v1/admin/... 내부 (admin) — 라우터 레벨에서 role >= DEVELOPER 강제
```
엔드포인트마다 `if role >= ...` 를 흩뿌리지 않고 **의존성 하나로 라우터에 건다.**
`common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니 라우터 의존성으로 올린다.
⚠️ 그 함수 이름의 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다. 최상단 폴더
`admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다. 폴더 이름을 admin 으로 정할 때
알고 정한 충돌이다.
### 왜 레포는 안 쪼개나
슬러그 규칙이 `site_payload.publish_slug()` 와 `solution/shared/src/lib/slug.ts`
**두 곳에 있고 같아야 한다.** `SitePayload`(252줄) 도 백엔드 출력과 프론트 입력이 짝이다.
한 레포에서는 어긋나면 **타입 에러·테스트 실패**로 잡히고, 레포를 쪼개면 같은 실수가
**운영 404** 로 나타난다 — 배포 시점이 달라 언제 깨졌는지도 모른다.
지금 만드는 건 세 개의 제품이 아니라 **한 파이프라인의 세 창구**다.
### 아직 안 한 것
- `/api/v1/admin/*` 라우터 분리 + role 의존성 (위)
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
그때 `solution/frontend` 의 인증 정책을 다시 본다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
## 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-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` 로 붙는다.
데이터 수명이 컨테이너 수명과 달라야 해서다.