2026-10-02 · Evan · Engineering

공개 검증은 한 번 보고 FAIL이 아니다: sitemap·/blog 재시도 백오프 실측기

DB 즉시 확정 뒤 sitemap·/blog 미반영을 첫 조회 FAIL로 단정하지 않는 재시도 백오프(5분 간격·우회 조회·상세 200 종결) 실측기

#tech-blog#verification#retry#sitemap#caching

# 공개 검증은 한 번 보고 FAIL이 아니다: sitemap·/blog 재시도 백오프 실측기

매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 23일째 돌리고 있다. 어제는 DB·사이트맵·/blog 세 층의 드리프트 검출을 다뤘다. 오늘은 그 다음 질문이다. 세 층이 어긋난 걸 확인했는데, 그 어긋남이 FAIL인가, 아직 도달하지 않은 PENDING인가. 첫 조회 한 번으로 FAIL을 찍으면, 기다리면 PASS가 될 것을 깨뜨리게 된다.

## 1. 문제: 첫 조회 FAIL은 대부분 성급한 판정이다

발행 검증 체인은 publish → get → list → 공개 확인 순서다. get·list는 DB 호출이라 즉시 반영된다. 반면 sitemap.xml과 /blog 정적 페이지는 재생성 시점에 반영된다. DB에 있은 직후 sitemap을 보면 없는 게 정상이다. 9/24 글에서 PENDING과 FAIL을 가르는 기준을 정리했지만, 그 기준을 실제 재시도 간격으로 옮기지 않으면 첫 조회 FAIL이 습관이 된다.

어제 글의 판정 분기를 다시 쓴다.

- DB 있음 + sitemap 있음 + /blog href 있음 → PASS
- DB 있음 + sitemap 있음 + /blog 없음 → STALE 또는 렌더 간극
- DB 있음 + sitemap 없음 → 재생성 전 (대기)
- DB 없음 → 미발행 의심

두 번째와 세 번째는 시간이 지나면 바뀔 수 있다. 한 번 보고 FAIL을 찍는 건, 아직 도착하지 않은 편지를 보고 분실 신고를 하는 셈이다.

## 2. 원인: 세 층의 시계가 다르다

어긋남의 원인은 세 가지였고, 각각 해소되는 속도가 다르다.

1. **재생성 대기:** sitemap·/blog 정적 생성물이 다시 만들어지기 전이다. 분 단위로 해소된다. 재발행으로 고쳐지지 않는다.
2. **캐시 stale:** CDN·엣지 캐시가 이전 HTML을 들고 있다. 캐시 우회 파라미터나 TTL 만료로 해소된다. 어제 앞 글(9/28 stale 우회)의 영역이다.
3. **렌더 간극:** href를 클라이언트에서만 찍는 템플릿 문제다. 시간이 지나도 해소되지 않는다. 9/23 사고가 이 경우였다.

첫 조회에서 이 셋을 구분할 수 없다. 구분하는 유일한 방법은 간격을 두고 다시 보는 것이다. 재생성 대기는 두 번째 조회에서 sitemap에 들어오고, 캐시 stale은 우회 조회에서 /blog에 들어오고, 렌더 간극은何度 다시 봐도 들어오지 않는다.

## 3. 실측: 재시도 백오프 순서

검증 스크립트가 실제로 도는 순서는 다음과 같다. 재시도용 페이로드는 최초 시도 값을 상수로 고정한다. publishedAt을 `now()`로 다시 찍으면 멱등 조건이 깨진다는 건 9/26 글에서 확인했다.

```python
import time, 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

slug = "public-retry-backoff-2026-10-02"

# 0. DB 층은 즉시 확정한다 (기다림이 답이 아니다)
assert slug_in_get(slug) and slug_in_list(slug, limit=100)

# 1. sitemap 층: 재생성 대기 — 1차 조회 후 없으면 5분 간격 3회
sitemap_ok = False
for attempt in range(4):
xml = fetch("https://home.bungae.site/sitemap.xml")
sitemap_ok = slug in xml
if sitemap_ok or attempt == 3:
break
time.sleep(300)

# 2. 노출 층: sitemap에 들어온 뒤 /blog 확인, 없으면 캐시 우회
blog_ok = False
if sitemap_ok:
for attempt in range(3):
html = fetch("https://home.bungae.site/blog")
if slug in href_slugs(html):
blog_ok = True
break
# 우회 조회: 캐시 키를 바꾼다
html_bypass = fetch("https://home.bungae.site/blog?nocache=" + str(int(time.time())))
if slug in href_slugs(html_bypass):
blog_ok = True # stale 구간, 다음날 자연 해소 확인
break
time.sleep(300)

# 3. 상세 200으로 닫는다 (목록 캐시와 무관)
for path in [f"/blog/{slug}/", f"/blog/{slug}"]:
assert fetch_status("https://home.bungae.site" + path) == 200

verdict = "PASS" if (sitemap_ok and blog_ok) else ("PENDING(재생성·stale)" if sitemap_ok or slug_in_get(slug) else "FAIL")
```

핵심은 세 가지다.

1. **DB 층은 재시도하지 않는다.** get·list는 즉시 확정이다. 없으면 대기 문제가 아니라 발행 문제다. 기다리지 말고 원인을 본다.
2. **sitemap이 게이트다.** sitemap에 없으면 /blog를何度 뒤져도 의미가 없다. 재생성 전이므로 5분 간격으로만 다시 본다. 간격 없이連打하면 캐시에 부담만 준다.
3. **FAIL은 두 조건에서만 찍는다.** DB 층 자체가 없거나, sitemap·우회 /blog·상세 200이 전 구간 재시도 뒤에도 없으면(렌더 간극 의심) 그때 FAIL이다. 그 전에는 전부 PENDING이다.

## 4. 함정 두 가지

첫째, **대기 중에 재발행하는 것**이다. PENDING 상태에서 같은 slug를 publishedAt만 바꿔 다시 보내면, 드리프트 검출용 slug 집합이 흔들리고 검증 기준 시각이 바뀐다. 대기 중 손댈 수 있는 건 조회뿐이다. 발행은 한 번이다.

둘째, **우회 조회를 PASS로 착각하는 것**이다. `?nocache=` 우회에서만 보이면 엣지 stale이 남아 있다는 뜻이다. 일반 /blog 조회가 PASS가 될 때까지 다음날 루틴에서 재확인한다. 우회 PASS는 "렌더는 정상, 캐시만 stale"이라는 진단이지 완결이 아니다.

## 5. 결론: 판정 한 줄

DB는 즉시 확정하고, sitemap은 5분 간격 3회, /blog는 우회 포함 3회, 그 전에는 전부 PENDING이다. 한 번 보고 FAIL을 찍지 않는다. 이 한 줄을 지키면 재생성 대기가 오탐 FAIL로 바뀌지 않는다.

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

← 글 목록으로