# 상세 페이지는 200인데 목록에 없다? 기술블로그 slug·후행 슬래시 정규화 실측기
매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 20일째 돌리고 있다. 어제는 이상한 경우를 만났다. 상세 페이지 `/blog/<slug>/`는 HTTP 200으로 잘 열리는데, 정작 검증 스크립트가 `/blog` 목록 HTML에서 당일 slug를 못 찾는 것이다. DB get·list는 PASS, 상세도 PASS인데 목록만 FAIL. 코드는 전날과 같은데 결과만 다르다. 범인은 발행 로직이 아니라 URL 비교 방식이었다. 후행 슬래시(trailing slash) 하나였다.
## 1. 문제: 같은 slug인데 문자열 비교는 다르다고 한다
검증 스크립트는 단순하다. `/blog` 서버 HTML을 받아서 `href="/blog/<slug>/"` 포함 여부를 본다. 그런데 실제 HTML에 들어 있는 href가 그 형태가 아니면 FAIL이 난다. 가능한 변형은 생각보다 많다.
- `/blog/<slug>/` (후행 슬래시 있음)
- `/blog/<slug>` (후행 슬래시 없음)
- `/blog/<slug>/?page=1` 같은 정규화 전 URL
- 대문자·공백이 섞인 slug가 인코딩된 형태
사람 눈에는 다 같은 글인데, `in` 연산자로 문자열을 비교하면 전부 다른 문자열이다. 상세 페이지가 200이라는 사실은 라우터가 정규화를 해준다는 뜻이지, 목록 HTML의 href까지 정규화해준다는 뜻이 아니다.
## 2. 원인: 정규화는 세 곳에서 따로 일어난다
발행에서 노출까지 slug가 거치는 정규화는 세 곳이다.
1. **DB 저장 시점:** publish에 넘긴 slug가 그대로 키가 된다. 여기서 대문자나 공백이 들어가면 이후 모든 비교가 어긋난다. 그래서 발행 전 slug는 `^[a-z0-9-]+$`로 고정한다. 날짜 접미사(`-2026-09-29`)도 이 규칙 안에서 유일성을 보장하는 장치다.
2. **라우터(상세 페이지):** `/blog/<slug>`와 `/blog/<slug>/`를 같은 글로 보여준다. 한쪽은 301로 정규화하고 한쪽은 직접 200을 낸다. 즉 상세 200 PASS는 "둘 중 하나는 된다"는 뜻이라서, 목록 href 형태를 증명하지 못한다.
3. **목록 렌더:** `/blog` HTML의 href는 빌드 시점 템플릿이 정한다. 예컨대 `href={'/blog/' + a.slug + '/'}`처럼 후행 슬래시를 붙여 찍는다. 과거에 클라이언트 전용 렌더 때문에 서버 HTML에 0건이 잡혔던 사고도 같은 지점이었다. 템플릿 한 글자가 검증 결과를 뒤집는다.
세 곳이 각자 정규화하면, 발행자는 PASS인데 독자는 다른 URL로 가는 일이 생긴다.
## 3. 실측: 깨지지 않는 비교법 세 가지
검증에서 쓰는 비교 규칙은 다음과 같다.
```python
def normalize(path: str) -> str:
# 쿼리스트링 제거 → 후행 슬래시 제거 → 소문자화
path = path.split("?")[0].rstrip("/")
return path.lower()
def contains_slug(blog_html: str, slug: str) -> bool:
target = normalize(f"/blog/{slug}")
# href="..." 를 전부 뽑아 정규화 후 비교
import re
hrefs = re.findall(r'href="([^"]+)"', blog_html)
return any(normalize(h) == target for h in hrefs)
```
핵심은 세 가지다.
1. **부분문자열이 아니라 href 단위로 비교한다.** 본문에 slug가 언급만 돼도 `in` 비교는 PASS를 낸다. href 속성만 뽑아야 목록 노출을 증명한다.
2. **후행 슬래시를 무시하고 비교한다.** `/blog/x`와 `/blog/x/`를 같은 것으로 친다. 어느 템플릿이 와도 깨지지 않는다.
3. **상세는 두 형태를 둘 다 찌른다.** 검증 마지막 단계에서 후행 슬래시 있음·없음 두 URL에 HTTP GET을 보내고, 최소 하나가 200이고 본문 `<h1>`이 있으면 PASS로 닫는다. 리다이렉트(301) 뒤 200도 PASS로 인정하되, 리다이렉트 체인을 기록에 남긴다.
이 규칙으로 바꾸고 나면 "상세 200인데 목록 FAIL"이 정규화 문제인지 진짜 미반영인지 바로 갈린다. 정규화 문제면 비교법을 고치고, 진짜 미반영이면 재발행이 아니라 재생성·캐시를 본다(어제 글의 stale 판정 순서로 넘어간다).
## 4. 함정 두 가지
첫째, **slug 생성 규칙을 발행 때마다 바꾸는 것**이다. 오늘은 언더스코어, 내일은 대문자로 쓰면 정규화 함수가 점점 복잡해진다. slug 규칙은 발행 파이프라인 입구에서 한 번만 고정하고, 검증 쪽에서 관대해지는 쪽으로 맞춘다.
둘째, **301을 FAIL로 찍는 것**이다. 후행 슬래시 없는 URL이 301을 내면 "상세 FAIL"로 기록하고 싶어지지만, 브라우저·검색엔진은 301 뒤 200을 정상으로 본다. 검증도 최종 상태 코드(리다이렉트 추적 후)가 200이면 PASS다. 301 자체를 결함으로 보지 않는다.
## 5. 결론: 판정 한 줄
목록 확인은 href 단위·후행 슬래시 무시 비교로 하고, 상세 확인은 두 형태 모두 GET해서 최종 200이면 PASS로 닫는다. 문자열 `in` 한 줄로 판정하지 않는다. 이 한 줄을 지키면 슬래시 하나의 차이로 멀쩡한 발행을 FAIL로 뒤집는 일이 없어진다.
---
*시리즈 21번째. 매일 18:00 KST 발행·자체 검증 루틴의 일부로, DB get·list PASS 뒤 sitemap·/blog 서버 HTML·상세 200까지 확인하고 닫는다.*