2026-09-24 · Evan · engineering

PENDING인지 FAIL인지 먼저 가려라: 기술블로그 공개 반영 지연 판정 실측기

DB PASS 뒤 공개 0건을 PENDING과 FAIL로 가르는 지연 판정 실측기

#automation#tech-blog#sitemap#ssr

# PENDING인지 FAIL인지 먼저 가려라: 기술블로그 공개 반영 지연 판정 실측기

## 요약

기술블로그 일일 발행(DB 직접 게시 방식)의 열여섯 번째 기록이다. 주제는 공개 검증의 시간 판정이다. 어제 15편에서 발행 완결 체인을 `publish → get → list → sitemap → render` 다섯 단계로 넓혔다. 앞 셋은 DB 완결, 뒤 둘은 공개 완결이다. 그런데 뒤 둘을 발행 직후에 재면 거의 항상 0건이 나온다. 빌드·캐시·CDN이 아직 안 돌았기 때문이다. 이때 0건을 곧바로 FAIL로 쓰면 오판이다. 오늘 글은 sitemap과 `/blog` 서버 렌더의 미반영을 PENDING(기다릴 것)과 FAIL(고칠 것)로 가르는 기준을 정리한다. 매일 18:00 KST 발행 체제의 네 번째 주간 첫 기록이다.

## 1. 배경: 9/23 실측이 남긴 질문 — 0건은 지연인가 고장인가

9/23 실측 숫자를 다시 적는다. DB `list` 15편 대비가 아니라 당시 14편 기준으로, `home.bungae.site/sitemap.xml` 9건, `/blog` 서버 렌더 HTML 소스 0건이었다. 원인은 `href='/blog/'+a.slug+'/'`를 클라이언트에서만 만들던 구조였다. 서버 HTML에는 목록이 없었다.

여기서 질문이 갈라진다. 오늘이 발행한 글이 sitemap에 없다는 것은, 빌드가 아직 안 돌아서인가, 구조가 깨져서인가. `/blog` 서버 HTML에 없다는 것은, 캐시가 아직 안 비워져서인가, 애초에 서버가 목록을 안 내보내서인가. 같은 0건이라도 처방이 정반대다. 기다릴 것과 고칠 것을 구분하지 않으면, 기다리면 될 일을 고치다가 깨뜨리고, 고쳐야 할 일을 기다리다가 하루를 넘긴다.

그래서 오늘 규칙은 한 문장이다. 공개 미반영은 즉시 판정하지 않고, 시점과 함께 두 번 읽는다.

## 2. PENDING과 FAIL을 가르는 세 가지 축: 시점·범위·재현

판정에는 세 축이 필요하다.

첫째, 시점이다. 발행 직후 1회 측정과 기대 반영 시점 이후 재측정을 분리한다. 기대 반영 시점은 sitemap 빌드 주기, Pages 배포·캐시 무효화, CDN 전파를 합친 것이다. 직후 0건은 PENDING의 기본값이고, 기대 시점 이후에도 0건이면 FAIL 후보다.

둘째, 범위다. 같은 slug에 대해 `get`·`list`(DB)와 sitemap·서버 렌더(공개)를 함께 본다. DB에 없는데 공개에만 없으면 DB 문제다. DB에는 있는데 공개에만 없으면 공개 레이어 문제다. DB `get` 단건 대조(제목 바이트 일치, 본문 앞 200자 일치, 태그·카테고리 일치)와 `list` 포함 확인이 모두 PASS인데 sitemap만 빠지면 빌드 순서 문제, sitemap은 있는데 서버 렌더만 빠지면 렌더 레이어 문제로 좁혀진다.

셋째, 재현이다. 한 번의 0건으로 단정하지 않는다. 같은 URL을 같은 판정 함수로 두 번 이상 읽고, 두 번 다 0건일 때만 FAIL로 올린다. 9/12 노출 지연 사건이 가르친 것과 같다. 기다리면 해결되는 것을 실패로 기록하지 않는다. 단, 몇 번을 기다렸는지는 기록한다.

```js
function classifyPublicMiss({ dbPass, sitemapHit, ssrHit, attempt, maxAttempts }) {
if (!dbPass) return 'DB-FAIL'; // 공개를 보기 전에 DB를 먼저 본다
if (sitemapHit && ssrHit) return 'PASS';
if (attempt < maxAttempts) return 'PENDING'; // 기대 시점 전이면 기다린다
if (!sitemapHit && ssrHit) return 'FAIL-sitemap';
if (sitemapHit && !ssrHit) return 'FAIL-ssr';
return 'FAIL-public';
}

async function verifyPublicWithRetry(slug, fetchText, { retries = 3, delayMs = 60000 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
const xml = await fetchText('https://home.bungae.site/sitemap.xml');
const html = await fetchText('https://home.bungae.site/blog');
const expected = `/blog/${slug}/`;
const sitemapHit = xml.includes(expected);
const ssrHit = html.includes(expected);
if (sitemapHit && ssrHit) return { verdict: 'PASS', attempt };
if (attempt < retries) await new Promise((r) => setTimeout(r, delayMs));
}
return { verdict: 'PENDING-or-FAIL', retries };
}
```

재시도 횟수와 시점이 증거의 일부다. PENDING은 기다림의 기록이고, FAIL은 기다린 뒤의 기록이다.

## 3. sitemap PENDING 해소: 빌드 순서와 캐시를 의심한다

sitemap 미반영의 흔한 원인은 순서다. sitemap이 빌드 산출물이면, 빌드가 DB 쓰기 전에 돌았을 때 새 글이 빠진다. 9/23의 14대 9 간극이 이 패턴이다. 처방은 다시 쓰는 것이 아니라 다시 빌드하고 다시 읽는 것이다.

검증 방법은 sitemap 텍스트에서 `text.includes('/blog/'+slug+'/')`로 본다. sitemap이 분할돼 있으면 index를 따라가 합친다. 발행 직후가 아니라 기대 반영 시점(빌드·캐시·CDN 반영 후)에 재측정한다. 즉시 0건이 나와도 바로 FAIL로 단정하지 않는다.

```js
async function verifySitemapDelayed(slug, fetchText) {
const xml = await fetchText('https://home.bungae.site/sitemap.xml');
const expected = `/blog/${slug}/`;
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일 때, 저장을 넘어 수집 가능 단계까지 닫은 것이다. 그 전까지는 PENDING으로 적는다.

## 4. 서버 렌더 PENDING 해소: curl 관점으로 두 번 읽는다

`/blog` 서버 렌더 검증은 더 엄격하다. 브라우저가 아니라 `curl` 관점에서 본다. 자바스크립트 실행 없이 받은 HTML에 `href="/blog/<slug>/"`가 있어야 한다. 클라 전용 렌더면 HTML에는 없고 렌더 후에만 생긴다. 그건 SSR FAIL 후보다.

다만 여기도 즉시 단정은 금물이다. ISR·캐시 무효화·배포 전파가 끝나기 전이면 PENDING이다. 검증 방법은 서버 HTML 텍스트 검색을 두 시점에 반복하는 것이다. Playwright `page.content()`가 아니라 `fetch`로 받은 생 HTML을 본다. 두 번 다 없으면 렌더 레이어를 고친다. 근본 해결은 `/blog`를 서버에서 목록을 채워 내보내도록 하는 것이다. DB `list`를 직접 읽어 HTML에 `a[href]`를 박는 방식이다.

```js
async function verifyBlogSSRDelayed(slug, fetchText) {
const html = await fetchText('https://home.bungae.site/blog');
const href = `/blog/${slug}/`;
const ssrHit = html.includes(href);
return { href, ssrHit, verdict: ssrHit ? 'PASS' : 'PENDING-or-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 쓰기 게이트와 공개 검증은 분리된다. 게이트를 통과해야 쓰고, 쓴 뒤에 sitemap과 SSR을 각각 시점을 두고 확인한다.

## 5. 닫기: 오늘 글의 정합성과 다섯 단계 체인

오늘부터 판정 단어는 셋이 아니라 넷이다. PASS·PENDING·FAIL·BLOCKED. BLOCKED는 쓰기 전 중단, PENDING은 공개 대기, FAIL은 기다린 뒤의 고장이다. 매일 같은 slug에 대해 `publish → get → list → sitemap → render` 순서로 같은 판정 단어로 기록한다.

오늘 글의 정합성도 같은 체크로 확인했다. slug `public-pending-fail-2026-09-24`는 약속일 날짜 접미사 규칙을 따르고, `publishedAt`은 `2026-09-24T09:00:00.000Z`(KST 18:00의 UTC 환산)이며, 본문은 500자·헤딩 3개·코드펜스 짝수를 만족하고, 앞 200자 대조용 요청 원본을 동결 보관한 뒤 발행 후 `get` + `list`를 같은 순서로 수행하고, 추가로 sitemap과 `/blog` 서버 렌더 포함 여부를 같은 slug로 대조한다. 어제까지의 시리즈 규칙(입력 불변·시간 계산·순서 고정·증거 묶음)을 모두 따른다.

## 결론

0건은 판정이 아니라 질문이다. 언제 읽은 0건인지, 어디까지 읽은 0건인지, 몇 번째 0건인지를 적어야 PENDING과 FAIL이 갈린다. 기다릴 것은 기다리고, 고칠 것은 고친다. 같은 slug가 DB `get`에서 바이트 일치하고, `list`에 포함되고, sitemap에 포함되고, `/blog` 서버 HTML의 `href`에 박혀야 공개 PASS다. 열여섯 번째 기록은 열다섯 번째와 같은 DB 검사를 통과하고, 두 개의 공개 검사를 시점을 두고 통과해야 같은 무게의 PASS다. 내일도 같은 순서로 같은 자리에서 확인한다.

← 글 목록으로