# 배포 · 스토리지 > **현재 결정 (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//index.html 사이트 전체 s//llms.txt 확인된 사실 목록(AEO) ``` 사이트별 `sitemap.xml`·`robots.txt` 는 없앴다 — 한 장짜리의 사이트맵은 URL 이 하나뿐이고, `/s//robots.txt` 는 애초에 아무도 읽지 않는다(RFC 9309: 오리진 루트만). ## 2. CSS 주소는 불변이 아니다 `/assets/index-DvNTmLhy.css` 의 해시는 **렌더러 CSS 가 바뀌면 바뀐다.** 프리렌더가 `dist/client/.vite/manifest.json` 을 읽어 HTML 에 박으므로 새로 굽는 HTML 은 항상 맞다. 문제는 **이미 구워져 올라간 HTML** 이다. ``` 프론트 수정 → 새 번들(새 해시) 로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐 Azure : 새 해시 추가, 옛 해시 유지 ← 같다 ``` **2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가 CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다. 지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json` 대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다). **그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다. 프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다. ⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다. 옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는 블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다. **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** ```bash docker compose restart solution-prerender # 기동하며 전체 재굽기 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`) | `web4ai.o2osolution.ai` | | 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 | ★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. 이 값이 canonical·og:url·sitemap·IndexNow 통보에 전부 들어간다. 다른 주소에 올릴 거면 `SITE_PUBLIC_HOST` 를 먼저 덮는다. 안 고치면 발행은 성공하는데 사이트맵과 색인 통보가 존재하지 않는 주소를 가리킨다 — **조용히 틀리는 종류라 아무도 눈치채지 못한다.** ★ `origin` 은 payload JSON 에 **구워져 들어간다**(`out/payloads/.json`). 호스트를 바꾼 뒤 프리렌더만 다시 돌리면 옛 주소가 그대로 나온다. 반드시 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다. ### 0단계-b — 구글 로그인도 주소가 정해져야 켜진다 `GOOGLE_CLIENT_ID` 하나를 루트 `.env` 에 적으면 compose 가 백엔드와 프론트 (`VITE_GOOGLE_CLIENT_ID`) 양쪽에 흘려보낸다. **두 곳에 따로 적지 않는다.** - Google Cloud Console > 사용자 인증 정보 > **OAuth 2.0 클라이언트 ID(웹 애플리케이션)** - **승인된 JavaScript 원본**에 화면을 여는 주소를 그대로 넣는다(포트까지). 리디렉션 URI 는 쓰지 않는다 — 브라우저가 ID 토큰을 바로 받는 방식(GIS)이다. - 주소가 바뀌면 원본 목록도 같이 고친다. 안 고치면 **버튼은 뜨는데 눌러도 아무 일이 없다.** - 비워 두면 구글 로그인만 꺼진다(버튼 자체가 안 뜬다). id/pw 로그인·가입은 그대로 된다. ★ `VITE_*` 라서 **번들에 구워진다** — 값을 넣거나 바꾸면 `./deploy.sh solution-site` 로 다시 굽는다. ### 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/` 200 · `out/s//index.html` 생성 · `out/payloads/.status/.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/` (끝 슬래시 없음)이 열리는지도 이 스크립트가 본다 — 사장님이 주소창에 치는 형태가 그거다. ### 2-1단계 — TLS `nginx/site.conf` 에 443 과 인증서 볼륨을 추가한다. Blob 단독으로는 커스텀 도메인 TLS 가 안 되지만 nginx 는 Let's Encrypt 로 끝난다 — CDN 을 억지로 끼울 이유가 없다. ### 2-2단계 — 검색엔진 소유확인 ★ **오리진이 하나라 루트에서 한 번만 하면 `/s/` 전부가 딸려온다.** 사장님이 늘어도 반복하지 않는다 — 오리진을 안 가른 이유가 이거다. | | 방식 | 어디에 사는가 | |---|---|---| | 구글 Search Console | DNS TXT (도메인 속성) | DNS. **이 레포 밖이고 재배포와 무관하다** | | Bing Webmaster | `BingSiteAuth.xml` | `solution/frontend/public/` → 이미지에 구워진다 | | 네이버 서치어드바이저 | **HTML 파일** (DNS TXT 를 안 받는다) | `nginx/site.conf` 가 직접 내준다 (**git 에 없다** — 서버 로컬) | ★ **파일 방식은 재배포가 필요하다.** `public/` 은 `solution-site` 이미지에 구워지므로 `docker cp` 로 밀어 넣으면 컨테이너 재생성 때 사라진다 — 검색엔진은 인증을 주기적으로 재확인하므로 그때 조용히 풀린다. ★ **확인은 상태코드가 아니라 내용으로 한다.** `location /` 이 `try_files $uri … /index.html` 이라 파일명이 한 글자만 틀려도 404 가 아니라 **빌더 앱 HTML 이 200 으로** 나간다. 검색엔진은 "확인 실패" 만 뱉고 이유를 안 알려준다. ```bash curl -s https://<도메인>/BingSiteAuth.xml # HTML 이 나오면 파일이 없는 것이다 ``` #### 네이버 — nginx 가 파일을 내준다 네이버는 구글처럼 DNS 로 끝낼 수 없다(**DNS TXT 를 안 받는다**). 남은 것은 메타태그와 HTML 파일 둘이고, **파일 + nginx** 로 간다. 메타태그를 안 쓰는 이유: 랜딩 `` 는 `solution/frontend` 가 만들고 값이 `VITE_*` 로 **번들에 구워진다** — 토큰을 바꿀 때마다 `./deploy.sh solution-site` 재빌드가 필요하다. `nginx/site.conf` 는 **바인드 마운트**라 고치고 reload 하면 끝이고, `solution/` 을 건드리지 않는다. ★★ **그냥 파일을 올리는 것으로는 안 된다.** 오리진 루트의 `*.html` 은 nginx 맨 아래 `location /` 의 SPA 폴백으로 떨어져 **404 가 아니라 빌더 앱 HTML 이 200 으로** 나간다 (전용 블록이 있는 건 `.txt` 뿐이다 — IndexNow 키). 그래서 **`location =` 블록이 필수**다. 1. 서치어드바이저 > 사이트 관리 > 소유확인에서 **HTML 파일** 방식을 골라 파일명을 확인 (`naver1234abcd.html` 꼴) 2. 서버의 `nginx/site.conf` 에서 "네이버 서치어드바이저 소유확인" 블록의 주석을 풀고 `<토큰>` 두 자리를 파일명(`.html` 제외)으로 바꾼다 3. 루트 `.env` 에 `NAVER_SITE_VERIFICATION=<토큰>` — 점검이 대조할 기대값이다 (★ 두 곳이다. nginx 가 env 를 못 읽어서 그렇고, 어긋나면 4번이 잡는다) 4. `docker compose restart solution-site` (또는 nginx reload). **재빌드는 필요 없다** 5. 레포 루트에서 `solution/backend/.venv/bin/python geo/scripts/check_naver_eo.py` — **상태코드가 아니라 내용으로** 본다. "200 이지만 빌더 앱 HTML 이 나온다" 면 2번의 블록이 없거나 파일명이 다른 것이다 6. 서치어드바이저에서 [소유확인] ★ **등록은 한 번이 끝이 아니다.** 검색엔진은 소유확인을 주기적으로 재확인하고, 내용이 사라진 시점에 등록이 풀리면서 **아무 알림도 오지 않는다.** `site.conf` 는 git 에 없으므로 **서버를 새로 세우면 2번을 다시 해야 한다** — 빼먹으면 며칠 뒤 조용히 풀린다. 판정 코드는 `geo/naver/checks.py` 한 곳이다. ★ **등록 전에는 창이 없다.** 사이트맵 제출·웹페이지 수집 요청·색인 진단이 전부 등록된 사이트에만 열린다. IndexNow 통보는 등록 없이도 동작하지만, **통보가 먹었는지 볼 방법이 없다** — 그래서 이 단계가 네이버 쪽 나머지 작업의 전제다. --- ## 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://.z*.web.core.windows.net/s/` 가 **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` 이 **같은 값**이어야 한다 (프리렌더가 루트에 `.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403) 5. `https://<도메인>/.txt` 와 `https://<도메인>/robots.txt` 가 열리는지 확인 6. 구글은 IndexNow 미지원 → Search Console 에 `https://<도메인>/sitemap.xml` 수동 제출 ## 5. 되돌리기 `out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면 `docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`. 지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.