REST API 발행 / VERIFIED CASE

HTTP 200인데 WordPress REST 발행이 실패한 이유

자동발행 검사를 만들다가 HTTP 200인데 본문이 로그인 HTML인 응답을 넣어봤다. 상태 코드만 보는 검사는 성공이라고 답했다. Content-Type과 생성된 글 ID까지 확인하도록 바꾸자 같은 입력이 unexpected_content_type으로 막혔다. 정상 JSON fixture는 같은 검사에서 종료 코드 0으로 통과했다. 이 글은 실제 운영 장애 회고가 아니라, 그 오판 조건을 격리된 fixture에서 만든 통제 실험이다.

실패 조건을 먼저 고정했다

입력은 세 가지다. 상태는 200, Content-Type은 text/html, 본문은 로그인 페이지 형태의 HTML이다. 운영 WordPress에는 요청하지 않았다.

.venv/bin/python scripts/huntlab_wp_diagnostics.py rest-response \
  --status 200 \
  --content-type 'text/html; charset=UTF-8' \
  --body evidence/lab-fixtures/rest-html-200.html

상태 코드만 보는 이전 판단은 이 입력을 성공으로 통과시킨다. 강화한 진단기는 다음처럼 종료 코드 1과 구체적인 이유를 돌려줬다.

passed=false
reason=unexpected_content_type
status=200
content_type=text/html
body_bytes=99

핵심은 “200이 아니면 실패”가 아니라 “이 API 호출에서 기대한 응답 계약인가”다.

허용 상태 코드 목록만 좁혀서는 HTML 로그인 페이지의 200을 구분할 수 없다. 상태 코드 규칙과 함께 응답 형식과 생성된 대상의 ID를 별도로 확인해야 한다.

생성 요청의 성공 계약

게시물 생성 호출이라면 최소한 다음 세 조건을 함께 확인해야 한다.

  1. 허용한 HTTP 상태인가.
  2. 응답의 media type이 application/json인가.
  3. JSON을 파싱했을 때 게시물 ID가 양의 정수인가. 기존 대상을 확인하는 요청이라면 기대한 ID와 일치하는가.

다음은 고정 커밋에서 핵심 분기만 발췌한 코드다. 그대로 복사해 범용 JSON 검사기로 쓰면 배열·null·불리언 ID 같은 입력을 충분히 거절하지 못하므로 전체 구현과 테스트를 함께 확인해야 한다.

media_type = content_type.split(";", 1)[0].strip().lower()
if media_type != "application/json":
    return failed("unexpected_content_type")

payload = json.loads(body)
if payload.get("id") != expected_id:
    return failed("post_identity_mismatch")

당시 실험의 구현과 fixture는 고정 커밋의 진단 스크립트와 테스트에서 확인할 수 있다.

2026년 9월 16일 실행 안내 보정: 역사 구현은 정수 여부만 검사해 true, 0, -1 ID를 통과시킬 수 있다. 현재 적용할 코드는 양의 정수 검사를 보강한 전체 진단기와 잘못된 ID 회귀 테스트다. 불리언을 제외한 실제 정수형인지 확인하고 0보다 커야 통과시킨다. 위 양의 정수 조건은 이 수정 후 구현을 뜻한다. 같은 세 ID는 당시 코드에서 모두 통과했지만 수정 후 코드에서는 모두 거절됐다. 최초 실험의 테스트 4개와 달리 현재 진단 테스트는 이 ID 검사를 포함해 5개이며, 이번 로컬 재실행에서 모두 통과했다.

같은 검사기에 정상 응답을 넣었다

비교 입력은 201, JSON content type, id=742인 최소 응답이다.

.venv/bin/python scripts/huntlab_wp_diagnostics.py rest-response \
  --status 201 \
  --content-type 'application/json; charset=UTF-8' \
  --body evidence/lab-fixtures/rest-post-201.json \
  --expected-id 742

결과는 종료 코드 0이었다.

passed=true
reason=validated_json_post_identity
status=201
content_type=application/json
post_id=742

실패와 성공은 같은 Python 3.12.9 환경, 같은 검사기, 고정된 입력 파일로 비교했다. 이 실험 중 네트워크 쓰기와 WordPress 쓰기는 각각 0회였다.

회귀 테스트가 막는 오판

test_html_login_page_with_200_exposes_status_only_false_positive는 200 HTML이 거절되는지 검사한다. test_json_post_identity_passes_same_response_contract는 JSON 응답의 ID까지 일치할 때만 통과하는지 검사한다. 최초 실험에서는 4개가 통과했다. 아래 재실행 명령은 앞서 연결한 ID 보강 버전에서 5개를 검사하므로, 과거 로그의 테스트 수와 구분한다.

.venv/bin/python -m unittest tests.test_huntlab_wp_diagnostics -v

검사 결과와 입력 파일 SHA는 Evidence Lab 실행기가 기록한다. 따라서 나중에 fixture가 바뀌면 같은 결과라고 주장할 수 없다.

이 검사를 적용하면 안 되는 경우

읽기 API는 정상 상태가 200일 수 있고, 삭제 API는 204처럼 본문이 없는 성공을 사용할 수 있다. 모든 REST 호출에 201과 id를 강제하면 또 다른 오판이 된다. 호출 종류별로 허용 상태와 필수 응답 필드를 선언해야 한다.

또 이 fixture는 프록시 제품 하나의 실제 동작을 재현한 것이 아니다. 확인한 범위는 “HTML 200을 상태 코드만으로 성공 처리하는 클라이언트 결함”이다. 특정 플러그인이나 호스팅 서비스가 원인이라고 확대하지 않는다.

발행 후 검증 체크리스트

  • 요청 종류별 허용 상태를 분리한다.
  • 응답 media type을 파싱한 뒤 JSON만 디코딩한다.
  • 생성·수정 대상의 ID와 slug를 read-back으로 대조한다.
  • JSON 파싱 실패를 성공으로 기록하지 않는다.
  • 실패 시 재시도 가능 여부와 중복 생성 위험을 별도로 판단한다.

복사해서 실행하기

앞의 실행 준비를 마친 프로젝트 루트에서 다음 명령을 실행한다. 결과 파일 덮어쓰기를 막는 실행기이므로 매번 새 임시 디렉터리를 만들고, 출력된 경로에서 전후 결과와 회귀 테스트를 확인한다.

huntlab_result_dir=$(mktemp -d)
.venv/bin/python scripts/run_evidence_lab.py rest-html-200-response \
  --output "$huntlab_result_dir/result.json"
printf '결과 파일: %s/result.json\n' "$huntlab_result_dir"

예상 결과는 before.exit_code=1, after.exit_code=0, status=READY, wordpress_writes=0이다. 값이 다르면 글의 결론을 그대로 적용하지 않는다.

진단 스크립트와 재현 fixture 보기

HTML 200과 JSON 201 응답 비교하기 — 입력값만 검사하는 브라우저 도구이며 실제 사이트에 요청하지 않습니다.