최상단을 프로젝트 단위로 평평하게 둔다 — 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
9.6 KiB
배포 · 스토리지
현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다. Azure Blob 은 코드에 있으나 켜지 않는다(
AZURE_STORAGE_CONNECTION_STRING비움). 클라우드는 고도화 때 붙인다 — 근거는 ARCHITECTURE.md 3절. 이 문서의 3절이 지금 밟는 절차이고, 4절은 나중에 켤 때 본다.
1. 산출물을 어디에 둘 것인가
역할이 셋인데 하나로 묶어 생각하면 헷갈린다. 원본 / 작업공간 / 배달을 나눈다.
| 층 | 무엇 | 어디 | 잃어버리면 |
|---|---|---|---|
| 원본 | DB (site_versions.snapshot) + out/payloads/*.json |
PostgreSQL + 서버 디스크 | 진짜 손실. 백업 대상은 여기뿐 |
| 작업공간 · 배달 | out/ 정적 트리 |
Docker named volume site-out (nginx 가 읽는다) |
괜찮다 — payload 로 다시 굽는다 |
| 배달(백업) | 같은 파일의 사본 | Azure Blob $web |
다시 올리면 된다 |
결론: 공개 서빙은 nginx 가 맡는다(2026-08-31 결정). 파일이 이미 site-out 볼륨에 있는데
Blob 으로 보냈다가 되받아 오면 요청마다 왕복이 하나 더 붙고, Blob 은 커스텀 도메인 TLS 도 못 붙여
CDN 을 억지로 끼워야 한다. azure_static.py 의 업로드는 배달 백업으로 남긴다 —
웹서버를 여러 대로 늘릴 때 다시 볼 문제다.
트래픽은 클라우드가 더 걸리지 않는다
방문자가 받는 바이트는 어디서 주든 똑같다. 달라지는 건 누가 그 비용을 어떤 형태로 내는가다.
- 서버 직접 서빙: 대역폭은 보통 VM 요금에 포함(무료처럼 보인다). 대신 CPU·가용성·TLS 갱신· 캐시 헤더·장애 시 전 사이트 동시 다운을 우리가 떠안는다.
- Blob+CDN: egress 를 GB 단위로 낸다. 대신 서버가 트래픽을 아예 안 받는다.
실측 기준 1회 방문 바이트:
첫 방문 HTML 50~100KB + CSS 35KB + JS 320KB ≈ 420KB
재방문 HTML 만 (assets 는 immutable 1년 캐시) ≈ 60KB, 대개 304
이미지 0KB ← 네이버 CDN 이 대신 낸다
사이트 1,000개 × 월 100뷰 = 월 10만 PV ≈ 월 24GB. Azure 무료 egress 한도(월 100GB) 안이다. 즉 지금 규모에서 트래픽 비용은 판단 근거가 아니다. 운영 편의로 고르면 된다.
⚠️ 단, 이미지를 pstatic.net 에서 핫링크하고 있다. 우리 트래픽 비용을 0으로 만드는 대신
네이버가 referer 차단하거나 이미지를 내리면 전 사이트가 동시에 깨진다. 발행 시점에
이미지를 우리 Blob 으로 복사해 두는 게 중기 과제다.
사이트 하나에 들어가는 파일
2026-08-31 부터 사이트는 한 장이다(라우터 없음). 사이트당 30개 안팎이던 HTML 이 둘로 줄었다.
site-out/
assets/ 공용 번들(전 사이트 공유) robots.txt 크롤러가 읽는 유일한 자리
fonts/ 공용 sitemap.xml 전 사이트 URL 한 파일
s/<slug>/index.html 사이트 전체
s/<slug>/llms.txt 확인된 사실 목록(AEO)
사이트별 sitemap.xml·robots.txt 는 없앴다 — 한 장짜리의 사이트맵은 URL 이 하나뿐이고,
<host>/s/<slug>/robots.txt 는 애초에 아무도 읽지 않는다(RFC 9309: 오리진 루트만).
2. CSS 주소는 불변이 아니다
/assets/index-DvNTmLhy.css 의 해시는 렌더러 CSS 가 바뀌면 바뀐다. 프리렌더가
dist/client/.vite/manifest.json 을 읽어 HTML 에 박으므로 새로 굽는 HTML 은 항상 맞다.
문제는 이미 구워져 올라간 HTML 이다.
프론트 수정 → 새 번들(새 해시)
로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404
Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제
프리렌더 컨테이너는 기동할 때 payload 전체를 다시 굽는다. 그래서 로컬 out/ 은 재시작만
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
backend/scripts/republish_all.py 다.
규칙: solution/site 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.
docker compose restart web # 기동하며 전체 재굽기
docker compose logs -f web # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec worker python scripts/republish_all.py
3. 서버에 올리는 순서 (지금 밟는 경로)
굽는 것과 서빙하는 것을 한 번에 바꾸지 않는다. 깨졌을 때 어느 쪽인지 못 가린다.
0단계 — 도메인부터 정한다 (코드가 하드코딩이라 먼저다)
발행 호스트는 백엔드·프론트 두 곳에 있고 값이 같아야 한다.
| 어디 | 무엇 | 기본값 |
|---|---|---|
| 백엔드 | SITE_PUBLIC_HOST (site_payload.py) |
w4ai.o2o.kr |
| 프론트(admin) | VITE_PUBLISH_HOST |
없으면 현재 브라우저 호스트 |
이 값이 canonical·og:url·sitemap·IndexNow 통보에 전부 들어간다. 다른 주소에 올릴 거면
SITE_PUBLIC_HOST 를 먼저 덮는다. 안 고치면 발행은 성공하는데 사이트맵과 색인 통보가
존재하지 않는 주소를 가리킨다 — 조용히 틀리는 종류라 아무도 눈치채지 못한다.
★ origin 은 payload JSON 에 구워져 들어간다(out/payloads/<slug>.json). 호스트를 바꾼 뒤
프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 백엔드에서 재발행해 payload 를
다시 만들어야 한다.
1단계 — 서버에서 "굽기"만 재현 (Azure 끔)
# 서버에서
cp .env.example .env # DB_*, JWT_*, 외부 API 키 채우기
# AZURE_STORAGE_CONNECTION_STRING 은 비워 둔다 ← 이번 단계에서는 안 올린다
# INDEXNOW_KEY 도 비워 둔다 ← 없는 주소를 색인 통보하지 않는다
docker compose up -d
docker compose logs -f worker
확인: 사이트 1개 발행 → curl -I http://<서버>:3000/s/<slug> 200 ·
out/s/<slug>/index.html 생성 · out/payloads/.status/<slug>.json 의 ok: true.
여기서 막히면 Azure 문제가 아니다. DB 연결 / payload 디렉토리 마운트 / node_modules 셋 중 하나다.
2단계 — 정적 서빙을 nginx 로 교체 (구현됨)
serve-sites.mjs 는 개발용이다. 운영은 o2o-web4ai-nginx 컨테이너가 맡는다(nginx/site.conf).
★ 산출물은 named volume site-out 에 있다. 프리렌더가 쓰고 nginx·워커가 읽는다.
호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
docker compose down 으로는 안 지워진다 — down -v 만 지운다.
docker compose up -d # nginx 포함 전부
docker compose exec nginx ls /usr/share/nginx/html/s # 들여다볼 때
확인은 스크립트가 한다. 색인을 기다리지 않고 지금 볼 수 있는 것만 본다 — 파일이 그 자리에 있는지, JSON-LD 가 HTML 소스에 들어 있는지, 그리고 크롤러 UA 로 받았을 때 200 이 나오는지(CDN·WAF 가 봇을 막는 설정이 기본값인 경우가 있는데 브라우저로는 절대 안 보인다).
docker compose exec worker python scripts/check_search_ready.py https://<도메인>
/s/<slug> (끝 슬래시 없음)이 열리는지도 이 스크립트가 본다 — 사장님이 주소창에 치는 형태가 그거다.
2-1단계 — TLS
nginx/site.conf 에 443 과 인증서 볼륨을 추가한다. Blob 단독으로는 커스텀 도메인 TLS 가
안 되지만 nginx 는 Let's Encrypt 로 끝난다 — CDN 을 억지로 끼울 이유가 없다.
4. 나중에 — Azure Blob 을 켤 때
언제 켜나: 실사용 고객이 붙어 "서버 하나가 죽으면 전 사이트가 동시에 내려간다"를 더는 감수할 수 없을 때. 그 전에는 켜지 않는다 (ARCHITECTURE.md 3절).
4-1단계 — Azure Blob 붙이기
# 접두사 함정: HTML 이 /assets/… 를 루트 절대경로로 가리킨다.
# CDN 오리진 경로를 세팅하기 전까지는 접두사를 비운다.
AZURE_STORAGE_CONNECTION_STRING=<연결문자열>
AZURE_STORAGE_CONTAINER='$web'
AZURE_STORAGE_PREFIX=
docker compose exec worker python scripts/republish_all.py --dry-run # 대상 확인
docker compose exec worker python scripts/republish_all.py # 전체 업로드
확인: https://<account>.z*.web.core.windows.net/s/<slug> 가 CSS 까지 입혀서 뜨는지.
스타일이 없으면 접두사/경로 문제다(2절 참고).
4-2단계 — 도메인 · TLS · 색인
- DNS 를 Blob 정적 웹사이트(또는 앞단 CDN)로 연결
- HTTPS 확인 — 커스텀 도메인 + TLS 는 Blob 단독으로는 안 되고 CDN/Front Door 가 필요하다
DEFAULT_HOST가 실제 도메인과 같은지 재확인 → 다르면 고치고 전체 재발행- 그 다음에야
INDEXNOW_KEY를 채운다. 백엔드와o2o-web4ai-web이 같은 값이어야 한다 (프리렌더가 루트에<key>.txt를 굽고 검색엔진이 대조한다 — 어긋나면 403) https://<도메인>/<key>.txt와https://<도메인>/robots.txt가 열리는지 확인- 구글은 IndexNow 미지원 → Search Console 에
https://<도메인>/sitemap.xml수동 제출
5. 되돌리기
out/ 은 재생성물이라 백업이 필요 없다. 문제가 생기면
docker compose restart web → 전체 재굽기 → republish_all.py.
지켜야 할 건 DB 와 out/payloads/ 뿐이다.