o2o-site-AEO/docs/ARCHITECTURE.md
Mina Choi 5ef3e5a7de 업종 4번째를 관광체험 → 피부과·성형외과 로 바꾸고, 로그인 관문을 에디터 진입으로 되돌린다
## 업종 교체 (tour → clinic)

PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다.

- category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시
  운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다.
  unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항).
- 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 —
  이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다.
- jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다.
- 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로.
- mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다.
- site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 —
  test_site_theme 이 이 둘을 대조한다.

## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert)

두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다.
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다.
위저드를 열어 두고 에디터 진입에서 한 번 받는 969fb67 설계로 되돌린다.
d6a6c8e 가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다.

## 그 밖

- test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락).
- .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면
  nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다.

검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed —
그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
2026-09-02 15:42:34 +09:00

288 lines
18 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` 하나다.
⚠️ **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 ★ 백엔드의 유일한 산출물
│
│ ┌ [별도 컨테이너 o2o-web4ai-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절)
└ 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-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` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
→ 지금은 두 `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` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다).
⚠️ **`/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** 로 나타난다 — 배포 시점이 달라 언제 깨졌는지도 모른다.
지금 만드는 건 세 개의 제품이 아니라 **한 파이프라인의 세 창구**다.
### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
그때 `solution/frontend` 의 인증 정책을 다시 본다.
- 운영 배포에서 `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` 로 붙는다.
데이터 수명이 컨테이너 수명과 달라야 해서다.