본문 바로가기
빌드노트 검색

빌드노트

304 응답에는 JSON이 없다: ETag만 저장하면 생기는 파싱 오류

ETag가 일치한 GET의 304에는 JSON 본문이 없다. 실제 200·304·변경 후 200의 14→0→14바이트와 파싱 오류를 비교하고, 짧은 클라이언트 분기로 저장 본문 재사용과 조건부 헤더 없는 재요청을 구별한다.

ETag를 받은 JSON API에 If-None-Match를 붙여 다시 요청했더니, 빈 응답을 JSON으로 읽는 곳에서 오류가 난다. 상태가 304라면 본문이 없는 것이 정상이다. 304를 처리하려면 앞서 받은 본문이 클라이언트에 남아 있어야 한다. ETag 문자열만 복사해 보냈다면 재사용할 데이터는 없다.

RFC 9110 §15.4.5 (새 탭에서 열림)는 304를 조건부 GET·HEAD의 결과로 정의하며 본문과 트레일러를 허용하지 않는다. 이 글은 JSON을 가져오는 GET을 다룬다. 먼저 클라이언트에서 상태를 확인하기 전에 매번 JSON 파싱을 하는지 살펴본다.

같은 요청에서 본문이 14 → 0 → 14바이트로 바뀌었다

별도 합성 서버의 /item에 직접 GET을 보냈다. Windows 11·Python 3.12.10에서 로컬 TCP 연결로 측정한 결과이며, 실제 서비스의 장애 기록은 아니다.

  • 처음에는 조건부 헤더 없이 요청했다. 응답은 200, ETag: "v1", Content-Type: application/json, Content-Length: 14였다. 실제 본문은 {"revision":1}로 14바이트였다.
  • 같은 주소에 If-None-Match: "v1"을 넣자 304와 같은 ETag가 왔다. 이 서버는 304에서 Content-Type과 Content-Length를 생략했고, 실제 본문도 0바이트였다.
  • 서버의 합성 데이터를 {"revision":2}, ETag를 "v2"로 바꾼 뒤 옛 태그 "v1"로 요청했다. 이번에는 200, ETag: "v2", Content-Type: application/json, Content-Length: 14와 새 본문을 받았다. 길이는 같아도 내용과 태그는 달랐다.

RFC 9110 §13.1.2 (새 탭에서 열림)에 따르면 GET에서 If-None-Match의 태그가 현재 표현과 일치하면 304로 응답한다. ETag는 서버가 부여한 검증자이므로 응답에서 받은 값을 따옴표까지 보존해 사용한다.

0바이트라는 판단은 헤더 숫자만 보고 내리지 않았다. TCP 응답 원문에서 헤더 구분자 뒤의 바이트를 직접 셌다. 다른 서버의 304에 Content-Length가 있다고 본문이 따라오는 것도 아니다. RFC 9110 §8.6 (새 탭에서 열림)은 같은 요청에 200으로 응답했다면 보냈을 본문 길이를 304의 Content-Length에 넣는 것을 허용한다.

별도 Python 클라이언트에서는 첫 200의 본문을 저장하고, 다음 304의 빈 본문을 일부러 json.loads에 넣었다. 아래 출력의 열은 상태, ETag, Content-Length 헤더, 읽은 본문의 바이트 수다. None은 그 헤더가 없다는 뜻이다.

200 "v1" 14 14
304 "v1" None 0
JSONDecodeError: Expecting value: line 1 column 1 (char 0)
reused: {'revision': 1}
200 "v2" 14 14

오류 다음 줄은 같은 304를 처리하면서 첫 200의 저장 본문을 읽은 결과다. 새로 온 본문을 파싱할 수는 없어도, 앞서 받은 revision: 1은 재사용할 수 있었다.

JSON 파싱 전에 304를 분기한다

직접 HTTP 응답을 처리하는 Python 코드라면 json.loads(body)를 바로 호출하는 곳을 다음처럼 나눌 수 있다. status는 응답 상태, body는 이번 응답에서 읽은 바이트, saved_body는 이번 요청에 재사용할 수 있는 저장 본문이다. 본문이 없다는 표시는 None으로 구별한다.

import json


def read_json(status, body, saved_body=None):
    if status == 304:
        if saved_body is None:
            raise RuntimeError("304를 재사용할 본문이 없습니다")
        return json.loads(saved_body)
    if status == 200:
        return json.loads(body)
    raise RuntimeError(f"예상하지 않은 HTTP 상태: {status}")

이 분기를 위 실험에서 받은 응답에 적용했을 때, 저장 본문이 있는 304는 {'revision': 1}, 변경 후 200은 {'revision': 2}로 읽혔다. 저장 본문 없이 받은 304는 RuntimeError: 304를 재사용할 본문이 없습니다로 구별됐다. 이때 호출하는 쪽에서 조건부 헤더 없는 GET으로 한 번 다시 확보하는 처리를 연결할 수 있다.

이 함수는 파싱할 본문을 고르는 부분만 보여 준다. 실제 캐시에서는 같은 URL과 요청 조건에 맞는 저장 응답을 선택하고, 304에 담긴 헤더로 저장된 응답 정보도 갱신해야 한다. RFC 9111 §4 (새 탭에서 열림)와 §4.3.4 (새 탭에서 열림)가 이 선택과 갱신을 다룬다. Vary로 구분되는 표현을 섞거나, 다른 주소의 본문을 ETag 문자열이 같다는 이유로 재사용하면 안 된다.

ETag만 남았다면 조건부 헤더 없이 다시 받는다

추가 요청에서는 저장 본문을 사용하지 않는 새 직접 연결에 If-None-Match: "v1"만 보냈다. 서버는 여전히 304·0바이트를 보냈다. 이 비교에서 서버는 클라이언트가 본문을 실제로 보관하는지 확인하지 않는다는 것을 알 수 있었다. 그다음 조건부 헤더를 빼고 GET하자 200·14바이트로 같은 {"revision":1}을 다시 받았다.

자기 코드에서는 요청 헤더를 만드는 곳과 본문을 저장하는 곳을 함께 확인한다.

  • 같은 요청에 쓸 본문과 ETag가 함께 있다면 조건부 GET을 보낸다. 304에서는 저장 본문을 재사용하고, 200에서는 새 본문과 응답 정보를 저장한다.
  • 본문을 버렸거나 ETag만 전달받았다면 조건부 요청을 만들지 않는다. 이미 304를 받았다면 If-None-Match를 제거해 다시 GET한다. If-Modified-Since도 쓰고 있다면 함께 제거한다. 이 날짜 헤더도 조건부 GET을 만드는 헤더 (새 탭에서 열림)다. 재시도는 한 번으로 제한하고, 그래도 304라면 실제 전송 요청과 중간 계층을 확인한다.
  • 캐시를 구현하지 않는 일회성 호출이라면 복사해 둔 ETag를 매번 붙이지 않는다. 304가 오면 실제 요청에 조건부 헤더가 들어갔는지부터 확인한다.

Cache-Control: no-cache만 추가하는 것으로 본문 확보를 보장할 수는 없다. 실험에서도 일치하는 If-None-Match와 no-cache를 함께 보냈을 때 304·0바이트였다. RFC 9111 §5.2.1.4 (새 탭에서 열림)의 no-cache 요청 지시어는 저장된 응답을 원 서버에서 검증한 뒤 사용하기를 원한다는 뜻이다. 검증 결과는 304일 수 있다. 다시 검증해야 하는 요청인지, 재사용할 본문 자체가 없는 요청인지 구별한다.

데이터를 바꾼 뒤에도 304라면 실제 본문과 ETag가 함께 바뀌었는지 본다. 상태가 200인데 본문이 비어 파싱에 실패했다면 이 글의 304 재사용 분기를 적용할 상황은 아니다. 빈 값을 무조건 {}로 바꾸면 이 차이를 놓친다.

여기서 실행한 것은 합성 JSON 서버와 직접 연결한 클라이언트의 GET이다. 단일 URL·단일 표현·단일 ETag만 비교했다. 브라우저 자동 캐시, CDN·프록시, 인증·Vary에 따른 여러 표현 선택, 날짜 조건부 요청은 실행하지 않았다. 이런 환경에서는 캐시 계층이 처리한 뒤 애플리케이션에 전달하는 상태와 본문도 따로 확인해야 한다.

← 전체 글 보기