o2o-site-AEO/docs/DEPLOY.md
Mina Choi c250b656fd [chore] solution/frontend,docs: Bing 소유확인 파일 추가 — 이미지에 구워야 재생성에도 살아남는다
Bing Webmaster 의 XML 파일 방식은 루트에서 파일을 읽는다. nginx `location /` 이
`/srv/app`(= solution-site 이미지)에서 찾으므로 `public/` 에 두고 굽는 것 말고는 자리가 없다.
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라지고, 검색엔진이 인증을 재확인하는
시점에 조용히 풀린다.

- solution/frontend/public/BingSiteAuth.xml: Bing 이 준 파일 그대로(가공하면 파싱이 깨진다)
- docs/DEPLOY.md 2-2절: 세 검색엔진의 소유확인 방식과 사는 자리.
  ★ 확인은 상태코드가 아니라 내용으로 한다 — `try_files … /index.html` 이라
  파일명이 틀리면 404 가 아니라 빌더 HTML 이 200 으로 나간다

검증: 배포 후 curl 로 본문 대조
2026-09-01 13:33:48 +09:00

207 lines
11 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 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
`solution/backend/scripts/republish_all.py` 다.
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash
docker compose restart solution-frontend # 기동하며 전체 재굽기
docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec solution-worker python scripts/republish_all.py
```
## 3. 서버에 올리는 순서 (지금 밟는 경로)
굽는 것과 서빙하는 것을 **한 번에 바꾸지 않는다.** 깨졌을 때 어느 쪽인지 못 가린다.
### 0단계 — 도메인부터 정한다 (코드가 하드코딩이라 먼저다)
발행 호스트는 **백엔드·프론트 두 곳**에 있고 값이 같아야 한다.
| 어디 | 무엇 | 기본값 |
|---|---|---|
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `w4ai.o2o.kr` |
| 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 |
★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
이 값이 canonical·og:url·sitemap·IndexNow 통보에 전부 들어간다. 다른 주소에 올릴 거면
`SITE_PUBLIC_HOST` 를 먼저 덮는다. 안 고치면 발행은 성공하는데 사이트맵과 색인 통보가
존재하지 않는 주소를 가리킨다 — **조용히 틀리는 종류라 아무도 눈치채지 못한다.**
★ `origin` 은 payload JSON 에 **구워져 들어간다**(`out/payloads/<slug>.json`). 호스트를 바꾼 뒤
프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 **백엔드에서 재발행**해 payload 를
다시 만들어야 한다.
### 1단계 — 서버에서 "굽기"만 재현 (Azure 끔)
배포 대상 서버(접속·경로·이미 물린 포트)는 [SERVERS.md](SERVERS.md) 가 단일 출처다.
```bash
# 서버에서
cp .env.example .env # DB_*, JWT_*, 외부 API 키 채우기
# AZURE_STORAGE_CONNECTION_STRING 은 비워 둔다 ← 이번 단계에서는 안 올린다
# INDEXNOW_KEY 도 비워 둔다 ← 없는 주소를 색인 통보하지 않는다
docker compose up -d
docker compose logs -f solution-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-solution-site` 컨테이너가 맡는다(`nginx/site.conf`).
★ **산출물은 named volume `site-out` 에 있다.** 프리렌더가 쓰고 nginx·워커가 읽는다.
호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
`docker compose down` 으로는 안 지워진다 — `down -v` 만 지운다.
```bash
docker compose up -d # nginx 포함 전부
docker compose exec solution-site ls /usr/share/nginx/html/s # 들여다볼 때
```
확인은 스크립트가 한다. 색인을 기다리지 않고 **지금 볼 수 있는 것만** 본다 —
파일이 그 자리에 있는지, JSON-LD 가 HTML 소스에 들어 있는지,
그리고 **크롤러 UA 로 받았을 때 200 이 나오는지**(CDN·WAF 가 봇을 막는 설정이 기본값인 경우가 있는데
브라우저로는 절대 안 보인다).
```bash
docker compose exec solution-worker python scripts/check_search_ready.py https://<도메인>
```
`/s/<slug>` (끝 슬래시 없음)이 열리는지도 이 스크립트가 본다 — 사장님이 주소창에 치는 형태가 그거다.
### 2-1단계 — TLS
`nginx/site.conf` 에 443 과 인증서 볼륨을 추가한다. Blob 단독으로는 커스텀 도메인 TLS 가
안 되지만 nginx 는 Let's Encrypt 로 끝난다 — CDN 을 억지로 끼울 이유가 없다.
### 2-2단계 — 검색엔진 소유확인
★ **오리진이 하나라 루트에서 한 번만 하면 `/s/<slug>` 전부가 딸려온다.** 사장님이 늘어도
반복하지 않는다 — 오리진을 안 가른 이유가 이거다.
| | 방식 | 어디에 사는가 |
|---|---|---|
| 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** |
| Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 |
| 네이버 서치어드바이저 | 메타태그 / HTML 파일 (**DNS TXT 를 안 받는다**) | 아직 안 붙였다 |
★ **파일 방식은 재배포가 필요하다.** `public/` 은 `solution-site` 이미지에 구워지므로
`docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로
재확인하므로 그때 조용히 풀린다.
★ **확인은 상태코드가 아니라 내용으로 한다.** `location /` 이 `try_files $uri … /index.html`
이라 파일명이 한 글자만 틀려도 404 가 아니라 **빌더 앱 HTML 이 200 으로** 나간다.
검색엔진은 "확인 실패" 만 뱉고 이유를 안 알려준다.
```bash
curl -s https://<도메인>/BingSiteAuth.xml # HTML 이 나오면 파일이 없는 것이다
```
---
## 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 solution-worker python scripts/republish_all.py --dry-run # 대상 확인
docker compose exec solution-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. `SITE_PUBLIC_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행**
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-solution-frontend` 이 **같은 값**이어야 한다
(프리렌더가 루트에 `<key>.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403)
5. `https://<도메인>/<key>.txt` 와 `https://<도메인>/robots.txt` 가 열리는지 확인
6. 구글은 IndexNow 미지원 → Search Console 에 `https://<도메인>/sitemap.xml` 수동 제출
## 5. 되돌리기
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`.
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.