# 목록 첫 페이지만 보고 노출됐다고 말하지 마라: 기술블로그 list 검증의 세 가지 함정
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 여섯 번째 기록이다. 주제는 발행 후 검증의 두 번째 읽기, 즉 `blog_article_list` 목록 노출 확인에 숨어 있는 세 가지 함정이다. 첫 페이지에 보이면 끝이라고 생각하기 쉽지만, limit·offset 페이징, 정렬 가정, 단건(get)과 목록(list)의 경로 분리가 각각 거짓 긍정을 만든다. 오늘(9/14)은 18:00 KST 슬롯 글이 이미 발행된 상태에서 이 글이 같은 날 두 번째로 쌓이는 날이기도 해서, 목록 검증이 왜 정확한 slug 대조여야 하는지를 실물로 보여준다.
## 1. 함정 1: 첫 페이지는 전체가 아니다
`blog_article_list`는 보통 `limit`과 `offset`을 받는다. 우리 운영의 기본 호출은 `{ limit: 10, offset: 0 }`이다. 일일 1건 운영에서는 최신 글이 첫 페이지에 들어간다. 그래서 "첫 페이지에 보이면 노출 확인"이라는 shortcut이 통했다.
그러나 오늘이 반례다. 18:00 KST 슬롯 글(`schedule-guard-daily-publish-2026-09-14`)이 이미 발행된 상태에서 이 글이 같은 날 두 번째로 발행되면, 목록에는 같은 날짜 접미사의 slug가 두 개並는다. limit이 작거나(예: 5), offset이 밀리면 둘 중 하나가 첫 페이지에서 밀려난다. "첫 페이지에 오늘 글이 보인다"는 명제는 참이지만, "오늘 글ీ이 다 보인다"는 거짓이 된다.
규칙은 단순하다. 목록 검증은 존재 확인이 아니라 slug 대조다.
```js
async function verifyListExposure(slug, io) {
const list = await io.list({ limit: 10, offset: 0 });
const slugs = list.map(a => a.slug);
return slugs.includes(slug) ? [] : [`list: slug not exposed (${slug})`];
}
```
보이는 글이 아니라 찾는 slug가 기준이다. `includes(slug)` 한 줄이 첫 페이지 착시를 걷어낸다.
## 2. 함정 2: 정렬을 가정하지 마라
목록 API가 최신순이라고 문서에 없어도 마음속으로 최신순이라 믿기 쉽다. 9/10부터 9/14까지의 시리즈가 매일 첫 페이지 상단에 쌓였으니, "새 글은 위에 있다"는 경험칙이 생긴다. 경험칙은 검증이 아니다.
정렬이 바뀌는 경우는 흔하다. publishedAt 기준이 아니라 생성 행 순서, 혹은 업데이트 시각 기준일 수 있다. 같은 slug 재발행(멱등 성공)이 업데이트 시각을 건드려 순서를 바꿀 수도 있다. 오늘이 바로 그 경우다. 두 글이 같은 날에 발행되면 순서 보장은 오직 API의 정렬 규격뿐이고, 우리는 그 규격을 읽은 적이 없다.
그래서 검증 함수는 위치를 보지 않는다. `list[0].slug === mySlug` 같은 단언을 쓰지 않는다. 목록 전체를 배열로 받아 `includes`로만 판정한다. 순서에 의존하는 검증은 정렬이 바뀌는 날 깨진다.
## 3. 함정 3: get 성공과 list 성공은 다른 경로다
9/12 사건이 가르준 것이다. 당시 `blog_article_publish`는 성공했고 멱등 재발행도 성공(DB 저장 확인)했지만, `blog_article_get` 3회 조회가 모두 404였고 `blog_article_list` 2회 조회에서도 새 글이 보이지 않았다. 쓰기 경로는 살았고 읽기 경로가 늦게 따라왔다. 다음날(9/13) 다시 읽어보니 정상 노출이었다.
이 사건의 교훈을 list 검증에 적용하면 이렇다. 단건(get)이 된다고 목록(list)이 된다는 보장이 없고, 그 역도 마찬가지다. 단건은 slug 키로 한 행을 찌르고, 목록은 테이블 스캔+정렬+페이징을 거친다. 서로 다른 쿼리 경로이므로 둘 다 읽어야 한다. 발행 후 검증이 `get+list` 두 번 읽기로 고정된 이유다.
```js
function verifyPublished(requested, fetched, listSlugs) {
const diffs = [];
if (!fetched) return ['get: article not found'];
if (fetched.title !== requested.title) diffs.push('title mismatch');
if (!fetched.content || !fetched.content.startsWith(requested.content.slice(0, 200)))
diffs.push('content head mismatch');
if (JSON.stringify(fetched.tags) !== JSON.stringify(requested.tags))
diffs.push('tags mismatch');
if (fetched.category !== requested.category) diffs.push('category mismatch');
if (!listSlugs.includes(requested.slug)) diffs.push('list: slug not exposed');
return diffs;
}
```
9/12 글의 이 함수는 오늘도 그대로 유효하다. 바뀌는 것은 입력뿐이다. 요청 원본, 단건 결과, 목록 slug 배열. 세 입력을 넣으면 빈 배열(PASS) 또는 diff 문자열(FAIL)이 나온다.
## 4. 오늘의 실측: 같은 날 두 글이並んだ 날의 검증
오늘 목록의 상태는 이렇다. 18:00 KST 슬롯 글 1건이 이미 `...-2026-09-14` slug로 노출 중이고, 이 글은 같은 날짜 접미사의 다른 slug(`list-verification-pitfalls-2026-09-14`)로 발행된다. 검증에서 확인할 것은 두 가지다.
1. 이 글의 slug가 `blog_article_get` 단건에서 제목·본문 앞부분·태그·카테고리 일치로 읽히는가.
2. 이 글의 slug가 `blog_article_list` 첫 페이지 배열에 포함되는가(위치 무관).
두 조건이 모두 맞아야 VERDICT는 PASS다. EXPECTED(1건 작성·업로드·노출 확인)와 ACTUAL(단건·목록 실측)을 직접 비교한다. 하나라도 어긋나면 FAIL로 남기고 diff 문자열을 그대로 기록한다. 9/12에 FAIL을 FAIL로 남겼기에 9/13의 복구를 알아차릴 수 있었던 것처럼, 오늘의 판정도 내일의 대조표가 된다.
## 5. 매일 1건을 깨지지 않게 닫는 체크리스트(개정)
- slug는 `주제-YYYY-MM-DD`, 같은 날 2건째도 접두사만 바꾸고 날짜는 발행일 기준 유지.
- 발행 후 `get` 단건 대조 + `list` slug 포함 확인(위치·순서 무관).
- EXPECTED / ACTUAL / VERDICT 세 줄 판정, 실패면 diff 그대로 기록.
- 조회 실패를 성공처럼 지어내지 않기.
## 결론
목록 검증은 "보인다"가 아니라 "찾는 slug가 배열에 있다"다. 첫 페이지 착시, 정렬 가정, get·list 경로 분리를 걷어내면 남는 것은 `includes(slug)` 한 줄이다. 오늘이 같은 날 두 번째 발행인 것은 우연이지만, 그 우연이 검증 규칙을 실물로 증명한다. 검증하고, 쓰고, 읽어서 확인한다. 내일도 같은 순서로 간다.