2026-09-30 · Evan · Engineering

get은 되는데 list 첫 페이지에 없다? 기술블로그 목록 pagination 경계 실측기

get PASS인데 list 첫 페이지에 당일 글이 없을 때 pagination 경계·정렬·공개 미반영을 가르는 위치 기반 판정 실측기

#tech-blog#pagination#sorting#verification#list-api

# get은 되는데 list 첫 페이지에 없다? 기술블로그 목록 pagination 경계 실측기

매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 21일째 돌리고 있다. 어제는 후행 슬래시 정규화 간극을 다뤘다. 오늘은 그 다음으로 조용한 함정이다. 단건 조회(get)는 PASS인데 목록(list) 첫 페이지에서 당일 slug가 안 보이는 경우다. DB에 분명히 있는데 목록에 없다. 코드를 뜯기 전에는 발행 실패로 착각하기 쉽지만, 대부분은 실패가 아니라 pagination 경계 문제다.

## 1. 문제: 존재 증명과 노출 증명은 다른 쿼리다

발행 검증 체인은 publish → get → list 순서다. get은 slug 키로 단건을 찍는다. 키만 맞으면 페이지 위치와 무관하게 나온다. 반면 list는 limit·offset으로 잘라서 본다. 기본값이 limit 10이면 11번째 글부터는 첫 페이지에 없다. 글이 22편째 쌓인 지금, 어제까지 최상단이던 글이 오늘은 한 칸 밀린다. 그 자체는 정상이다. 문제는 검증이 "첫 페이지에 있는가"만 보고 "어디에 있는가"를 안 볼 때다.

어제 글의 교훈(검증 스크립트가 `href="/blog/<slug>/"` 포함 여부만 봤다)과 겹친다. 문자열 비교 이전에, 비교 대상 페이지 자체가 당일 글을 포함할 수 없는 범위면 FAIL은 당연하다. FAIL 처리 전에 범위부터 확인해야 한다.

## 2. 원인: 정렬 + 잘라내기가 만나는 지점

list 노출을 결정하는 것은 세 가지다.

1. **정렬 키:** publishedAt 내림차순이다. 발행 시각을 KST 18:00 그대로 `T18:00:00.000Z`로 찍으면 미래 시각이 되어 순서가 어긋난다. 그래서 발행 시각은 `2026-09-30T09:00:00.000Z`처럼 UTC 환산 고정값으로 찍는다. 시각이 흔들리면 새 글이 최상단이 아니라 중간에 꽂힌다.
2. **잘라내기:** limit·offset이다. limit 10, offset 0이면 최신 10건만 본다. 당일 글이 11번째로 밀릴 일은 정렬이 정상이면 없지만, publishedAt이 어긋나면 충분히 생긴다.
3. **공개 렌더 계층:** DB list 최상단과 공개 /blog 최상단은 다른 레이어다. 9/23 실측에서 DB 14편이 sitemap 9건·서버렌더 0건이었던 것처럼, DB 위치가 공개 위치를 보장하지 않는다.

즉 "list 첫 페이지에 없다"는 세 가지 중 하나의 신호다. 정렬 오기, 범위 부족, 공개 미반영. 어느 것인지 가리지 않고 재발행하면 slug 충돌(동일 slug에 다른 내용은 실패)만 난다.

## 3. 실측: 경계 문제인지 진짜 FAIL인지 가리는 순서

검증 순서는 다음과 같다.

```python
# 1. get으로 존재 확정
article = get(slug)
assert article.title == expected.title

# 2. list는 위치로 판정 (존재가 아니라 순위)
page1 = list(limit=10, offset=0)
page2 = list(limit=10, offset=10)
if slug in [a.slug for a in page1]:
rank = "page1"
elif slug in [a.slug for a in page2]:
rank = "page2 (정렬 의심)"
else:
rank = "missing (발행·공개 의심)"
```

핵심은 두 가지다.

1. **첫 페이지 FAIL이면 두 번째 페이지를 본다.** 두 번째 페이지에 있으면 정렬(publishedAt) 문제다. 본문을 고치지 말고 시각 필드부터 대조한다.
2. **두 페이지에 다 없으면 공개 검증으로 넘어간다.** sitemap.xml에 당일 slug가 있는지, /blog 서버 HTML에 href가 직접 포함됐는지 본다. sitemap에는 있고 list에만 없으면 캐시·빌드 시차(stale)다. 둘 다 없으면 진짜 미반영이다.

판정 한 줄: page1 없음 → page2 확인 → 그래도 없음 → sitemap·서버 HTML 확인. 이 순서를 지키면 pagination 경계를 발행 실패로 오판하지 않는다.

## 4. 함정 두 가지

첫째, **limit을 늘려서 PASS 만드는 것**이다. limit 100으로 조회하면 어디에 있든 나온다. 검증은 통과하지만 독자가 보는 첫 화면은 그대로다. 검증용 limit은 독자가 보는 조건(첫 페이지 기본값)과 같게 둔다. 관대한 조회로 PASS를 만드는 건 검증이 아니라 위로다.

둘째, **위치 확인 없이 재발행하는 것**이다. 같은 slug에 바이트 동일 재전송이면 멱등으로 안전하지만, publishedAt을 `now()`로 다시 찍는 순간 내용이 달라져 멱등 조건이 깨진다. 재시도용 페이로드는 최초 시도 값을 상수로 고정한다. 시각 갱신 한 줄이 멀쩡한 재시도를 실패로 바꾼다.

## 5. 결론: 판정 한 줄

list 검증은 존재가 아니라 위치로 판정하고, 첫 페이지에 없으면 두 번째 페이지→sitemap→서버 HTML 순서로 좁힌다. 잘라내기 범위를 먼저 의심하고 발행을 나중에 의심한다. 이 한 줄을 지키면 쌓여가는 시리즈가 검증 범위를 깨뜨리는 일이 없어진다.

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

← 글 목록으로