From f2dad657925369d6eb157ec07ed56f785fbce87f Mon Sep 17 00:00:00 2001
From: Mina Choi
Date: Tue, 8 Sep 2026 13:02:11 +0900
Subject: [PATCH] =?UTF-8?q?[fix]=20site,deploy:=20=EB=B0=9C=ED=96=89?=
=?UTF-8?q?=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EC=A0=95=EB=B3=B8=20?=
=?UTF-8?q?=EC=A3=BC=EC=86=8C=EB=A5=BC=20`/s`=20=EB=A1=9C=20=E2=80=94=20?=
=?UTF-8?q?=EB=81=9D=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다.
그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08:
`/s` 3.1KB `Web4Ai` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째
페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다.
색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한
표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts
주석) 목록만 반대 형태로 남아 있었다.
- nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301.
`^~ /s/` 의 `index index.html` 은 남긴다 — `/s//` 가 그걸로 열린다
- 같은 파일: `absolute_redirect off`. TLS 를 앞단 Apache 가 끊어 nginx 의 `$scheme` 는
늘 `http` 다 — 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려간다
- prerender.ts: `indexUrl` 의 `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가
이 값 하나를 쓴다
- directory.ts · AGENTS.md · DEVLOG.md: 슬래시 규칙과 근거 갱신
검증: `nginx -t` 통과 · `tsc --noEmit` 통과 · 컨테이너 실측
`/s`→200 목록 · `/s/`→301 `Location: /s`(상대) · `/s/`→200 · `/s//`→200 ·
`/nope`→200 앱 셸(변화 없음)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs
---
AGENTS.md | 11 +-
docs/DEVLOG.md | 31 +++
nginx/site.conf.example | 24 +-
.../features/onboarding/Step3DataReview.tsx | 14 +-
.../src/features/publish/PublishModal.tsx | 206 +++++++++++++++---
solution/site/scripts/prerender.ts | 8 +-
solution/site/src/seo/directory.ts | 4 +-
7 files changed, 259 insertions(+), 39 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 0c725e2..b682a08 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -73,8 +73,15 @@
- **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`).
- **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` ↔
`solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
-- **디렉토리 요청 → `index.html`.** `/s/` 가 **끝 슬래시 없이** 열려야 한다.
- 정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다.
+- **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.**
+ canonical · 사이트맵 · llms.txt · 서치콘솔 색인 요청이 전부 이 형태여야 한다. 어긋나면
+ 구글이 제출분을 **"대체 페이지(적절한 표준 태그가 있음)"** 로 분류한다 — 색인은 되는데
+ 제출 URL 은 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
+ → nginx 는 `location = /s` 로 목록 index.html 을 **직접** 주고 `/s/` 는 거기로 301 한다.
+ 이 블록을 지우면 `/s` 가 맨 아래 `location /` 로 떨어져 **빌더 SPA 셸이 200 으로 나간다**
+ — 404 도 목록도 아닌 세 번째 페이지가 크롤러에 잡힌다(실측 2026-09-08).
+ → 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
+ Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
## 코드 규약
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index bf68dea..14852b8 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -5,6 +5,37 @@
---
+## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
+
+**무슨 일**
+사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지
+(`/s/`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다.
+목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고,
+`/s` 는 맨 아래 `location /` 로 떨어진다.
+
+**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다
+(실측: `/s` 3.1KB `Web4Ai` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도
+목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
+
+**바꾼 것**
+- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는
+ 거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s//` 가 그걸로 열린다
+- `absolute_redirect off`: TLS 를 앞단 Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다.
+ 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려가는 301 이 나간다
+- `prerender.ts` `indexUrl`: `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를
+ 쓰므로 전부 같이 따라온다
+
+**왜 형태를 맞추나**
+색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준
+태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번
+밟은 함정이고(`prerender.ts` 주석), 목록만 반대 형태로 남아 있었다.
+
+**남은 것**
+`/s//` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서
+크롤러가 스스로 만들어낼 주소는 아니다.
+
+---
+
## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다
**무슨 일**
diff --git a/nginx/site.conf.example b/nginx/site.conf.example
index 16b79e3..be044e8 100644
--- a/nginx/site.conf.example
+++ b/nginx/site.conf.example
@@ -44,10 +44,30 @@ server {
image/svg+xml;
# ── 발행 사이트 ────────────────────────────────────────────
+ # ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
+ # 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
+ # `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다.
+ absolute_redirect off;
+
+ # ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/`)와 형태를 맞춘다.
+ # 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고
+ # **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에
+ # 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록).
+ location = /s {
+ root /srv/sites;
+ try_files /s/index.html =404;
+ add_header Cache-Control "public, max-age=300, must-revalidate";
+ }
+
+ # 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다.
+ location = /s/ {
+ return 301 /s;
+ }
+
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
location ^~ /s/ {
- # ★ `/s/` 자체(발행본 목록 페이지)를 위해 필요하다. try_files 의 첫 인자 `$uri` 가
- # 끝 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
+ # ★ `/s//`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝
+ # 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
index index.html;
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
diff --git a/solution/frontend/src/features/onboarding/Step3DataReview.tsx b/solution/frontend/src/features/onboarding/Step3DataReview.tsx
index 713c468..110cbac 100644
--- a/solution/frontend/src/features/onboarding/Step3DataReview.tsx
+++ b/solution/frontend/src/features/onboarding/Step3DataReview.tsx
@@ -320,8 +320,20 @@ export function Step3DataReview() {
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
주소 붙여넣기와 자동 찾기가 모두 열립니다.
+ {/* ★ 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이
+ 없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal
+ PlaceFirstPanel). 예전에는 아무 말 없이 통과시켜서, 사장님은 사이트를 다
+ 만든 뒤 발행을 누르는 자리에서야 그 사실을 알았다.
+ 버튼은 남긴다 — 검증을 못 통과한 분이 화면을 구경할 길까지 막지는 않는다. */}
+
+
+ 이대로 진행하면 화면은 만들 수 있지만 발행은 되지 않습니다.
+ {' '}
+ 발행본은 확인된 가게 한 곳에 묶여서, 가게 확인 전에는 만들어 둘 페이지가
+ 없습니다. 둘러보신 뒤 언제든 가게를 확인하시면 그때 발행이 열립니다.
+
>
diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx
index 3b66929..755ea91 100644
--- a/solution/frontend/src/features/publish/PublishModal.tsx
+++ b/solution/frontend/src/features/publish/PublishModal.tsx
@@ -1,21 +1,29 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
+import {useNavigate} from 'react-router';
import {
AlertTriangle,
ArrowUpRight,
Check,
Copy,
ExternalLink,
+ Eye,
Loader2,
RefreshCw,
+ Search,
ServerCrash,
ShieldAlert,
+ Store,
} from 'lucide-react';
import {publishUrlString, toSlug} from '@o2o/shared';
+import {getAccessToken} from '@/api';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Dialog} from '@/components/ui/dialog';
+import {SignInForm} from '@/features/auth/SignInForm';
+import type {WizardStep} from '@/features/onboarding/wizardUrl';
import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils';
+import {useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder';
import {runPublishGate, type GateFinding} from './publishGate';
import {checkSiteSlug, reserveSiteSlug} from './siteSlug';
@@ -34,6 +42,27 @@ import {
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
+/**
+ * 가게 확인이 안 끝난 사장님을 돌려보낼 곳.
+ *
+ * ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날
+ * 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep).
+ */
+const PLACE_SEARCH_STEP: WizardStep = 'search';
+const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`;
+
+/**
+ * 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다.
+ *
+ * ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고
+ * 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라
+ * 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다.
+ * ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의
+ * [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데
+ * 발행만 안 된다.
+ */
+type PublishBlocker = 'signin' | 'place';
+
export function PublishModal() {
const isOpen = useBuilderStore((s) => s.isPublishModalOpen);
const close = useBuilderStore((s) => s.closePublishModal);
@@ -57,11 +86,28 @@ export function PublishModal() {
/**
* 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링).
- * ★ placeId 가 없는 데모 경로는 예전처럼 주소만 확정한다 — 없는 사업장을 서버에 빌드시킬 수 없다.
+ * ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다.
+ * PublishBlocker 주석 참고.
*/
const publisher = usePublishSite(placeId);
const {state, reset} = publisher;
+ const navigate = useNavigate();
+
+ /*
+ * ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도
+ * 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 —
+ * 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다.
+ */
+ useAuthStore((s) => s.user);
+
+ /** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */
+ const blocker: PublishBlocker | null = publisher.isLive
+ ? null
+ : getAccessToken()
+ ? 'place'
+ : 'signin';
+
const gate = useMemo(
() =>
runPublishGate({
@@ -110,7 +156,12 @@ export function PublishModal() {
if (!isOpen) reset();
}, [isOpen, reset]);
- const isDone = state.phase === 'published' || (!publisher.isLive && Boolean(publishedUrl));
+ /**
+ * ★ 서버가 발행을 확정한 것만 '발행됨'이다.
+ * 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이
+ * 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다.
+ */
+ const isDone = state.phase === 'published';
/**
* 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다.
@@ -149,25 +200,22 @@ export function PublishModal() {
}, [slug, placeId]);
const handlePublish = async () => {
- if (!gate.canPublish || publisher.isPublishing || !slugReady) return;
+ // ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 —
+ // 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다.
+ if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return;
- if (publisher.isLive) {
- // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
- // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
- if (!isSlugLocked) {
- try {
- await reserveSiteSlug(placeId ?? '', slug);
- } catch (error) {
- notifyApiError(error, '주소를 확정하지 못했습니다.');
- return;
- }
+ // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
+ // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
+ if (!isSlugLocked) {
+ try {
+ await reserveSiteSlug(placeId ?? '', slug);
+ } catch (error) {
+ notifyApiError(error, '주소를 확정하지 못했습니다.');
+ return;
}
- // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
- publisher.publish();
- return;
}
- setPublishedUrl(url);
- notify.success('발행 준비가 끝났습니다', '확인된 정보만 담긴 정적 페이지가 생성됩니다.');
+ // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
+ publisher.publish();
};
const handleCopy = async () => {
@@ -184,11 +232,25 @@ export function PublishModal() {