o2o-site-AEO/docs/DEPLOY.md
Mina Choi 48109fdc99 [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
2026-09-02 09:33:59 +09:00

12 KiB
Raw Blame History

배포 · 스토리지

현재 결정 (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 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 solution/backend/scripts/republish_all.py 다.

규칙: solution/site 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.

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.pyDEFAULT_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 를 다시 만들어야 한다.

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 가 단일 출처다.

# 서버에서
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>.jsonok: 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 만 지운다.

docker compose up -d                      # nginx 포함 전부
docker compose exec solution-site ls /usr/share/nginx/html/s   # 들여다볼 때

확인은 스크립트가 한다. 색인을 기다리지 않고 지금 볼 수 있는 것만 본다 — 파일이 그 자리에 있는지, JSON-LD 가 HTML 소스에 들어 있는지, 그리고 크롤러 UA 로 받았을 때 200 이 나오는지(CDN·WAF 가 봇을 막는 설정이 기본값인 경우가 있는데 브라우저로는 절대 안 보인다).

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 으로 나간다. 검색엔진은 "확인 실패" 만 뱉고 이유를 안 알려준다.

curl -s https://<도메인>/BingSiteAuth.xml   # HTML 이 나오면 파일이 없는 것이다

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 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>.txthttps://<도메인>/robots.txt 가 열리는지 확인
  6. 구글은 IndexNow 미지원 → Search Console 에 https://<도메인>/sitemap.xml 수동 제출

5. 되돌리기

out/ 은 재생성물이라 백업이 필요 없다. 문제가 생기면 docker compose restart solution-frontend → 전체 재굽기 → republish_all.py. 지켜야 할 건 DB 와 out/payloads/ 뿐이다.