o2o-site-AEO/nginx/site.conf.example
Mina Choi 533de126fb [feat] solution/frontend,nginx: 랜딩·요금·사례를 프리렌더 — 크롤러가 빈 종이를 받던 것
실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`<a>` 0개였다. 같은 호스트의
발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규
도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안
걸리던 이유다.

스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고
`ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 —
나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다.

- react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`.
  로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다)
- src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일.
  가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다
- 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다.
  예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다.
  랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다
- src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을
  전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의
  최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다)
- LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라
  라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다
- nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로
  `/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩
  HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다
- nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가
  조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다
- site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow.
  이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다

검증: tsc·eslint·react-router build 통과.
랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj
2026-09-07 11:30:27 +09:00

145 lines
7.4 KiB
Plaintext

# 공개 진입점 하나. 사장님 앱 · 발행 사이트 · API 가 **같은 오리진**을 쓴다.
#
# / → 사장님 앱 (이미지에 구워 넣은 정적 번들, /srv/app)
# /s/<slug> → 발행 사이트 (site-out 볼륨에서 정적)
# /assets/ → 발행본 공용 번들 (정적)
# /robots.txt · /sitemap.xml → 크롤러가 읽는 파일 (정적)
# /v1/... /healthz → API(solution-backend:9800)
#
# ★ 오리진을 가르지 않는 이유: robots.txt·sitemap.xml 은 RFC 9309 상 **오리진 루트에서만**
# 읽힌다. 앱과 사이트를 다른 호스트에 두면 인증서도 DNS 도 두 벌이 되고 CORS 가 붙는다.
# 개발에서는 Vite 프록시가 같은 일을 한다(solution/frontend/vite.config.ts).
#
# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더가 쓰고 여기서 읽기만 한다 —
# 호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
server {
listen 80;
listen [::]:80;
server_name _;
# ★ nginx 이미지의 기본 문서 루트(/usr/share/nginx/html)를 쓰지 않는다.
# named volume 을 거기 마운트하면 Docker 가 **이미지에 들어 있던 index.html 을
# 빈 볼륨으로 복사한다** — 그러면 오리진 루트가 "Welcome to nginx!" 를 띄우고,
# 그게 크롤러에 잡힌다. 빈 경로에 마운트하면 복사될 것이 없다.
root /srv/sites;
charset utf-8;
server_tokens off;
client_max_body_size 20m;
# ★ Docker 내장 DNS. 업스트림을 변수로 두면 nginx 가 **기동할 때** 이름을 풀지 않는다 —
# 안 그러면 solution-backend 가 아직 안 떴을 때 nginx 자체가 죽는다.
resolver 127.0.0.11 valid=10s ipv6=off;
set $api http://solution-backend:9800;
# 텍스트 산출물은 압축이 크게 먹는다(HTML 55KB → 10KB 안팎).
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_vary on;
gzip_types
text/plain text/css text/xml
application/javascript application/json application/xml
image/svg+xml;
# ── 발행 사이트 ────────────────────────────────────────────
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
location ^~ /s/ {
# ★ `/s/` 자체(발행본 목록 페이지)를 위해 필요하다. try_files 의 첫 인자 `$uri` 가
# 끝 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
index index.html;
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
# ★ 빌더와 발행본이 `/fonts/` 를 **각자** 쓴다(vite.config.ts 주석). 발행본을 먼저 보고
# 없으면 빌더 것으로 떨어뜨린다 — 한쪽만 잡으면 다른 쪽 폰트가 조용히 404 다.
location ^~ /fonts/ {
root /srv/sites;
add_header Cache-Control "public, max-age=604800";
access_log off;
try_files $uri @app_fonts;
}
location @app_fonts {
root /srv/app;
access_log off;
try_files $uri =404;
}
# ── 크롤러가 읽는 파일 ─────────────────────────────────────
# 발행하면 곧바로 반영되어야 한다. 길게 캐시하면 새 사업장이 사이트맵에 들어가도
# 크롤러가 옛 파일을 계속 본다.
location = /robots.txt {
add_header Cache-Control "public, max-age=300, must-revalidate";
try_files $uri =404;
}
location = /sitemap.xml {
add_header Cache-Control "public, max-age=300, must-revalidate";
try_files $uri =404;
}
# ── API ────────────────────────────────────────────────────
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
proxy_pass $api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# IndexNow 키 파일. 프리렌더가 루트에 <key>.txt 를 굽고 검색엔진이 대조한다.
# ★ `{8,128}` 같은 수량자는 못 쓴다 — nginx 는 `{`·`}` 를 블록 구분자로 먼저 읽는다.
location ~ ^/[A-Za-z0-9_-]+\.txt$ {
try_files $uri =404;
}
# ── 사장님 앱 (그 외 전부) ─────────────────────────────────
# 해시가 박힌 번들. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# ★ 발행본 `/assets/` 와 겹치지 않게 빌더만 `builder-assets` 로 뺐다
# (solution/frontend/vite.config.ts 의 build.assetsDir).
location ^~ /builder-assets/ {
root /srv/app;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
# ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다.
# 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면
# `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로
# 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다.
# 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다.
# ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도
# 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
location / {
root /srv/app;
try_files $uri $uri/index.html /__spa-fallback.html;
add_header Cache-Control "no-cache";
}
# 발행되지 않은 주소. 사장님이 오타를 냈을 때 흰 화면 대신 이유를 보여준다.
error_page 404 /404.html;
location = /404.html {
internal;
return 404 '<!doctype html><html lang="ko"><meta charset="utf-8"><title>페이지를 찾을 수 없습니다</title><body style="font-family:system-ui;padding:3rem;text-align:center"><h1>페이지를 찾을 수 없습니다</h1><p>주소를 다시 확인해 주세요.</p></body></html>';
add_header Content-Type "text/html; charset=utf-8";
}
}