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

빌드노트

ETag는 다른데 날짜는 그대로라면: 조건부 GET의 헤더 우선순위

조건부 GET에 If-None-Match와 If-Modified-Since를 함께 보내면 날짜 조건은 판단에서 빠진다. Werkzeug 3.1.9의 실제 WSGI 응답을 태그·날짜 단독 요청과 비교하고, 날짜만 보고 304를 반환하는 분기를 고치는 위치를 설명한다.

조건부 GET에서 수정 시각만 비교해 304를 반환하려는데, 요청에 If-None-Match도 들어 있다. 태그는 다르지만 날짜로는 바뀌지 않은 자원이라면 어느 쪽을 따라야 할까. If-None-Match가 있는 요청에서는 If-Modified-Since를 판단에서 제외한다. 태그가 불일치했다고 날짜 검사로 넘어가 304를 반환하면 안 된다.

RFC 9110 §13.1.3 (새 탭에서 열림)은 두 헤더가 함께 오면 If-Modified-Since를 무시하도록 규정한다. §13.2.2 (새 탭에서 열림)의 평가 순서에도 날짜 조건은 If-None-Match가 없을 때만 등장한다. 아래에서는 다른 전제조건이나 Range가 없고, 조건 없는 GET은 200인 자원 하나로 이 차이를 확인한다.

날짜가 반대 결론을 내도록 요청을 만들었다

Windows 11·Python 3.12.10·Werkzeug 3.1.9에서 고정 본문 guide revision 2와 끝의 줄바꿈을 반환하는 WSGI 서버를 실행했다. ETag는 "guide-v2", Last-Modified는 Mon, 01 Jan 2024 00:00:00 GMT였다. 상태를 직접 정하는 모의 분기 대신 실제 Response.make_conditional에 조건 처리를 맡기고, loopback TCP 연결로 응답을 읽었다. 다음은 요청과 관찰을 짝지어 정리한 결과다.

  • 불일치 태그 + 미래 날짜: If-None-Match: "guide-v1"과 If-Modified-Since: Tue, 01 Jan 2030 00:00:00 GMT를 함께 보냈다. 결과는 200과 본문이었다. 같은 미래 날짜만 보낸 요청은 304와 빈 본문이었다.
  • 일치 태그 + 과거 날짜: If-None-Match: "guide-v2"와 If-Modified-Since: Sun, 31 Dec 2023 00:00:00 GMT를 함께 보냈다. 결과는 304와 빈 본문이었다. 같은 과거 날짜만 보낸 요청은 200과 본문이었다.

첫 비교는 태그가 다를 때도 날짜 때문에 304가 되는 오류를 잡는다. 두 번째 비교는 날짜가 더 오래됐다는 이유로 일치하는 태그의 304를 취소하는 오류를 잡는다. 날짜만 보낸 요청에서는 날짜 조건이 실제로 작동했으므로, 두 헤더를 함께 보냈을 때의 차이를 단순히 날짜 처리 누락으로 설명할 수 없다.

GET의 If-None-Match는 현재 태그와 일치하면 조건이 거짓이 되어 304를 반환한다. 태그가 다르면 그 조건을 통과해 요청을 계속 처리한다. 이 의미는 RFC 9110 §13.1.2 (새 탭에서 열림)에 정의돼 있다. 여기서는 다른 조건이나 오류가 없어서 200이었지만, 태그 불일치가 모든 요청의 200을 보장한다는 뜻은 아니다.

Werkzeug에서는 응답의 검증자를 먼저 설정한다

자기 코드에서 날짜를 비교한 뒤 곧바로 304를 반환하는 곳을 확인한다. 그 분기가 먼저 끝나면 라이브러리는 If-None-Match를 판단할 기회를 얻지 못한다. Werkzeug를 사용한다면 본문에 맞는 ETag와 수정 시각을 응답에 설정한 뒤 make_conditional을 호출하도록 연결할 수 있다. 응답은 WSGI를 통해 반환한다.

아래는 비교에 필요한 작은 서버 전체다. 고정 태그와 날짜는 실습용이며, 실제 자원에서는 선택한 표현에 맞는 값으로 만들어야 한다. 같은 입력을 확인하려면 Python 3.12가 있는 빈 연습 폴더에서 다음 두 명령으로 별도 환경을 준비한다. 설치가 실패하면 서버 실행으로 넘어가지 않는다.

python -m venv .venv
.\.venv\Scripts\python.exe -m pip install Werkzeug==3.1.9

다음 코드를 conditional_server.py로 저장한다. environ은 WSGI 서버가 전달하는 현재 요청 정보다.

from datetime import datetime, timezone
from werkzeug.serving import run_simple
from werkzeug.wrappers import Response


def app(environ, start_response):
    response = Response("guide revision 2\n", mimetype="text/plain")
    response.set_etag("guide-v2")
    response.last_modified = datetime(2024, 1, 1, tzinfo=timezone.utc)
    response.make_conditional(environ)
    return response(environ, start_response)


if __name__ == "__main__":
    run_simple("127.0.0.1", 8765, app)

같은 폴더에서 .\.venv\Scripts\python.exe .\conditional_server.py를 실행하고 http://127.0.0.1:8765의 시작 안내를 확인한다. 포트가 이미 사용 중이면 기존 프로세스를 종료하지 말고 예제의 포트와 아래 URL을 함께 바꾼다. 이 서버는 Werkzeug의 로컬 개발용 서버 (새 탭에서 열림)다. 확인을 마치면 서버 창에서 Ctrl+C로 종료한다.

make_conditional 문서 (새 탭에서 열림)에 따르면 이 메서드는 응답 객체를 직접 수정하며, 실제 본문 생략은 WSGI 호출에서 처리한다. 따라서 호출 직후 response.get_data()에 문자열이 남아 있다는 것만으로 304에 본문이 전송됐다고 판단하면 안 된다. 위 관찰도 객체 내부 값이 아니라 실제 HTTP 응답 본문으로 확인했다.

두 헤더를 함께 보낸 뒤 하나만 제거해 비교한다

서버를 켜 둔 채 다른 PowerShell 창에서 같은 연습 폴더를 연다. 다음은 PowerShell 7.6.6·curl 8.21.0에서 실행한 요청이다. curl.exe를 사용하고 태그의 큰따옴표를 헤더 값에 보존한다. curl 옵션 (새 탭에서 열림) 중 -H는 요청 헤더, -i는 응답 헤더 출력을 위한 것이다. 맨 앞의 -q와 --noproxy '*'는 이 비교에 개인 curl 설정과 프록시가 개입하지 않게 한다.

$url = 'http://127.0.0.1:8765/guide'
# A: 불일치 ETag + 미래 날짜
curl.exe -q --noproxy '*' -sS -i --max-time 3 $url -H 'If-None-Match: "guide-v1"' -H 'If-Modified-Since: Tue, 01 Jan 2030 00:00:00 GMT'
# B: 일치 ETag + 과거 날짜
curl.exe -q --noproxy '*' -sS -i --max-time 3 $url -H 'If-None-Match: "guide-v2"' -H 'If-Modified-Since: Sun, 31 Dec 2023 00:00:00 GMT'
# C, D: 위 두 요청에서 If-None-Match만 제거
curl.exe -q --noproxy '*' -sS -i --max-time 3 $url -H 'If-Modified-Since: Tue, 01 Jan 2030 00:00:00 GMT'
curl.exe -q --noproxy '*' -sS -i --max-time 3 $url -H 'If-Modified-Since: Sun, 31 Dec 2023 00:00:00 GMT'

실제 응답에서 상태와 본문만 추리면 다음과 같았다. \n은 본문 끝의 줄바꿈이며, ''는 빈 본문이다. 응답 헤더는 생략했다.

A: 200, body='guide revision 2\n'
B: 304, body=''
C: 304, body=''
D: 200, body='guide revision 2\n'

자기 개발 서버에 적용할 때는 그 자원의 현재 ETag·Last-Modified를 먼저 확인하고, 일치·불일치 태그와 앞뒤 날짜를 정한다. A가 C처럼 304라면 날짜만 보고 조기 반환하는 분기를 확인한다. B가 D처럼 200이라면 두 조건을 모두 만족해야만 304가 되는 식으로 묶었는지 살펴본다. 실제 전송 헤더가 의도한 값인지, 앞단이나 뒷단에서 상태를 바꾸는지도 함께 확인한다.

두 헤더의 판단을 단순한 AND나 OR로 합치는 대신 If-None-Match가 없을 때만 날짜 조건으로 들어가게 해야 한다. 직접 파서를 만든다면 단일 문자열 비교를 일반 규칙으로 사용하지 않는다. RFC의 If-None-Match에는 약한 비교, 태그 목록, 별표 규칙도 있다. 이번에는 유효한 강한 태그 하나씩만 보내 그 파싱 경계까지 검증하지 않았다.

이 실험에서 Werkzeug 3.1.9의 관찰은 RFC의 두 헤더 우선순위와 일치했다. 단일 고정 표현의 GET만 비교했으며 HEAD·쓰기 요청·If-Match와의 조합·Range·잘못된 헤더·브라우저 자동 캐시·CDN은 실행하지 않았다. 이 결과를 전체 조건부 요청 구현의 적합성으로 확대할 수는 없다.

← 전체 글 보기