o2o-site-AEO/docs/ARCHITECTURE.md
Mina Choi a186842b54 문서: 앱 경계를 negosium 규약(프로젝트별 최상단, 안에서 f/b)으로 다시 쓴다
ARCHITECTURE.md 4절이 권고하던 `frontend/{console,admin,site,shared}` 는 사내 다른
레포(o2o-negosium)의 규약과 어긋났다. 이 레포만 다르게 갈 이유가 없어 최상단을
프로젝트 단위로 평평하게 두는 쪽으로 바꾼다 — solution/{backend,front,site} + admin/.

admin 에 백엔드를 두지 않는 근거를 실측으로 적었다: 내부 4장이 부르는 훅이 전부
router/v1/{place,fact,local,validator} 에 이미 있어 새로 만들 게 없고, 자체 백엔드를
두면 같은 DB 에 대고 도메인을 두 번 구현하게 된다. negosium 의 lps-admin 이
프론트 전용 최상단 폴더의 선례다.

shared/ 를 없애는 근거도 적었다 — 그것이 지키던 결합(슬러그 규칙, SitePayload)의
양쪽이 둘 다 solution/ 안에 들어가므로 공용 워크스페이스가 필요 없어진다.

"admin/ 이 두 앱을 반씩 겸한다"는 서술도 고쳤다. 실제로는 사장님 110파일 대
내부 814줄이라, 사실상 사장님 앱에 내부 화면 4장이 얹힌 모양이다.

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

258 lines
16 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 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
계약의 타입은 `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/<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. 앱 경계 — 지금 구조와 나눌 지점
### 지금
```
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) 표)
크기는 이렇게 갈린다 (손으로 쓴 코드 152개 기준 — `api/generated` 296개는 orval 산출물이라 뺐다):
| | 규모 |
|---|---|
| 사장님 — `features/builder`(79) `onboarding`(21) `publish`(10) | **110 파일** |
| 내부 운영 — `pages/` 4장 | **814 줄** |
즉 `admin/` 은 두 앱을 반씩 겸하는 게 아니라, **사실상 사장님 앱인데 내부 화면 4장이 얹혀 있다.**
### 나눠야 하는 이유 — 취향이 아니라 셋 다 실제 문제다
1. **내부 기능이 사장님 번들에 실려 나간다.** 한 앱이면 `/local-content`, `/places/:id/seo`
같은 내부 라우트 이름과 화면 코드가 사장님 브라우저에 그대로 내려간다.
`UserRole.DEVELOPER` 는 코드 주석에 **"고객사에 존재를 노출하지 않는다"** 고 적혀 있는데,
번들이 그 약속을 깨고 있다. 라우트 가드는 화면을 가리지 **번들은 못 가린다**.
★ 이 문제는 **코드 크기와 무관하다.** 내부가 814줄뿐이어도 사장님 브라우저에 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 지금 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다")
— `stores/auth.ts` 를 빌더 쪽 어느 파일도 import 하지 않는 것이 그 증거다.
그런데 사장님에게 **"내 사이트 관리"** 가 붙는 순간 그쪽도 로그인 뒤로 들어간다.
같은 로그인이 아니라 **role 이 다른 로그인**(OWNER vs DEVELOPER)이다.
한 앱에서 두 정책을 유지하면 실수는 항상 **느슨한 쪽으로** 난다.
3. **배포 리듬이 다르다.** 사장님 화면은 조심스럽게, 내부 화면은 매일 고쳐도 된다.
한 번들이면 내부 화면 수정 때문에 사장님 화면을 재배포한다.
### 권고 구조 — o2o-negosium 과 같은 규약
**2026-08-31 결정.** 최상단은 **프로젝트 단위**로 평평하게 두고, 프로젝트 안에서 backend/front 를
가른다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
**사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.**
```
o2o-web4ai/ ← 레포 하나. 쪼개지 않는다
├─ solution/ 사장님 — 사이트 만들기·관리
│ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드
│ ├─ front/ builder · onboarding · publish
│ └─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
│
├─ admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
│ └─ src/ package.json vite.config.ts
│
├─ docs/ nginx/ postgres-init/ docker-compose.yml
```
**negosium 대응:** `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례이고,
`lps-admin/` 이 **백엔드 없이 프론트만 가진 최상단 폴더**의 선례다. `admin/` 이 후자다.
**`admin/` 에 백엔드를 두지 않는 이유.** 내부 4장이 부르는 것이 전부 지금 백엔드에 이미 있다 —
`useGetPlace` `useListPlaces` `useListFacts` `useListLinks` `useGetSchema` `useConfirmLink`
`useTransitionFact` → `router/v1/{place, fact, local, validator}`. 새로 만들 게 없고,
자체 백엔드를 두면 `place`·`fact`·`link` 도메인을 **같은 DB 에 대고 두 번** 구현하게 된다.
→ 대가: `solution/backend` 가 죽으면 admin 도 멈춘다. **내부 도구라 감수한다.**
**`shared/` 는 없앤다.** 지금 `shared/` 가 지키던 진짜 결합은 슬러그 규칙과 `SitePayload` 이고,
그 양쪽(`backend` ↔ `site`)이 **둘 다 `solution/` 안에 있다.** 결합이 프로젝트 하나 안에서 닫히므로
공용 워크스페이스가 필요 없다. `admin/` 이 쓰는 것은 열거형 몇 개(`PlaceCategory` `PlaceStatus`
`FactStatus` `isPublishableFact`)뿐이라 자기 것으로 갖는다.
**왜 레포를 안 쪼개나.** 슬러그 규칙이 `site_payload.publish_slug()` 와 `site` 쪽 `publishUrl()`
**두 곳에 있고 같아야 한다.** `SitePayload`(252줄) 도 백엔드 출력과 프론트 입력이 짝이다.
한 레포에서는 어긋나면 **타입 에러·테스트 실패**로 잡히고, 레포를 쪼개면 같은 실수가
**운영 404** 로 나타난다 — 배포 시점이 달라 언제 깨졌는지도 모른다.
지금 만드는 건 세 개의 제품이 아니라 **한 파이프라인의 세 창구**다.
**백엔드는 쪼개지 않는다.** 마이크로서비스로 가르자는 제안은 지금 근거가 없다 —
팀 규모·트래픽 어느 쪽도 그 비용을 정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로
갈려 있어 **나중에 진짜 나눠야 할 때 그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다.
지금 할 일은 **라우터 표면을 청중별로 가르는 것**뿐이다:
```
/api/v1/... 사장님 (solution/front) — 자기 리소스만
/api/v1/admin/... 내부 (admin) — 라우터 레벨에서 role >= DEVELOPER 강제
```
엔드포인트마다 `if role >= ...` 를 흩뿌리지 않고 **의존성 하나로 라우터에 건다.**
지금 `common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니, 그걸 라우터
의존성으로 올리면 된다.
⚠️ 그 함수의 이름에서 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다.
최상단 폴더 `admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다.
### 마이그레이션 — 되돌리기 쉬운 순서
1. `solution/` 생성 → `backend/` 를 통째로 `git mv`. 경로만 바뀌고 내용은 그대로다.
2. `frontend/site` → `solution/site`, `frontend/admin` → `solution/front`
3. `frontend/shared` 해체 — 타입·slug·토큰을 `solution/` 안으로 흡수
4. `solution/front` 에서 내부 4장(`PlaceList` `PlaceDetail` `SeoAudit` `LocalContent`)과
`components/layout/AppShell`·`RequireAuth` 를 떼어 `admin/` 으로. 라우터에서도 제거
5. `docker-compose.yml` 경로 전부 갱신 + admin Vite 서비스 추가.
진입점 `exec npm run dev -w admin` 이 `solution/front` 를 가리키도록 바꾼다
6. 백엔드 `/api/v1/admin/*` 라우터 분리 + role 의존성
★ **4번의 유일한 얽힘**: `PlaceDetailPage.tsx:18` 이 `@/features/onboarding` 의
`PasteFactsPanel`·`RecollectPanel` 을 쓴다. 내부 페이지가 사장님 쪽 feature 를 참조하는
**단 하나의 지점**이고, 이 둘만 복제하거나 옮기면 4장은 그냥 떨어진다.
★ **먼저 정리할 것**: 메인 체크아웃에 미커밋으로 남은 `frontend/admin/src/stores/builder.ts`·
`orval.config.ts` 가 이 마이그레이션이 옮길 파일이다. 커밋하든 버리든 **먼저 비우고** 시작한다.
**아직 실행하지 않았다.** 지금 구조로도 동작하고, 위 3가지 문제는 실사용 고객이 붙기 전까지는
터지지 않는다. 다만 **사장님에게 계정을 열어주기 전에는 반드시 끝내야 한다** — 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-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` 로 붙는다.
데이터 수명이 컨테이너 수명과 달라야 해서다.