2026-10-04 · Evan · Engineering

검증이 PASS라는데 독자에게 안 보인다: 기술블로그 거짓 PASS 세 가지 실측기

검증 스크립트가 PASS를 찍고도 실제 노출이 안 되는 세 가지 거짓 PASS(관대한 조회·비교·종결)와 이를 막는 고정 검증 규칙 실측기

#tech-blog#verification#false-positive#sitemap#rendering

# 검증이 PASS라는데 독자에게 안 보인다: 기술블로그 거짓 PASS 세 가지 실측기

매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 25일째 돌리고 있다. 어제는 대기 중 재발행이 멱등 조건을 깨는 구조를 다뤘다. 오늘은 그 반대쪽 실수다. 검증 스크립트가 PASS를 찍었는데 실제로는 반영이 안 된 경우다. 스크립트는 거짓말을 하지 않는다. 다만 묻지 않은 것은 대답해주지 않는다.

## 1. 문제: PASS인데 공개가 안 됐다

발행 검증 체인은 publish → get → list → 공개 확인 순서다. 각 단계가 PASS를 내는 조건은 다르다. 문제는 각 조건이 생각보다 관대하다는 점이다.

- get PASS: slug 키로 단건이 나온다. 위치·정렬·노출과 무관하다.
- list PASS: limit을 넓히면 어디에 있든 나온다. 독자가 보는 첫 페이지 조건과 다를 수 있다.
- sitemap PASS: 문자열 `in` 비교면 본문 언급만으로 통과한다.
- 상세 PASS: 리다이렉트 추적 없이 상태 코드만 보면 301을 FAIL로 오판하거나, 반대로 빈 200을 PASS로 인정한다.

지난 25편에서 하나씩 잡았던 함정들을 한 곳에 모으면, 거짓 PASS는 세 가지 패턴으로 수렴한다. 관대한 조회, 관대한 비교, 관대한 종결이다.

## 2. 원인: 검증이 묻지 않은 것들

거짓 PASS의 원인은 세 가지다.

1. **관대한 조회:** 검증용 limit 100으로 조회하면 된다. 그런데 독자가 보는 건 첫 페이지 기본값(limit 10)이다. 9/30 글의 pagination 경계 그대로다. 관대한 조건으로 PASS를 만들면 실제 노출을 증명하지 못한다.
2. **관대한 비교:** `/blog` HTML에서 `slug in html`로 보면 본문 언급·메타태그·스크립트 내 문자열까지 전부 PASS가 된다. 9/29 글에서 href 단위 비교로 바꾼 이유다. 목록 노출 증명은 href 속성에서만 나온다.
3. **관대한 종결:** 상세 페이지에서 상태 코드 200만 보고 닫으면 빈 껍데기 200도 PASS가 된다. 라우트는 살아 있는데 본문이 비어 있는 경우다. `<h1>`에 제목이 들어 있는지까지 봐야 종결이다.

세 경우 모두 스크립트 버그가 아니다. 검증 조건이 증명하려는 대상보다 넓은 것이다.

## 3. 실측: 거짓 PASS를 막는 세 가지 고정 규칙

검증 스크립트에서 쓰는 규칙은 다음과 같다.

```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

slug = "false-positive-guard-2026-10-04"

# 1. list는 독자 조건으로 판정한다 (관대한 조회를 분리)
page1 = blog_article_list(limit=10, offset=0)
assert slug in [a["slug"] for a in page1], "첫 페이지 노출로 판정"
full = blog_article_list(limit=100, offset=0) # 드리프트 검출용으로만 사용
assert slug in [a["slug"] for a in full]

# 2. /blog는 href 단위·후행 슬래시 무시 비교로 판정한다
blog_html = fetch("https://home.bungae.site/blog")
assert slug.lower() in href_slugs(blog_html), "본문 언급은 노출이 아니다"

# 3. 상세는 상태 코드 + 본문 제목으로 닫는다
for path in [f"/blog/{slug}/", f"/blog/{slug}"]:
res = fetch_with_body("https://home.bungae.site" + path)
assert res.final_status == 200 and expected_title in res.h1_text
```

핵심은 세 가지다.

1. **검증용 조회와 진단용 조회를 분리한다.** PASS 판정은 독자 조건(limit 10 첫 페이지, 일반 /blog 요청)으로만 낸다. limit 100·`?nocache=` 우회 조회는 원인 진단용이다. 진단용 PASS를 완결 PASS로 올리지 않는다. 10/2 글의 우회 조회 결론 그대로다.
2. **비교 단위를 증명 대상에 맞춘다.** 목록 노출이면 href 집합, sitemap이면 url 집합, 상세면 최종 상태+본문이다. 문자열 포함 한 줄로 세 층을 퉁치지 않는다. 10/1 글의 삼중 비교 순서로 좁힌다.
3. **publishedAt·slug는 고정값으로 대조한다.** `publishedAt`은 KST 18:00의 UTC 환산 고정값(`T09:00:00.000Z`)과 바이트 대조하고, slug는 `^[a-z0-9-]+$` 규칙으로 둔다. 시각·slug가 흔들리면 위치 판정 자체가 흔들린다.

## 4. 함정 두 가지

첫째, **통과율을 올리기 위해 조건을 넓히는 것**이다. 연속 PASS 기록이 길어지면(지금 25일째) 검증이 의식으로 굳는다. limit을 늘리고, 비교를 느슨하게 하고, 상세 본문 확인을 생략하면 매일 PASS가 나온다. 그런데 그 PASS는 발행 파이프라인의 건강이 아니라 검증의 관대함을 측정한 것이다.

둘째, **DB PASS를 공개 PASS로 읽는 것**이다. get·list는 저장 증명이다. sitemap은 생성 증명, /blog 서버 HTML은 노출 증명이다. 9/23 실측(DB 14편·sitemap 9건·서버렌더 0건)이 이 구분을 만든 이유다. DB 층 PASS 뒤에는 반드시 공개 층 조회가 따라와야 한다.

## 5. 결론: 판정 한 줄

PASS 판정은 독자 조건으로만 내고, 비교는 href·본문 단위로 좁히고, 상세는 상태 코드에 본문 제목 확인을 붙여 닫는다. 관대한 조회로 PASS를 만들지 않는다. 이 한 줄을 지키면 스크립트는 매일 PASS를 찍으면서 독자에게는 안 보이는 상태가 조용히 넘어가지 않는다.

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

← 글 목록으로