o2o-site-AEO/solution
Mina Choi 374a3a4a6e 구조: admin 을 backend/frontend 로 가른다
admin/ 이 프론트 파일만 널려 있는 폴더였다. :9801 을 띄우는 코드도 solution/backend 안에
얹혀 있어서, 폴더만 봐서는 admin 에 백엔드가 있다는 걸 몰랐다.

  admin/backend/   main.py · app.py   (:9801 진입점. 도메인 코드는 PYTHONPATH 로 solution/backend)
  admin/frontend/  운영 화면

패키지 이름도 폴더에 맞췄다: @o2o/front → @o2o/frontend.
이미지 빌드 컨텍스트를 레포 루트로 올렸다 — 진입점(admin/)과 도메인 코드(solution/)가
한 이미지에 들어와야 한다. 루트 .dockerignore 로 프론트·문서·시크릿을 잘라냈다.

★ 실측으로 잡은 것: 패키지명을 바꾸면서 compose 의 `-w @o2o/front` 를 안 고쳐
  web 컨테이너가 `No workspaces found` 로 재기동 루프에 빠져 있었다.

주석은 짧게 줄였다.

검증: lint·build 6개 전부 0. 컨테이너 5개 엔드포인트(9800·9801·3000·3002·80) 전부 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 16:22:29 +09:00
..
backend 구조: 내부 API 진입점을 admin/backend 로 옮긴다 2026-08-31 16:14:19 +09:00
frontend 구조: admin 을 backend/frontend 로 가른다 2026-08-31 16:22:29 +09:00
shared 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
site 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
README.md 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00

solution — 사장님 앱 (backend · front · site)

소상공인 홈페이지 자동 생성 솔루션의 프론트엔드. o2o-negosium/negodata/front 보일러플레이트를 이식했다 — 빌드 도구·구조·프로토콜 규약은 원본과 같다.

목표는 예쁜 사이트가 아니라 AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것이다 (백엔드 README 와 같은 문장). 그래서 두 앱의 요구사항이 정반대다.

front/ (빌더) site/ (발행 사이트)
사용자 사장님 · 운영자 손님 · 검색/AI 크롤러
렌더링 CSR SPA SSG (정적 HTML)
색인 noindex 색인·인용되라고 존재
런타임 Vite dev / 정적 호스팅 서버 없음. 파일만
데이터 편집 중 상태(미확인 값 포함) SitePayload확인된 값만

한 프로젝트에 두 앱을 두되 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라 발행 사이트에 그대로 쓰면 크롤러가 <div id="root"></div> 만 읽고 떠난다.

solution/
├── backend/    FastAPI + 워커. payload JSON 만 떨어뜨린다
├── shared/     front·site 가 공유 — 도메인 enum · SitePayload 계약 · 디자인 토큰 · fact 필터
├── front/      빌더 (위저드 4단계 + 실시간 에디터 + 발행 게이트)
└── site/       발행 사이트 렌더러 + 프리렌더 스크립트

내부 운영 화면은 여기 없다 — 최상단 `admin/` 이다(ARCHITECTURE.md 4절).

실행

Docker Compose (기본 실행 방식)

docker compose up -d
  • 발행 사이트: http://localhost:3000/s/<slug>
  • API/Swagger: http://localhost:9800/docs
  • 정적 사이트 저장 위치: solution/site/out/s/<slug>/
  • 프리렌더 입력 payload: solution/site/payloads/<slug>.json
  • 컨테이너 내부 정적 서버는 3001을 사용하지만, Docker가 호스트 3000으로만 공개한다.

예: http://localhost:3000/s/grazz

npm install            # 워크스페이스 루트에서 한 번

npm run dev            # 빌더        → http://localhost:3000
npm run dev:site       # 발행 사이트  → http://localhost:3001  (데모 payload 로 CSR)

npm run build          # 전체 타입체크 + 린트 + 빌드
npm run prerender      # ★ 정적 사이트 굽기 → site/out/<slug>/

백엔드는 기본 http://localhost:9800 으로 본다. 바꾸려면 admin/.envVITE_API_BASE_URL. cp admin/.env.example admin/.env 로 시작한다.

발행 파이프라인

관리자 편집 → [발행 게이트] → SitePayload JSON → prerender → 정적 파일
                    ↓ 막히면 발행 불가
        미검증 fact / 필수 항목 누락 / 고유 콘텐츠 0건
# 데모 payload 로
npm run prerender

# 실제 payload 로 (백엔드 BUILD 잡이 부르는 자리)
npm run prerender -- --payload=./payloads --out=/var/www

사이트 하나당 나오는 것:

out/<slug>/
├── index.html                    홈 (JSON-LD 4종 + 본문 전체가 HTML 에)
├── rooms/index.html              하위 단위 목록  ← 업종별 경로(rooms/menu/programs)
├── rooms/<unit>/index.html       단위 상세
├── guide|location|faq/index.html
├── sitemap.xml
├── robots.txt                    ★ AI 크롤러 명시 허용
├── llms.txt                      ★ LLM 이 읽을 사실 목록
└── assets/                       하이드레이션 번들

SEO / AEO 가 어디에 박혀 있나

무엇 어디
정적 HTML site/scripts/prerender.ts JS 를 실행 안 하는 AI 크롤러가 본문을 그대로 읽는다
구조화 데이터 site/src/seo/jsonld.ts 업종별 Schema.org 타입 + FAQPage + BreadcrumbList + WebPage
meta · OG · geo site/src/seo/meta.ts head.ts description 을 확인된 fact 로 조립한다(지어내지 않는다)
llms.txt site/src/seo/llms.ts 사실만. 형용사 금지. 모르는 건 "정보 없음"이라고 적는다
robots.txt site/src/seo/robots.ts GPTBot · ClaudeBot · PerplexityBot 등 명시 허용
핵심 정보 요약 site/src/sections/AnswerBlock.tsx AI 가 답으로 뽑아 가는 단정문을 상단에 고정 배치
FAQ site/src/sections/FaqSection.tsx <details> — 접혀 있어도 크롤러가 읽는다
발행 게이트 admin/src/features/publish/publishGate.ts 백엔드 PublishRejectReason 과 1:1

절대규칙 1 이 지켜지는 지점

확인되지 않은 fact 는 사이트에 나가지 않는다.

한 곳에서만 거른다 — shared/src/lib/facts.ts.

  • selectPublishable()VERIFIED · CORRECTED 만 통과
  • sanitizePayloadForPublish()프리렌더가 payload 를 여기 통과시킨 뒤 렌더와 임베드 양쪽에 쓴다

두 번째가 중요하다. 정적 HTML 은 하이드레이션용으로 payload 를 통째로 심는데, 화면과 JSON-LD 만 걸러 두면 그 블롭에 미검증 값이 남아 원본 HTML 을 읽는 AI 가 그걸 읽는다. (실제로 한 번 그렇게 샜고, 그래서 이 함수가 생겼다.)

백엔드 연결

API 클라이언트는 전부 orval 생성물이다(React Query 훅). 화면은 @/api 하나만 import 한다. 자세한 규약은 admin/src/api/README.md.

npm run orval -w admin                                          # 백엔드가 떠 있을 때

cd ../backend && python scripts/export_openapi.py               # 서버 없이 — 스펙을 먼저 뽑고
cd .. && ORVAL_INPUT=solution/backend/openapi.json npm run orval

열려 있는 도메인은 auth / place / fact / job / site 전부다.

화면 부르는 것
사업장 목록·상세 useListPlaces useGetPlace useListFacts useListLinks useTransitionFact useConfirmLink
위저드 2단계(수집) POST /place/{id}/collect → 잡 폴링 — useGatherSimulation.ts
위저드 4단계(생성) POST /place/{id}/copy → 잡 폴링 — Step4Generating.tsx
발행 POST /place/{id}/site/build {publish:true} → 잡 폴링 — features/publish/usePublishSite.ts

발행 = 빌드다. 백엔드에 발행 엔드포인트가 따로 없는 것이 맞다 — 발행 검수 게이트가 빌드 잡 안에 있어서(services/build_servicepublish_gate) 게이트를 우회하는 경로가 없다. 그래서 잡이 DONE 이어도 발행됐다는 뜻이 아니다. job.result.gate.passed 를 봐야 한다.

placeId 가 없는 데모 경로(/builder)는 이 호출을 하나도 하지 않는다 — 로그인 없이 도는 화면이라 예전의 타이머 시뮬레이션으로 떨어진다. 실사업장은 /builder?placeId=<uuid>.

CORS 는 백엔드 config.local.tomlclient_url 이 정한다(쉼표로 여러 오리진). vite 가 3000 을 못 잡고 3001·3002 로 옮겨 뜨면 거기서 막히므로 개발 포트 대역을 함께 적어 둔다.

아직 안 한 것

  • 폰트 self-host*/public/fonts/PretendardVariable.woff2 가 없다. 지금은 Noto Sans KR 로 폴백된다
  • 이미지 최적화 — 원본 URL 을 그대로 쓴다. srcset/WebP 변환은 media 파이프라인이 붙은 뒤
  • admin 번들 분할 — 626KB(gzip 185KB). 라우트 단위 lazy() 로 나눌 수 있다
  • 사진(media) 연동 — 백엔드에 media 조회 엔드포인트가 없어 빌더의 사진 탭은 실데이터가 비어 있다 (stores/builder.tsphotos: []). VISION 잡이 붙인 분류·alt 를 읽을 창구가 열리면 채운다
  • 발행 주소sites.domain 을 채우는 경로가 아직 없다. 그때까지는 상호 슬러그로 주소를 만든다