# DB PASS가 공개 PASS는 아니다: 기술블로그 sitemap·/blog 렌더 검증으로 닫는 발행 완결 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 열다섯 번째 기록이다. 주제는 공개 검증이다. 9/22까지 14편이 `publish → get → list`로 DB 노출을 닫았다. 검증은 PASS였다. 그런데 같은 날 공개 사이트 실측에서 다른 숫자가 나왔다. DB 14편, `home.bungae.site/sitemap.xml` 9건, `/blog` 서버 렌더 0건. 원인은 `href='/blog/'+a.slug+'/'`를 클라이언트에서만 렌더하던 구조였다. DB에 있으면 보이는 것이 아니라, 서버가 내보내는 HTML에 있어야 보이는 것이다. 오늘 글은 이 간극을 메우는 검증 체인을 정리한다. `publish → get → list → sitemap → render`까지 같은 날 닫는 법이다. 매일 18:00 KST 발행 체제의 세 번째 주간 첫 기록이다.
## 1. 배경: 14편 PASS 뒤에 남은 공개 간극 실측
9/22 회고 글까지 시리즈는 규칙을 순서대로 쌓았다. 쓰기 전 게이트, 입력·시간·순서 고정, 재시도·계약·실패 분기, 증거 묶음. DB `get` 단건 대조(제목·본문 앞 200자·태그·카테고리)와 `list` 포함 여부로 매일을 PASS로 닫았다. DB 기준으로는 14편이 모두 노출됐다.
9/23에 공개 쪽을 재측정했다. 결과는 어긋났다. DB `list` 14편과 동일 시점의 공개 지표가 달랐다.
- DB: 14편 노출(PASS)
- `/sitemap.xml`: 9건만 수록
- `/blog` 서버 렌더(HTML 소스): 0건 수록, 클라이언트 렌더 후에만 목록이 채워짐
원인은 렌더 경계였다. 블로그 목록이 `href='/blog/'+a.slug+'/'` 패턴을 클라이언트 사이드에서만 생성하고, 서버가 내보내는 초기 HTML에는 목록이 없었다. 크롤러와 `curl`로 보는 HTML에는 글이 없었다. 브라우저에서 자바스크립트가 실행된 뒤에야 글이 보였다. DB PASS는 저장 PASS였고, 공개 PASS는 아니었다.
교훈은 단순하다. 저장 확인과 공개 확인은 다른 검사다. `get`+`list`는 저장 확인이다. sitemap과 서버 렌더는 공개 확인이다. 두 확인을 같은 날 같은 slug로 묶지 않으면 연속 PASS의 의미가 갈라진다.
## 2. 왜 get+list만으로는 부족한가: 클라이언트 렌더 함정
`get(slug)`는 DB 단건 정합성을 보고, `list`는 DB 목록 포함 여부를 본다. 둘 다 DB API가 아는 세상이다. 예전 9/12·9/14 글의 `list` 검증이 막은 것은 DB 노출 누락과 첫 페이지 함정(위치가 아니라 포함 여부)이었다. 공개 쪽 함정은 다른 레이어다.
공개 함정은 세 가지다.
첫째, 클라이언트 전용 렌더. 서버 HTML에는 `<div id=\"__next\">` 빈 껍질과 스크립트만 있고, 글 목록은 `fetch` 후 주입된다. `curl https://home.bungae.site/blog | grep slug`가 0건을 내는 이유다. 검색 엔진과 sitemap 생성기가 서버 HTML만 보면 글이 존재하지 않는 것처럼 보인다.
둘째, sitemap과 목록의 분리. sitemap은 빌드 시점 스냅샷을, `/blog`는 런타임 API를 본다. sitemap 빌드가 DB 쓰기 전에 돌면 sitemap에 빠지고, 목록이 클라 전용이면 서버 렌더에 빠진다. 같은 글이 두 곳에서 다른 시점에 보인다.
셋째, 검증 착시. DB `list`가 노출됐다고 해서 공개도 노출됐다고 말하면, 실제로는 5편이 sitemap에 없고 모든 글이 서버 렌더에 없는 상태를 PASS로 포장하게 된다. 9/23 실측이 보여준 것이 이 착시다.
```js
// DB PASS와 공개 PASS는 다른 검사다
async function verifyDbOnly(slug, io) {
const fetched = await io.get(slug);
const list = await io.list({ limit: 20, offset: 0 });
const dbExposed = list.articles.some(a => a.slug === slug);
return { titleMatch: fetched.title.length > 0, dbExposed }; // DB만 본다
}
async function verifyPublic(slug, fetchHtml) {
const sitemap = await fetchHtml('https://home.bungae.site/sitemap.xml');
const blogHtml = await fetchHtml('https://home.bungae.site/blog');
// 서버 HTML 소스에서 직접 찾는다 — JS 실행 전 상태
const inSitemap = sitemap.includes(`/blog/${slug}/`);
const inBlogSSR = blogHtml.includes(`/blog/${slug}/`);
return { inSitemap, inBlogSSR, publicPass: inSitemap && inBlogSSR };
}
```
DB PASS를 공개 PASS로 확장하려면, 같은 slug에 대해 `inSitemap && inBlogSSR`를 같은 날 확인해야 한다.
## 3. sitemap 검증: 서버가 아는 목록을 읽는다
sitemap은 크롤러가 아는 세상이다. DB에 있어도 sitemap에 없으면 검색 노출과 외부 검증에서 빠진다. 9/23 실측의 9/14 간극(14 vs 9)이 여기에 해당한다.
검증 방법은 소스 텍스트 검색이다. `fetch(sitemapUrl).text()` 뒤에 `text.includes('/blog/'+slug+'/')`로 판정한다. 페이지네이션이 있으면 모든 sitemap 청크를 합쳐야 한다. Cloudflare Pages의 sitemap이 빌드 산출물이면, 빌드 트리거 시점과 DB 쓰기 시점의 순서가 결과를 좌우한다.
운영 규칙은 두 가지다.
- 발행 직후가 아니라, 기대 반영 시점(빌드·캐시·CDN 반영 후)에 재측정한다. 즉시 0건이 나와도 바로 FAIL로 단정하지 않고, 9/12 글의 지연·누락 구분처럼 기다린 뒤 다시 읽는다.
- sitemap PASS 기준은 문자열 포함 여부다. 순위는 보지 않는다. sitemap에 있으면 PASS, 없으면 FAIL 또는 PENDING(지연)이다.
```js
async function verifySitemap(slug, fetchText) {
const xml = await fetchText('https://home.bungae.site/sitemap.xml');
const expected = `/blog/${slug}/`;
// sitemap이 분할돼 있으면 sitemap index를 따라가 합친다
if (xml.includes('<sitemap>')) {
const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
const chunks = await Promise.all(locs.filter(u => u.includes('sitemap')).map(fetchText));
return chunks.some(t => t.includes(expected)) || xml.includes(expected);
}
return xml.includes(expected);
}
```
DB `list` 포함과 sitemap 포함이 모두 true일 때, 저장을 넘어 수집 가능 단계까지 닫은 것이다.
## 4. /blog 서버 렌더 검증: href='/blog/'+slug를 HTML 소스에서 찾는다
`/blog` 목록의 서버 렌더 검증은 더 엄격하다. 브라우저가 아니라 `curl` 관점에서 본다. 자바스크립트 실행 없이 받은 HTML에 `href="/blog/<slug>/"`가 있어야 한다. 9/23 실측 0건의 원인이던 `href='/blog/'+a.slug+'/'` 클라 전용 패턴이 여기 걸린다.
검증 방법은 서버 HTML 텍스트 검색이다. Playwright `page.content()`가 아니라, `fetch`로 받은 생 HTML을 본다. 렌더가 `Next.js` ISR/SSR이면 배포·캐시 무효화 뒤에 반영되므로, sitemap과 마찬가지로 지연을 두고 재측정한다.
임시 방편과 근본 해결을 구분한다. 임시로는 빌드 후 sitemap에 넣는 것만으로도 크롤러 노출은 잡힌다. 근본으로는 `/blog`를 서버에서 목록을 채워 내보내도록 해야 한다. `getStaticProps`/`loader`에서 DB `list`를 직접 읽어 HTML에 `a[href]`를 박는 방식이다. 그러면 `curl | grep slug`가 0건에서 N건으로 바뀐다.
```js
async function verifyBlogSSR(slug, fetchText) {
const html = await fetchText('https://home.bungae.site/blog');
const href = `/blog/${slug}/`;
// 서버 HTML 소스에 href가 박혀 있어야 SSR PASS
const ssrHit = html.includes(href);
// 클라 전용 렌더면 html에는 없고, page.evaluate 후에만 생긴다 — 그건 SSR FAIL이다
return { href, ssrHit, verdict: ssrHit ? 'PASS' : 'FAIL' };
}
function dailyGateWithPublic(article, todayStr) {
const errors = [];
if (!article.slug.endsWith(todayStr)) errors.push(`slug date: ${article.slug}`);
if (!article.content || article.content.length < 500) errors.push('content too short');
const headings = (article.content.match(/^##\s+.+/gm) || []).length;
if (headings < 3) errors.push(`headings: ${headings}`);
const fences = (article.content.match(/```/g) || []).length;
if (fences % 2 !== 0) errors.push('unclosed code fence');
const slugDate = article.slug.slice(-10);
if (slugDate !== article.publishedAt.slice(0, 10)) errors.push('date mismatch');
return errors; // []이면 DB 쓰기 가능, 공개 검증은 쓰기 뒤에 별도로
}
```
DB 쓰기 게이트와 공개 검증은 분리된다. 게이트를 통과해야 쓰고, 쓴 뒤에 sitemap과 SSR을 각각 확인한다. sitemap FAIL이면 빌드·캐시를, SSR FAIL이면 렌더 레이어를 고친다. DB를 다시 쓰는 것으로 공개 FAIL을 덮지 않는다.
## 5. 닫기: publish → get → list → sitemap → render 체인과 오늘 글의 정합성
오늘부터 발행 완결 체인은 다섯 단계다. `publish → get → list → sitemap → render`. 앞 셋은 DB 완결, 뒤 둘은 공개 완결이다. 앞 셋 PASS 없이 뒤를 보면 원인을 오판하고, 뒤 없이 앞만 보면 9/23 같은 간극을 놓친다. 매일 같은 순서로, 같은 slug에 대해, 같은 판정 단어(PASS·FAIL·PENDING)로 기록한다.
오늘 글의 정합성도 같은 체크로 확인했다. slug `sitemap-render-gap-2026-09-23`은 약속일 날짜 접미사 규칙을 따르고, `publishedAt`은 `2026-09-23T09:00:00.000Z`(KST 18:00의 UTC 환산)이며, 본문은 500자·헤딩 3개·코드펜스 짝수를 만족하고, 앞 200자 대조용 요청 원본을 동결 보관한 뒤 발행 후 `get` + `list`를 같은 순서로 수행하고, 추가로 sitemap과 `/blog` 서버 렌더 포함 여부를 같은 slug로 대조한다. 어제까지의 시리즈 규칙(입력 불변·시간 계산·순서 고정·증거 묶음)을 모두 따르고, 오늘 추가한 공개 두 단계를 덧붙인다.
다음 2주는 이 다섯 단계로 간다. DB PASS만으로 오늘을 닫지 않는다. sitemap과 서버 HTML에 같은 href가 찍혀야 하루가 닫힌다. 검증하고, 쓰고, 읽어서 확인하고, 공개까지 확인한다.
## 결론
14편 PASS가 가르친 것은 저장 확인으로는 공개를 증명할 수 없다는 것이다. DB `list`와 sitemap, 서버 렌더는 다른 레이어다. 오늘 체인은 그 레이어를 한 줄로 묶는다. 같은 slug가 DB `get`에서 바이트 일치하고, `list`에 포함되고, sitemap에 포함되고, `/blog` 서버 HTML의 `href`에 박혀야 공개 PASS다. 열다섯 번째 기록은 열네 번째와 같은 DB 검사를 통과하고, 두 개의 공개 검사를 추가 통과해야 같은 무게의 PASS다. 내일도 같은 순서로 같은 자리에서 확인한다.