[fix] site,deploy: 발행본 목록의 정본 주소를 /s 로 — 끝 슬래시 제거

`/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다.
그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08:
`/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째
페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다.

색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한
표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts
주석) 목록만 반대 형태로 남아 있었다.

- nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301.
  `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- 같은 파일: `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/<slug>`→200 · `/s/<slug>/`→200 ·
`/nope`→200 앱 셸(변화 없음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs
This commit is contained in:
Mina Choi 2026-09-08 13:02:11 +09:00
parent 9f16c3224b
commit f2dad65792
7 changed files with 259 additions and 39 deletions

View File

@ -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/<slug>`**끝 슬래시 없이** 열려야 한다.
정적 서버를 바꾸든 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 다.
## 코드 규약

View File

@ -5,6 +5,37 @@
---
## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
**무슨 일**
사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지
(`/s/<slug>`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다.
목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/`**슬래시로 시작하는 것만** 잡고,
`/s` 는 맨 아래 `location /` 로 떨어진다.
**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다
(실측: `/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도
목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
**바꾼 것**
- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/`
거기로 301. `^~ /s/``index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- `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/<slug>/` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서
크롤러가 스스로 만들어낼 주소는 아니다.
---
## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다
**무슨 일**

View File

@ -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/<slug>`)와 형태를 맞춘다.
# 이 블록이 없으면 `/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/<slug>/`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가
# 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
index index.html;
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를

View File

@ -320,8 +320,20 @@ export function Step3DataReview() {
2
.
</p>
{/* ** **
, [ ] (PublishModal
PlaceFirstPanel). ,
.
. */}
<p className="rounded-xl border border-border bg-muted/40 p-3 text-[11px] leading-relaxed text-muted-foreground">
<strong className="text-foreground">
.
</strong>{' '}
,
. .
</p>
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
<span> </span>
<span> </span>
<ArrowRight />
</Button>
</>

View File

@ -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() {
<Dialog
open={isOpen}
onClose={close}
title={isDone ? '발행 완료' : isRepublish ? '재발행 전 점검' : '발행 전 점검'}
title={
blocker === 'signin'
? '지금 화면은 미리보기입니다'
: blocker === 'place'
? '가게 확인이 먼저입니다'
: isDone
? '발행 완료'
: isRepublish
? '재발행 전 점검'
: '발행 전 점검'
}
description={
isDone
? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.'
: '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.'
blocker === 'signin'
? '만드신 화면은 아직 어디에도 올라가 있지 않습니다.'
: blocker === 'place'
? '발행본은 확인된 가게 한 곳에 묶입니다.'
: isDone
? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.'
: '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.'
}
className="max-w-lg"
footer={
@ -197,7 +259,23 @@ export function PublishModal() {
</Button>
{isDone ? (
{/* ()
, [] . */}
{blocker === 'signin' ? null : blocker === 'place' ? (
<Button
variant="primary"
className="flex-1"
onClick={() => {
// 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라
// 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다.
close();
navigate(PLACE_SEARCH_URL);
}}
>
<Search />
<span> </span>
</Button>
) : isDone ? (
<Button
variant="primary"
className="flex-1"
@ -236,8 +314,11 @@ export function PublishModal() {
}
>
<div className="space-y-4">
{blocker === 'signin' && <SignInFirstPanel />}
{blocker === 'place' && <PlaceFirstPanel />}
{/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */}
{publisher.isLive && !isDone && (
{!blocker && !isDone && (
<SiteStatusRow
version={publisher.currentVersion?.version}
needsRebuild={publisher.needsRebuild}
@ -245,8 +326,10 @@ export function PublishModal() {
/>
)}
{/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. */}
{!isDone && (
{/* [] .
blocker . ·
. */}
{!blocker && !isDone && (
<SlugField
value={slug}
host={PUBLISH_HOST}
@ -261,7 +344,7 @@ export function PublishModal() {
/>
)}
{!isDone && gate.blockers.length > 0 && (
{!blocker && !isDone && gate.blockers.length > 0 && (
<section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-destructive">
<ShieldAlert className="size-3.5" />
@ -273,7 +356,7 @@ export function PublishModal() {
</section>
)}
{!isDone && gate.warnings.length > 0 && (
{!blocker && !isDone && gate.warnings.length > 0 && (
<section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-warning">
<AlertTriangle className="size-3.5" />
@ -285,7 +368,7 @@ export function PublishModal() {
</section>
)}
{!isDone && gate.findings.length === 0 && state.phase === 'idle' && (
{!blocker && !isDone && gate.findings.length === 0 && state.phase === 'idle' && (
<div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/8 p-3 text-xs text-success">
<Check className="size-4 shrink-0" />
<span>
@ -319,6 +402,71 @@ export function PublishModal() {
);
}
/**
* ** , .**
*
* /login . ·
* (stores/builder ) .
* (EditorSignInGate) .
* blocker
* ( PlaceFirstPanel ).
*/
function SignInFirstPanel() {
return (
<div className="space-y-3">
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Eye className="size-3.5" />
<span> </span>
</div>
<p className="leading-relaxed text-muted-foreground">
·AI (HTML) .
, .
</p>
<p className="mt-1.5 text-[11px] font-medium">
.
</p>
</div>
<div className="flex justify-center">
<SignInForm
submitLabel="로그인하고 발행 준비"
header={
<p className="text-center text-xs leading-relaxed text-muted-foreground">
.
</p>
}
/>
</div>
</div>
);
}
/**
* 3 .
*
* .
* features/onboarding/ensureServerPlace
* , .
*/
function PlaceFirstPanel() {
return (
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Store className="size-3.5" />
<span> </span>
</div>
<p className="leading-relaxed text-muted-foreground">
··
·AI .
, .
</p>
<p className="mt-1.5 text-[11px] font-medium">
.
</p>
</div>
);
}
/** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */
/**
* .

View File

@ -690,10 +690,12 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
})
.sort((a, b) => a.loc.localeCompare(b.loc));
// ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
// ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
// 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다.
// ★ 주소에 끝 슬래시가 있어야 한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡는다.
const indexUrl = joinUrl(origin, SITE_DIR) + '/';
// ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/<slug>`)와 같은 형태여야 한다.
// nginx 가 `location = /s` 로 이 파일을 직접 주고 `/s/` 는 여기로 301 한다
// (nginx/site.conf). 그 블록이 없으면 `/s` 는 빌더 SPA 셸을 200 으로 내준다.
const indexUrl = joinUrl(origin, SITE_DIR);
writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8');
// 사이트맵에는 랜딩·목록 페이지도 담는다. 랜딩은 이 호스트의 첫 페이지이고,

View File

@ -55,8 +55,8 @@ export function readBakedLastmod(html: string): string | undefined {
*/
export function renderSiteIndex(
origin: string,
/** . ** ** nginx `^~ /s/`
* , `/s` (SPA) (nginx/site.conf). */
/** (`<origin>/s`). ** **
* . nginx `location = /s` (nginx/site.conf). */
indexUrl: string,
entries: DirectoryEntry[],
): string {