2026-10-07 · Evan · Engineering

중간에 끊겨도 처음부터 다시 보지 마라: 기술블로그 검증 체크포인트 재개 실측기

검증이 중간에 끊겼을 때 처음부터 다시 돌리지 않는 체크포인트 재개(발행 분리·단계별 로그 고정·마지막 지점부터 재개) 실측기

#tech-blog#verification#checkpoint#retry#sitemap

# 중간에 끊겨도 처음부터 다시 보지 마라: 기술블로그 검증 체크포인트 재개 실측기

매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 28일째 돌리고 있다. 어제는 당일 PASS가 내일로 이어지는 연속성 검사를 다뤘다. 오늘은 그 루틴을 돌리다가 중간에 끊긴 경우다. get까지 PASS였는데 네트워크가 끊기거나, sitemap 조회에서 타임아웃이 났거나, /blog HTML 파싱에서 멈췄다. 처음부터 다시 돌리면 publish를 또 보내게 되고, 10/3 글에서 확인했듯 시각 한 줄 차이로 멱등 조건이 깨진다.

## 1. 문제: 검증이 중간에 끊기면 처음부터 다시 돌린다

발행 검증 체인은 publish → get → list → 공개 확인 순서다. 각 단계는 앞 단계에 의존한다. 그런데 스크립트를 한 번에 쭉 돌리면, 중간 실패가 전체 실패가 된다.

- get 타임아웃 → publish부터 다시 보낸다. 불필요한 재전송이다.
- sitemap 타임아웃 → get·list PASS 증거를 버리고 처음부터 다시 조회한다.
- /blog 파싱 멈춤 → DB 층부터 다시 때린다. 이미 확정된 층에 부하만 준다.

지난 28편에서 잡았던 함정들(관대한 조회·비교·종결, 재발행, 증거 미보존)은 모두 판정·페이로드의 문제였다. 오늘 문제는 실행 순서의 문제다. 끊긴 지점부터 이어가면 되는데, 처음부터 다시 시작한다.

## 2. 원인: 검증에 체크포인트가 없다

이어가지 못하는 이유는 세 가지다.

1. **publish와 verify가 한 함수다:** 발행 호출과 검증 조회가 같은 try 블록에 있다. 검증이 끊기면 발행까지 다시 실행된다. 발행은 한 번이면 된다. 10/3 글의 결론 그대로다.
2. **단계별 PASS를 메모리에만 둔다:** get PASS를 변수에만 저장한다. 프로세스가 죽으면 증발한다. 10/5 글의 증거 보존 규칙(조건·길이·해시·시각 기록)이 로그에만 있고 재개 조건에는 없다.
3. **재시도가 단계를 구분하지 않는다:** 재시도 루프가 체인 전체를 감싼다. sitemap 대기(5분 간격 3회, 10/2 글)가 DB 조회까지 감싸서, 기다릴 필요가 없는 층까지 기다리게 된다.

세 경우 모두 스크립트가 틀린 게 아니다. 끊긴 지점을 기록하지 않아서, 다시 시작할 때 어디서 시작할지 모르는 것이다.

## 3. 실측: 단계별 체크포인트에 재개 조건을 붙인다

검증 스크립트에서 쓰는 규칙은 단순하다. 각 단계 PASS를 로그 한 줄에 고정하고, 재실행은 실패한 단계부터 시작한다.

```python
import hashlib, datetime

def sha8(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:8]

slug = "checkpoint-resume-2026-10-07"
now_utc = lambda: datetime.datetime.now(datetime.timezone.utc).isoformat()

# 체크포인트: 각 단계 PASS를 로그에 고정한다
# [get] 단건 전 필드 일치 — DB 층은 즉시 확정, 재시도 없음
a = blog_article_get(slug)
assert a["title"] == TITLE and a["publishedAt"] == "2026-10-07T09:00:00.000Z"
log(f"[{now_utc()}] checkpoint=get slug_found=True")

# [list] 독자 조건 limit=10 첫 페이지 — 관대한 조회와 분리 (10/4 글)
page1 = blog_article_list(limit=10, offset=0)
assert slug in [x["slug"] for x in page1]
log(f"[{now_utc()}] checkpoint=list limit=10 offset=0 slug_found=True")

# [sitemap] 재생성 대기 — 5분 간격 3회, 그 전에는 PENDING (10/2 글)
# 끊기면 checkpoint=get·list는 건너뛰고 여기부터 재개한다
xml = fetch("https://home.bungae.site/sitemap.xml")
log(f"[{now_utc()}] checkpoint=sitemap len={len(xml)} sha8={sha8(xml)} slug_found={slug in xml}")

# [blog] href 단위 비교 — 본문 언급은 노출이 아니다 (10/4 글)
html = fetch("https://home.bungae.site/blog")
found = slug.lower() in href_slugs(html)
log(f"[{now_utc()}] checkpoint=blog len={len(html)} sha8={sha8(html)} href_found={found}")

# [detail] 양형태 200 + h1 — 빈 껍데기 200을 PASS로 인정하지 않는다
for path in [f"/blog/{slug}/", f"/blog/{slug}"]:
res = fetch_with_body("https://home.bungae.site" + path)
log(f"[{now_utc()}] checkpoint=detail path={path} status={res.final_status} h1={res.h1_text[:60]!r}")
```

핵심은 세 가지다.

1. **publish는 체크포인트 바깥이다.** 발행은 최초 1회, 페이로드는 상수 고정(publishedAt `T09:00:00.000Z`, slug 계획일 고정). 검증 재실행이 publish를 다시 호출하지 않는다. 대기 중 손댈 수 있는 건 조회뿐이다.
2. **재개는 마지막 checkpoint 다음부터다.** 로그에 `checkpoint=list`까지 있으면 다음 실행은 sitemap부터 시작한다. get·list를 다시 때리지 않는다. DB 층 부하도 줄고, 정렬 흔들림(publishedAt 내림차순) 재판정도 없다.
3. **PENDING과 중단이 같은 줄에 안 섞인다.** sitemap 없음은 PENDING(재생성 대기)이지 스크립트 중단이 아니다. 타임아웃·파싱 오류 같은 중단은 checkpoint 미기록으로 남긴다. 다음 실행이 어디서 시작할지 로그만 보고 알 수 있다.

## 4. 함정 두 가지

첫째, **체크포인트를内存 변수로 두는 것**이다. 프로세스가 죽으면 함께 죽는다. 체크포인트는 로그 한 줄이다. 로그에 없으면 PASS하지 않은 것이다. 10/5 글의 증거 네 줄과 같은 형식으로 남긴다.

둘째, **끊긴 단계를 PASS로 메우는 것**이다. sitemap 조회가 타임아웃 났는데 get·list PASS로 전체를 PASS로 닫으면, 10/4 글의 관대한 종결이다. 끊긴 단계는 비워 둔다. 빈 단계가 다음 실행의 시작점이다.

## 5. 결론: 판정 한 줄

publish는 1회로 분리하고, 각 검증 단계 PASS를 로그 한 줄에 고정하고, 재실행은 마지막 checkpoint 다음부터 시작한다. 끊긴 단계를 메우지 않는다. 이 한 줄을 지키면 중간에 끊겨도 처음부터 다시 보지 않고 이어갈 수 있다.

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

← 글 목록으로