두 응답 카드 사이에서 ETag 조건부 조회 결과를 판정하는 검증 장치를 표현한 대표 이미지

WordPress REST API ETag 조건부 조회: 중복 검사 응답 검증

매일 공개 글 목록을 읽는 중복 검사에서 WordPress REST API ETag 조건부 조회를 붙이면 변경 없는 JSON 전송을 줄일 수 있을까. 답은 서버가 실제 validator를 제공하고, 같은 representation에 If-None-Match를 보냈을 때 304 Not Modified로 응답하는 경우에만 그렇다. 조건부 헤더를 보내는 행위 자체는 최적화가 아니다.

2026년 8월 14일 HuntLab 공개 Posts API를 읽기 전용으로 확인했다. 공개 목록 56개와 6페이지는 확인됐지만 ETagLast-Modified는 없었고, 임의 validator 요청도 200과 전체 본문을 돌려줬다. 이 글은 이 관측을 모든 WordPress 설치로 확대하지 않고, RFC 9110의 조건부 요청, WordPress의 pagination, 현재 publisher/wordpress.py의 응답 처리 책임을 연결해 도입·보류 기준을 정한다.

20초 핵심 요약

  • 무엇: ETag는 서버가 특정 응답 representation에 부여하는 validator이며, 페이지 목록 자체를 대신하는 값이 아니다.
  • 왜: 304를 빈 목록으로 읽거나 첫 페이지 결과를 전체 컬렉션에 확대하면 중복 검사가 글을 누락할 수 있다.
  • 어떻게: 실제 ETag 안정성, 일치 요청의 304와 0바이트, 저장 본문 재사용, 페이지별 키 분리를 모두 검증한 뒤 채택한다.

ETag는 목록이 아니라 선택된 응답의 영수증이다

같은 /wp/v2/posts 경로라도 page, per_page, _fields, 인증 문맥이 달라지면 선택되는 representation이 달라질 수 있다. 서버는 그 결과에 ETag를 붙일 수 있고, 클라이언트는 값을 해석하거나 만들지 않은 채 같은 요청의 If-None-Match에 되돌려 보낸다. RFC 9110은 이 값이 일치하는 GET 또는 HEAD 요청에 304를 반환하도록 정의한다. RFC 9110의 ETagIf-None-Match가 구분한 책임이다.

여기서 304는 빈 JSON 배열이 아니다. 본문을 다시 보내지 않으니 클라이언트가 이전 200 응답으로 저장한 representation을 재사용하라는 뜻이다. 저장 본문 없이 상태만 받아 []로 바꾸면 “변경 없음”이 “게시물 없음”으로 변질된다. RFC 9110의 304 정의에 따라 응답은 content를 포함할 수 없으므로, 캐시 본문 보관은 선택적인 편의 기능이 아니라 조건부 조회의 일부다.

WordPress가 보장하는 pagination과 배포별 validator를 분리한다

WordPress의 공식 목록 계약은 pageper_page로 페이지를 요청하고 X-WP-Total, X-WP-TotalPages로 전체 범위를 판단하는 것이다. REST API Pagination 문서WP_REST_Posts_Controller::get_items()는 이 총계 헤더를 명시한다. 반면 Posts 목록마다 ETag를 반드시 제공한다는 계약은 확인되지 않았다.

응답은 한 계층에서 완성되지 않는다. Posts 컨트롤러가 목록과 pagination 헤더를 만들고, WP_REST_Server::serve_request()가 endpoint를 dispatch한 뒤 상태와 헤더를 보낸다. 그 사이의 WordPress 훅과 플러그인, 이후의 웹서버와 CDN도 최종 응답에 영향을 줄 수 있다. 따라서 ETag 유무는 코어 코드만 보고 추정할 일이 아니다. 실제 URL과 쿼리, 인증 문맥에서 확인해야 할 배포 속성이다. serve_request() 코드 경로도 이 확장 경계를 보여준다.

공개 목록 대조에서는 전송 절감 조건이 성립하지 않았다

첫 요청은 인증 없이 per_page=10&page=1&_fields=id,modified,slug로 제한한 공개 목록을 읽었다. 200, 884바이트, 총 56개·6페이지가 관측됐지만 ETag와 Last-Modified는 응답에 없었다.

$ curl -sS -D - -o /dev/null --max-time 30 \
  'https://huntlab.app/wp-json/wp/v2/posts?per_page=10&page=1&_fields=id,modified,slug' \
  -w 'CURL_STATUS=%{http_code} SIZE_DOWNLOAD=%{size_download} EXIT=0\n'
HTTP/2 200
content-length: 884
x-wp-total: 56
x-wp-totalpages: 6
CURL_STATUS=200 SIZE_DOWNLOAD=884 EXIT=0

공개 목록의 pagination 헤더와 validator 부재를 보여주는 직접 검증 캡처

두 번째 요청에는 서버가 발급하지 않은 "huntlab-nonexistent-validator"를 넣었다. curl은 정상 종료했지만 응답은 304가 아니라 첫 요청과 같은 200, 884바이트였다.

$ curl -sS -D - -o /dev/null --max-time 30 \
  -H 'If-None-Match: "huntlab-nonexistent-validator"' \
  'https://huntlab.app/wp-json/wp/v2/posts?per_page=10&page=1&_fields=id,modified,slug' \
  -w 'CURL_STATUS=%{http_code} SIZE_DOWNLOAD=%{size_download} EXIT=0\n'
HTTP/2 200
content-length: 884
x-wp-total: 56
x-wp-totalpages: 6
CURL_STATUS=200 SIZE_DOWNLOAD=884 EXIT=0

임의 If-None-Match 요청이 200과 전체 본문을 반환한 직접 검증 캡처

이는 HTTP 오류가 아니다. 요청은 정상 처리됐지만 비교할 서버 validator가 없어 캐시 절감이 생기지 않은 결과다. 같은 조건에서 상태, 다운로드 크기, 총계가 모두 같았으므로 관측된 절감은 0바이트다. ETag가 없어서 실제 일치 ETag에서 304로 가는 성공 경로는 재현하지 못했다.

이번 수치는 특정 시점의 공개 context=view, 첫 페이지, 선택한 세 필드에만 해당한다. Draft 0개는 주제 선정 과정의 인증 기반 중복 검사 맥락이며 이번 공개 요청으로 다시 검증한 값이 아니다. 다른 페이지와 쿼리, 인증된 context=edit, 다른 CDN 지점도 확인 범위에 포함하지 않았다.

클라이언트는 상태·헤더·본문을 하나의 결과로 다뤄야 한다

현재 publisher/wordpress.pyWordPressClient.request()는 응답을 읽어 JSON으로 역직렬화한 본문만 호출자에게 반환한다. validator와 pagination 헤더를 저장할 인터페이스가 없고, urlopen304HTTPError로 전달하면 현재 경로는 이를 정상 캐시 적중이 아닌 WordPressError로 바꾼다. 이 때문에 요청 헤더 하나만 추가해서는 조건부 조회가 완성되지 않는다.

캐시를 도입한다면 canonical 요청 키마다 다음 세 값을 함께 보관해야 한다.

  1. 서버가 돌려준 validator
  2. 마지막 정상 200의 본문
  3. 그 응답의 pagination metadata

200은 세 값을 갱신하고, 304는 저장된 본문을 재사용한다. ETag가 사라지거나 304인데 저장 본문이 없으면 조건 없는 GET으로 되돌아가야 한다. 쿼리, 페이지, _fields, 인증 문맥이 다르면 키도 분리한다. 이 책임을 별도 응답 타입과 캐시 저장소로 드러내기 전에는 기존 request()If-None-Match만 끼워 넣지 않는 편이 안전하다.

전송량보다 전체 페이지 완전성이 먼저다

선택지는 세 가지다. 하지만 모두 같은 문제를 푸는 것은 아니다.

선택 얻는 것 지불하는 비용과 위험
모든 페이지를 무조건 GET 매 순회에서 전체 페이지 수를 확인하는 단순한 기준선 변경이 없어도 JSON을 다시 받는다
페이지별 ETag 조건부 GET 안정된 ETag와 실제 304가 있을 때 본문 전송을 생략한다 키 정규화, 본문 저장, 304 분기, fallback, 인증 분리가 필요하다
modified_after 또는 자체 fingerprint 수정 시각 이후 데이터 선택이나 다운로드 후 동일성 판정 삭제·상태 변경·페이지 이동을 별도로 처리하며, fingerprint는 전송을 줄이지 못한다

새 글이 목록 첫머리에 추가되면 뒤 페이지의 항목 경계가 연쇄적으로 움직일 수 있다. 첫 페이지가 304였다는 사실을 “전체 컬렉션이 그대로다”로 확대하거나, 일부 페이지를 영구 생략해서는 안 된다. 서버가 컬렉션 전체 validator의 의미를 별도로 보장하지 않는 한 페이지별 representation과 전체 순회 정책은 분리해야 한다.

수정 시각 필터도 ETag의 대체품은 아니다. Posts REST API 문서modified_after는 선택할 데이터를 좁히는 기능이다. 저장한 전체 목록이 그대로인지 HTTP에서 검증하는 기능과는 목적이 다르므로 증분 동기화 설계로 따로 다뤄야 한다.

이 조건을 모두 통과할 때만 작은 파일럿을 연다

향후 서버나 캐시 계층이 ETag를 제공한다면 다음 결과를 읽기 전용 격리 테스트에서 확인해야 한다.

  • 동일 canonical 요청을 반복했을 때 ETag가 안정적으로 유지된다.
  • 일치하는 If-None-Match가 실제 304와 0바이트 본문을 반환한다.
  • 불일치 validator에는 200과 새 본문이 온다.
  • 페이지와 쿼리, _fields, 인증 문맥별 cache key가 분리된다.
  • 304에서는 저장 본문을 재사용하며, 본문이 없으면 무조건 GET으로 복구한다.
  • 새 게시물로 페이지 경계가 이동해도 전체 재순회가 완전하게 끝난다.

ETag가 반복해서 바뀌거나 조건부 요청이 계속 200이면 정확성 오류는 아니지만 최적화의 효용이 없다. 5xx와 timeout도 304처럼 취급하지 않는다. stale body를 쓸지는 별도의 가용성 정책으로 결정해야 한다.

현재 배포에는 첫 번째 선행조건인 validator가 없다. 그러므로 publisher/wordpress.py의 캐시 변경은 보류하고 기존 pagination으로 6페이지를 모두 순회하는 판단이 맞다. 서버가 ETag를 제공하는 격리 환경이 준비되면 위 파일럿을 먼저 실행하고, 목록 순회의 기준선과 결과가 달라지는 순간 기능을 끌 수 있어야 한다.

전체 목록 순회의 선행 계약을 이어서 확인하려면 WordPress REST API 페이지네이션 글에서 X-WP-TotalPages 기반 순회를 살펴볼 수 있다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다