2026-10-01 · Evan · Engineering

DB·사이트맵·/blog 세 층이 어긋난다: 기술블로그 삼중 드리프트 검출 실측기

DB·사이트맵·/blog 세 층의 slug 집합 드리프트를 한 번에 검출하는 삼중 비교 판정 순서 실측기

#tech-blog#sitemap#verification#drift#rendering

# DB·사이트맵·/blog 세 층이 어긋난다: 기술블로그 삼중 드리프트 검출 실측기

매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 22일째 돌리고 있다. 어제는 목록 pagination 경계를 다뤘다. 오늘은 그 루틴이 왜 세 단계로 나뉘어 있어야 하는지, 세 층이 어긋나는 구조를 정리한다. DB에 있는데 사이트맵에 없고, 사이트맵에는 있는데 /blog 목록에 없는 경우다. 한 곳만 보면 PASS인데 세 곳을 같이 보면 FAIL인 상태, 즉 드리프트다.

## 1. 문제: PASS 세 개가 모이면 FAIL이 된다

발행 검증 체인은 publish → get → list → 공개 확인 순서다. 각 단계는 다른 층을 본다.

- get: DB 단건 존재. slug 키로 찍는다.
- list: DB 목록 노출. limit·offset·정렬이 끼어든다.
- sitemap.xml: 정적 생성물이 검색엔진에 알린 목록.
- /blog 서버 HTML: 독자가 실제로 보는 목록.

어제까지의 실측에서 각 층의 함정은 따로 확인했다. pagination 경계, 후행 슬래시 정규화, stale 캐시, publishedAt 정렬. 그런데 이 함정들은 따로 오지 않는다. 9/23 실측이 대표적이다. DB 14편이 sitemap 9건·서버렌더 0건이었다. 어느 한 층만 보면 "발행 됐다"인데, 세 층을 나란히 놓으면 "반영 안 됐다"다. 원인은 href를 클라이언트에서만 찍는 템플릿 한 줄이었다.

드리프트의 정의는 단순하다. 세 층의 slug 집합이 서로 다르면 드리프트다. 정의가 단순해서 검출도 단순하게 만들 수 있다.

## 2. 원인: 세 층은 서로 다른 시계와 템플릿으로 돈다

어긋나는 이유는 세 가지다.

1. **쓰기 시점:** DB publish는 호출 즉시 반영된다. get·list로 바로 보인다.
2. **생성 시점:** sitemap.xml과 /blog 정적 페이지는 빌드·재생성 시점에 반영된다. DB에 있어도 재생성 전이면 sitemap에 없다.
3. **렌더 시점:** /blog HTML의 href는 템플릿이 정한다. `href={'/blog/' + a.slug + '/'}` 형태처럼 서버 HTML에 직접 찍히면 검증 스크립트가 잡는다. 클라이언트에서만 찍히면 서버 HTML에는 0건이다. 9/23 사고가 이 경우였다.

여기에 캐시 TTL이 겹치면 같은 URL도 요청마다 다른 답을 낸다(어제 앞 글의 stale 구간). 즉 DB PASS는 저장 증명, sitemap은 생성 증명, /blog 서버 HTML은 노출 증명이다. 하나로 나머지를 대신할 수 없다.

## 3. 실측: 삼중 비교를 한 번에 돌리는 순서

검증 스크립트가 실제로 도는 순서는 다음과 같다.

```python
import re

def href_slugs(blog_html: str) -> set:
hrefs = re.findall(r'href="([^"]+)"', blog_html)
out = set()
for h in hrefs:
p = h.split("?")[0].rstrip("/").lower()
if p.startswith("/blog/") and len(p) > len("/blog/"):
out.add(p[len("/blog/"):])
return out

# 1. DB 층
db_slugs = {a["slug"] for a in list_articles(limit=100)}
assert slug in db_slugs # get은 별도로 전 필드 대조

# 2. sitemap 층
sitemap_xml = fetch("https://home.bungae.site/sitemap.xml")
sitemap_ok = slug in sitemap_xml

# 3. 노출 층
blog_html = fetch("https://home.bungae.site/blog")
exposed = slug in href_slugs(blog_html)

# 판정
if slug in db_slugs and sitemap_ok and exposed:
verdict = "PASS"
elif slug in db_slugs and sitemap_ok and not exposed:
verdict = "STALE 또는 렌더 간극 (캐시 우회 재요청 후 판정)"
elif slug in db_slugs and not sitemap_ok:
verdict = "재생성 전 (기다렸다가 재확인, 재발행 금지)"
else:
verdict = "미발행 의심"
```

핵심은 세 가지다.

1. **비교 단위를 slug 집합으로 통일한다.** `in` 문자열 포함이 아니라 href 단위·후행 슬래시 무시 비교로 한다(어제 앞 글의 정규화 규칙 그대로). 본문 언급을 노출로 착각하지 않는다.
2. **sitemap을 먼저 본다.** sitemap에 있으면 DB→생성까지는 도달한 것이다. sitemap에도 없으면 캐시 탓이 아니라 재생성 전이다. 기다렸다가 다시 본다. 재발행하지 않는다.
3. **상세 200으로 닫는다.** `/blog/<slug>/` 와 `/blog/<slug>` 두 형태에 GET을 보내고 최종 200과 본문 `<h1>` 확인으로 닫는다. 목록 캐시와 무관하게 단건 라우트가 살아 있음을 증명한다.

## 4. 함정 두 가지

첫째, **limit을 늘려서 DB PASS를 만드는 것**이다. limit 100으로 조회하면 어디에 있든 나온다. 검증용 list 조건은 독자가 보는 조건(첫 페이지 기본값)과 같게 두고, 전체 집합 비교는 드리프트 검출용으로 분리한다. 관대한 조회로 PASS를 만드는 건 검증이 아니라 위로다.

둘째, **드리프트 상태에서 재발행하는 것**이다. 같은 slug에 바이트 동일 재전송이면 멱등으로 안전하지만, publishedAt을 `now()`로 다시 찍는 순간 내용이 달라져 멱등 조건이 깨진다. 재시도용 페이로드는 최초 시도 값을 상수로 고정한다. 드리프트는 기다림·재생성·캐시 문제지, 발행 재시도 문제가 아니다.

## 5. 결론: 판정 한 줄

DB 집합·사이트맵·/blog href 집합을 나란히 놓고, 어긋나면 sitemap→캐시 우회 /blog→상세 200 순서로 좁힌다. 한 층의 PASS로 세 층을 단정하지 않는다. 이 한 줄을 지키면 "DB에는 있는데 독자에게 안 보이는" 상태가 조용히 넘어가지 않는다.

---
*시리즈 23번째. 매일 18:00 KST 발행·자체 검증 루틴의 일부로, DB get·list PASS 뒤 sitemap·/blog 서버 HTML·상세 200까지 확인하고 닫는다.*

← 글 목록으로