WordPress 글 목록 전체를 페이지별로 검사하는 장면
REST API 발행 / VERIFIED CASE

첫 REST 응답에서 19개가 빠졌다: WordPress 119개 글 전수 감사

Hunt News의 AdSense 콘텐츠 감사를 시작하며 공개 글 수를 다시 셌다. per_page=100으로 한 번만 요청한 결과는 100개였다. 실제 공개 글은 119개였고, 두 번째 페이지의 19개가 감사표에서 통째로 빠질 조건이었다. 이번 작업은 일반적인 페이지네이션 설명이 아니라 운영 WordPress 119개를 전수 분류하기 위해 누락을 찾아 고친 기록이다.

100개 응답을 전체 목록으로 착각했다

첫 확인 명령은 정상적으로 HTTP 200을 반환했다. 오류가 없어서 더 위험했다.

curl -sS -D page1.headers -o page1.json \
  'https://huntlab.app/wp-json/wp/v2/posts?status=publish&per_page=100&page=1&_fields=id,slug'
page=1 status=HTTP/2 200 rows=100
x-wp-total=119 x-wp-totalpages=2
first_id=651 last_id=132

처음 가설은 “공개 글이 100개로 줄었거나 백업의 119가 오래됐다”였다. 하지만 응답 헤더 자체가 전체 119개와 2페이지를 알려주고 있었다. 데이터가 사라진 게 아니라 클라이언트가 첫 페이지에서 멈춘 것이었다.

같은 조건에서 두 번째 페이지를 열었다

URL의 page만 2로 바꾸고 나머지 조건을 고정했다.

curl -sS -D page2.headers -o page2.json \
  'https://huntlab.app/wp-json/wp/v2/posts?status=publish&per_page=100&page=2&_fields=id,slug'
page=2 status=HTTP/2 200 rows=19
x-wp-total=119 x-wp-totalpages=2
first_id=128 last_id=16

누락된 19개는 별도 상태의 글이 아니었다. 같은 status=publish, 같은 정렬 조건의 다음 페이지였다. 페이지 1의 마지막 ID가 132이고 페이지 2의 첫 ID가 128인 것도 기본 최신순 정렬과 맞는다.

기록 범위: 119개는 이 글의 최초 감사 당시 수치이며 현재 게시물 수가 아닙니다. 다음 코드 조각은 당시 100+19개 응답을 처리한 구현입니다. 총수가 100의 배수일 때의 종료 조건과 동시 변경은 별도로 다뤄야 합니다.

수정: 빈 페이지가 아니라 마지막 짧은 페이지까지 읽는다

감사 코드는 페이지 번호를 늘리며 100개보다 짧은 응답을 만날 때 종료한다.

def fetch_all(client, endpoint, *, context="edit", status=None):
    rows = []
    page = 1
    while True:
        query = {"per_page": "100", "page": str(page), "context": context}
        if status:
            query["status"] = status
        batch = client.request("GET", f"{endpoint}?{urlencode(query)}", expected=(200,))
        rows.extend(batch)
        if len(batch) < 100:
            break
        page += 1
    return rows

이 방식에서 첫 페이지는 종료 조건이 아니다. 100개를 꽉 채웠으므로 다음 페이지를 요청한다. 두 번째 페이지가 19개라서 그때 119개로 종료한다. 구현은 scripts/audit_adsense_content.py에 있고, 100개와 19개 응답을 고정한 회귀 테스트는 tests/test_adsense_content_audit.py에 있다.

수정 전후 결과

동일한 운영 사이트, 같은 시각대, 같은 status=publish 조건으로 비교했다.

수정 전 단일 요청: 100개, 19개 누락
수정 후 page 1+2: 119개, REST의 X-WP-Total과 일치
분류 합계: 119개

이 차이는 단순 카운트 문제가 아니었다. 빠진 오래된 글에는 비기술 카테고리가 포함돼 있어 한 페이지만 감사하면 noindex 대상과 사이트맵 조치가 달라질 수 있다. 실제 P0 변경에서는 119개 전체를 백업한 뒤 승인된 37개 ID만 별도 집합으로 적용했다. 페이지네이션 결과와 변경 대상 집합을 분리해 “두 번째 페이지를 읽었다”가 “모두 수정했다”로 번지지 않게 했다.

실패를 다시 만들지 않는 테스트

로컬 단위 테스트는 첫 페이지가 정확히 100개일 때 다음 요청을 해야 하고, 짧은 두 번째 페이지에서 멈추는 계약을 검사한다. 운영 검증은 REST 헤더의 119와 수집 결과 119를 다시 대조한다.

운영 REST: page1=100, page2=19, total=119
감사 입력: 119
ID 중복: 0

응답 순서에 의존해 누락을 판정하지 않고 ID 집합도 비교한다. 감사 중 새 글이 발행되면 offset 기반 페이지 사이에서 중복이나 누락이 생길 수 있기 때문이다. 그래서 이번 전수 감사 동안 독립 기사 자동 발행을 중지했다.

적용 범위와 남은 한계

이 구현은 현재처럼 데이터가 고정된 감사에는 충분하다. 쓰기가 계속되는 대형 사이트라면 페이지 1과 2 사이에 새 글이 들어와 기본 정렬 위치가 밀릴 수 있다. 그때는 수정 시각·ID 기준으로 스냅샷 경계를 고정하거나 데이터베이스 덤프와 대조해야 한다.

또 WordPress는 범위를 벗어난 페이지에 400을 반환할 수 있다. 마지막 페이지가 정확히 100개인 경우에는 한 번 더 요청해 오류 코드가 rest_post_invalid_page_number인 경우만 이전 페이지를 읽은 뒤의 종료로 해석해야 한다. 더 명시적인 방법은 첫 응답의 X-WP-TotalPages만큼 요청한 뒤 고유 ID 수를 X-WP-Total과 대조하는 것이다. 인증 오류나 다른 400까지 종료로 삼으면 안 된다.

지금 공개 글 전체를 직접 세는 완결 예제

앞의 119개는 과거 감사 수치다. 아래는 2026년 9월 16일 추가한 독자용 검사이며 현재 공개 글만 읽는다. Python 3.11 이상이 설치된 터미널에서 블록 전체를 실행한다. 저장소 다운로드·인증·추가 패키지가 필요 없고 파일도 만들지 않지만, 공개 HTTPS에 접근할 네트워크는 필요하다.

페이지 이동을 작은 사이트에서도 확인하도록 한 번에 3개씩 요청한다. orderby=id&order=asc로 정렬하고, 매번 전체 건수·페이지 수가 같은지, 예상한 페이지 길이와 고유 ID가 맞는지 확인한다. 리다이렉트·비JSON·중복 ID·응답 크기 초과는 실패로 끝낸다. 최대 100페이지인 이 예제는 300건을 넘는 사이트에는 그대로 쓰지 않는다.

python3 - <<'PY'
import json
from urllib.parse import urlencode
from urllib.request import Request, HTTPRedirectHandler, build_opener


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise ValueError("redirect_refused")


def collect(fetch):
    per_page, page, expected, ids = 3, 1, None, set()
    while True:
        total, pages, rows = fetch(page, per_page)
        if total < 0 or pages != (total + per_page - 1) // per_page or pages > 100:
            raise ValueError("invalid_totals_or_page_limit")
        if expected is None:
            expected = (total, pages)
        if expected != (total, pages):
            raise ValueError("inventory_changed")
        if not isinstance(rows, list) or len(rows) != min(per_page, max(0, total - (page - 1) * per_page)):
            raise ValueError("page_count_mismatch")
        for row in rows:
            post_id = row.get("id") if isinstance(row, dict) else None
            if type(post_id) is not int or post_id <= 0 or post_id in ids:
                raise ValueError("invalid_or_duplicate_id")
            ids.add(post_id)
        print(f"page={page} rows={len(rows)} total={total} pages={pages}")
        if page >= pages:
            break
        page += 1
    if len(ids) != expected[0]:
        raise ValueError("total_mismatch")
    return {"passed": True, "unique_ids": len(ids), "pages_requested": page}


def public_page(page, per_page):
    query = urlencode({"rest_route": "/wp/v2/posts", "status": "publish",
                       "per_page": per_page, "page": page,
                       "orderby": "id", "order": "asc", "_fields": "id"})
    request = Request("https://huntlab.app/?" + query,
                      headers={"User-Agent": "HuntLab-Reader-Check/1.0", "Cache-Control": "no-cache"})
    with build_opener(NoRedirect()).open(request, timeout=20) as response:
        if response.status != 200 or response.headers.get_content_type() != "application/json":
            raise ValueError("unexpected_response")
        raw = response.read(1_000_001)
        if len(raw) > 1_000_000:
            raise ValueError("response_too_large")
        return (int(response.headers["X-WP-Total"]),
                int(response.headers["X-WP-TotalPages"]), json.loads(raw))


if __name__ == "__main__":
    print(json.dumps(collect(public_page)))
PY

추가 검증 때는 3·3·3·1건을 읽어 다음 결과를 얻었다. 이 수치는 현재도 10편이어야 한다는 조건이 아니다. 글 수가 바뀌면 헤더에 맞는 새 수가 나와야 한다.

page=1 rows=3 total=10 pages=4
page=2 rows=3 total=10 pages=4
page=3 rows=3 total=10 pages=4
page=4 rows=1 total=10 pages=4
{"passed": true, "unique_ids": 10, "pages_requested": 4}

전체 종료 코드 0과 passed: true가 이 검사의 성공이다. 오류가 나면 출력을 성공으로 바꾸거나 전체 실행을 무작정 반복하지 말고 HTTP 접근, 응답 헤더, 동시에 진행된 발행부터 확인한다. 0건과 마지막 페이지가 정확히 꽉 찬 경우도 별도 대조 입력으로 확인했다. 총수가 같은 채 글이 교체되는 모든 동시 변경까지 감지하는 snapshot은 아니므로, 중요한 감사에서는 쓰기를 멈춘 시점의 백업과 대조해야 한다. 초안·비공개 글·첨부파일 수는 이 공개 검사에 포함되지 않는다.

재현 체크리스트

  1. 첫 응답의 X-WP-Total과 X-WP-TotalPages를 저장한다.
  2. 모든 페이지의 ID를 합치고 중복 수를 확인한다.
  3. 수집 수가 X-WP-Total과 같은지 비교한다.
  4. 감사 중 발행을 멈추거나 스냅샷 경계를 고정한다.
  5. 변경 전 전체 raw 본문·slug·미디어·canonical 백업과 대조한다.

WordPress REST API의 pagination 문서는 page, per_page, X-WP-Total, X-WP-TotalPages의 의미를 설명한다. 하지만 헤더를 읽는 것만으로 전수 감사가 되지는 않는다. 이번 문제의 해결 조건은 실제 두 페이지에서 100+19개를 모으고, 그 119개가 백업과 감사표의 ID 집합에 모두 존재하는지 확인하는 것이었다.

모은 글 ID와 전체 건수 비교하기 — 입력값만 검사하는 브라우저 도구이며 실제 사이트에 요청하지 않습니다.