o2o-site-AEO/docs/DEPLOY.md
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — 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
2026-08-31 15:12:09 +09:00

181 lines
9.6 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.

# 배포 · 스토리지
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).
> 이 문서의 **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` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash
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 끔)
```bash
# 서버에서
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` 만 지운다.
```bash
docker compose up -d # nginx 포함 전부
docker compose exec nginx ls /usr/share/nginx/html/s # 들여다볼 때
```
확인은 스크립트가 한다. 색인을 기다리지 않고 **지금 볼 수 있는 것만** 본다 —
파일이 그 자리에 있는지, JSON-LD 가 HTML 소스에 들어 있는지,
그리고 **크롤러 UA 로 받았을 때 200 이 나오는지**(CDN·WAF 가 봇을 막는 설정이 기본값인 경우가 있는데
브라우저로는 절대 안 보인다).
```bash
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절](ARCHITECTURE.md)).
### 4-1단계 — Azure Blob 붙이기
```bash
# 접두사 함정: HTML 이 /assets/… 를 루트 절대경로로 가리킨다.
# CDN 오리진 경로를 세팅하기 전까지는 접두사를 비운다.
AZURE_STORAGE_CONNECTION_STRING=<연결문자열>
AZURE_STORAGE_CONTAINER='$web'
AZURE_STORAGE_PREFIX=
```
```bash
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 · 색인
1. DNS 를 Blob 정적 웹사이트(또는 앞단 CDN)로 연결
2. HTTPS 확인 — 커스텀 도메인 + TLS 는 Blob 단독으로는 안 되고 CDN/Front Door 가 필요하다
3. `DEFAULT_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행**
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-web` 이 **같은 값**이어야 한다
(프리렌더가 루트에 `<key>.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403)
5. `https://<도메인>/<key>.txt` 와 `https://<도메인>/robots.txt` 가 열리는지 확인
6. 구글은 IndexNow 미지원 → Search Console 에 `https://<도메인>/sitemap.xml` 수동 제출
## 5. 되돌리기
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
`docker compose restart web` → 전체 재굽기 → `republish_all.py`.
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.