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

16 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 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.

계약의 타입은 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절 실측). 비용으로 못 가르면 운영 편의로 가르고, 지금 편한 쪽은 서버다.
  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절 표)

크기는 이렇게 갈린다 (손으로 쓴 코드 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 useTransitionFactrouter/v1/{place, fact, local, validator}. 새로 만들 게 없고, 자체 백엔드를 두면 place·fact·link 도메인을 같은 DB 에 대고 두 번 구현하게 된다. → 대가: solution/backend 가 죽으면 admin 도 멈춘다. 내부 도구라 감수한다.

shared/ 는 없앤다. 지금 shared/ 가 지키던 진짜 결합은 슬러그 규칙과 SitePayload 이고, 그 양쪽(backendsite)이 둘 다 solution/ 안에 있다. 결합이 프로젝트 하나 안에서 닫히므로 공용 워크스페이스가 필요 없다. admin/ 이 쓰는 것은 열거형 몇 개(PlaceCategory PlaceStatus FactStatus isPublishableFact)뿐이라 자기 것으로 갖는다.

왜 레포를 안 쪼개나. 슬러그 규칙이 site_payload.publish_slug()sitepublishUrl() 두 곳에 있고 같아야 한다. 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/sitesolution/site, frontend/adminsolution/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 adminsolution/front 를 가리키도록 바꾼다
  6. 백엔드 /api/v1/admin/* 라우터 분리 + role 의존성

4번의 유일한 얽힘: PlaceDetailPage.tsx:18@/features/onboardingPasteFactsPanel·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 로 붙는다. 데이터 수명이 컨테이너 수명과 달라야 해서다.