2026-10-05 · Evan · Engineering

로그는 남았는데 재현이 안 된다: 기술블로그 검증 증거 보존 실측기

판정은 남고 근거가 사라지는 문제를 막는 검증 증거 보존(조건·길이·해시·시각 기록과 우회 표식) 실측기

#tech-blog#verification#evidence#sitemap#logging

# 로그는 남았는데 재현이 안 된다: 기술블로그 검증 증거 보존 실측기

매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 26일째 돌리고 있다. 어제는 검증 스크립트의 거짓 PASS 세 가지 패턴을 다뤘다. 오늘은 그 다음 질문이다. PASS·PENDING·FAIL 판정은 남았는데, 다음날 다시 보면 그 판정의 근거가 사라진 경우다. 로그에는 "sitemap PASS"라고 적혀 있는데 sitemap 원문도, /blog HTML도, 해시도 없다. 판정은 남고 증거가 없으면, 그 판정은 믿을 수 없다.

## 1. 문제: 판정은 남고 근거가 사라진다

발행 검증 체인은 publish → get → list → 공개 확인 순서다. 각 단계의 판정은 순간값이다. sitemap.xml은 재생성되면 바뀌고, /blog HTML은 캐시 TTL이 지나면 바뀐다. 어제 PASS였던 sitemap이 오늘 다시 보면 달라져 있다. 문제는 검증 로그가 판정 한 줄만 남기고 원문을 버릴 때다.

- sitemap PASS: 어느 URL 집합에서 slug를 찾았는지 원문이 없다.
- /blog PASS: href 집합 추출 전의 HTML 원문이 없다.
- 상세 PASS: 상태 코드만 있고 `<h1>` 텍스트 스냅샷이 없다.
- list PASS: limit·offset·시각이 없이 "첫 페이지 최상단"이라는 말만 있다.

지난 26편에서 잡았던 함정들(관대한 조회·비교·종결)은 모두 판정 조건의 문제였다. 오늘 문제는 판정 이후의 문제다. 조건이 맞아도 증거를 남기지 않으면, 다음날 드리프트가 생겼을 때 어제 PASS가 진짜였는지 가릴 방법이 없다.

## 2. 원인: 검증을 메모리로 돌린다

증거가 사라지는 이유는 세 가지다.

1. **원문을 저장하지 않는다:** `slug in xml` 같은 불리언만 로그에 남긴다. sitemap 원문 길이라도 없으면, 나중에 "그때는 있었는데"라는 말과 구별이 안 된다.
2. **요청 조건을 기록하지 않는다:** limit·offset·URL·쿼리 파라미터(`?nocache=` 여부)를 안 적는다. 우회 조회 PASS였는지 일반 조회 PASS였는지 다음날 알 수 없다. 10/2 글의 우회 진단과 완결 PASS 구분이 무너진다.
3. **시각을 섞는다:** 검증 시각을 KST·UTC·실행 시각으로 뒤섞어 적는다. publishedAt(`T09:00:00.000Z` 고정)과 조회 시각이 같은 열에 있으면 정렬 분석이 깨진다. 9/30 글의 pagination 경계 분석이 흔들린다.

세 경우 모두 스크립트가 잘못 뜬 게 아니다. 사람이 다음날 다시 읽기 위한 최소 정보를 안 남긴 것이다.

## 3. 실측: 판정 한 줄당 증거 네 줄을 남긴다

검증 스크립트에서 쓰는 규칙은 단순하다. 판정 한 줄마다 원문 위치·조건·해시를 같이 적는다.

```python
import hashlib, datetime

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

slug = "evidence-preservation-2026-10-05"
now_utc = datetime.datetime.now(datetime.timezone.utc).isoformat()

# 1. DB 층: 조건 그대로 기록한다
page1 = blog_article_list(limit=10, offset=0)
db_ok = slug in [a["slug"] for a in page1]
log(f"[{now_utc}] list limit=10 offset=0 count={len(page1)} slug_found={db_ok}")

# 2. sitemap 층: 원문 길이 + 해시 + URL을 남긴다
xml = fetch("https://home.bungae.site/sitemap.xml")
log(f"[{now_utc}] sitemap len={len(xml)} sha8={sha8(xml)} slug_found={slug in xml}")

# 3. 노출 층: HTML 길이 + 해시 + href 판정을 남긴다
html = fetch("https://home.bungae.site/blog")
found = slug.lower() in href_slugs(html)
log(f"[{now_utc}] blog len={len(html)} sha8={sha8(html)} href_found={found}")

# 4. 상세 층: 최종 상태 + h1 스냅샷을 남긴다
for path in [f"/blog/{slug}/", f"/blog/{slug}"]:
res = fetch_with_body("https://home.bungae.site" + path)
log(f"[{now_utc}] detail path={path} status={res.final_status} h1={res.h1_text[:60]!r}")
```

핵심은 세 가지다.

1. **원문 대신 해시를 남긴다.** HTML·XML 전체를 로그에 붙이지 않는다. 길이 + sha 앞 8자리면 충분하다. 같은 해시는 같은 원문, 다른 해시는 바뀐 원문이다. 드리프트 검출(10/1 글)이 다음날 해시 비교 한 줄로 된다.
2. **우회 조회는 표식을 붙인다.** `?nocache=` 조회면 로그에 `bypass=true`를 적는다. 우회 PASS를 완결 PASS 줄에 합치지 않는다. 10/2 글의 stale 진단이 다음날에도 진단으로 읽힌다.
3. **publishedAt과 조회 시각을 분리한다.** 발행 시각 고정값(`2026-10-05T09:00:00.000Z`)은 페이로드 상수 줄에, 조회 시각(UTC ISO)은 로그 접두에 둔다. 두 시각이 한 줄에 섞이지 않게 한다.

## 4. 함정 두 가지

첫째, **원문 전체를 로그에 붙이는 것**이다. HTML 몇십 KB를 매일 로그에 쌓으면 읽을 수 없는 로그가 된다. 필요할 때 원문을 다시 받을 수 있게 URL·시각·해시만 남기고, 원문 저장은 실패 케이스로 한정한다. 증거 보존은 전부 저장이 아니라 다시 찾을 수 있게 남기는 것이다.

둘째, **PASS일 때만 증거를 생략하는 것**이다. FAIL일 때만 원문을 남기면 PASS의 근거가 비어서, 다음날 FAIL이 생겼을 때 어제와의 diff가 안 나온다. PASS·PENDING·FAIL 모두 같은 형식 네 줄을 남긴다. 형식이 같아야 해시 비교가 된다.

## 5. 결론: 판정 한 줄

판정 한 줄당 조건·길이·해시·시각 네 줄을 같은 형식으로 남기고, 우회 조회는 표식을 붙인다. 원문 전체는 FAIL 케이스로 한정한다. 이 한 줄을 지키면 다음날 드리프트가 생겨도 어제 PASS가 진짜였는지 해시 한 번으로 가릴 수 있다.

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

← 글 목록으로