최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
|
||
|---|---|---|
| .. | ||
| backend | ||
| front | ||
| shared | ||
| site | ||
| README.md | ||
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/.env 에 VITE_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_service → publish_gate) 게이트를 우회하는 경로가 없다.
그래서 잡이 DONE 이어도 발행됐다는 뜻이 아니다. job.result.gate.passed 를 봐야 한다.
★ placeId 가 없는 데모 경로(/builder)는 이 호출을 하나도 하지 않는다 —
로그인 없이 도는 화면이라 예전의 타이머 시뮬레이션으로 떨어진다. 실사업장은 /builder?placeId=<uuid>.
CORS 는 백엔드 config.local.toml 의 client_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.ts의photos: []). VISION 잡이 붙인 분류·alt 를 읽을 창구가 열리면 채운다 - 발행 주소 —
sites.domain을 채우는 경로가 아직 없다. 그때까지는 상호 슬러그로 주소를 만든다