HTTP ETag와 조건부 요청: If-None-Match의 304, If-Match의 412

ETag는 캐시 재검증과 동시 수정 방지에 함께 쓰입니다. RFC 9110과 RFC 9111을 기준으로 강한·약한 비교, If-None-Match와 304, If-Match와 412, 조건 헤더의 평가 순서를 예시 요청과 함께 정리합니다.

httpcacheetagconditional-requests

응답의 ETag 값은 두 가지 일에 쓰입니다. 하나는 캐시가 저장해 둔 응답을 아직 써도 되는지 원 서버에 묻는 일이고, 다른 하나는 두 클라이언트가 같은 리소스를 동시에 고칠 때 한쪽 변경이 조용히 덮어써지는 것을 막는 일입니다. 앞쪽은 If-None-Match와 304로, 뒤쪽은 If-Match와 412로 이루어집니다. 헤더 이름이 비슷하지만 비교 방식도, 실패했을 때의 응답도 다릅니다.

이 글은 HTTP 의미론 표준인 RFC 9110(2022년 6월)과 HTTP 캐싱 표준인 RFC 9111(2022년 6월)의 해당 절을 기준으로 씁니다. 특정 CDN, 브라우저, 웹 프레임워크의 동작은 다루지 않습니다. 글에 나오는 /notes/42 같은 리소스와 헤더 값은 설명을 위해 만든 예시입니다.

ETag는 클라이언트가 해석하지 않는 값

RFC 9110 8.8.3절은 엔티티 태그(entity tag)를 같은 리소스의 여러 표현을 구별하는 불투명한(opaque) 검증자로 정의합니다. 표현이 여러 개인 이유가 시간에 따른 상태 변화이든, 콘텐츠 협상으로 동시에 여러 표현이 유효하기 때문이든 상관없습니다. 값은 큰따옴표로 감싼 문자열이고, 앞에 약함 표시 W/가 붙을 수 있습니다.

ETag: "xyzzy"
ETag: W/"xyzzy"
ETag: ""

값을 어떻게 만들지는 서버가 정합니다. 8.8.3.1절은 내부 리비전 번호(콘텐츠 협상이 있다면 변이 식별자를 덧붙여서), 표현 내용에 대한 충돌 저항성 해시, 여러 파일 속성의 조합, 1초보다 정밀한 수정 시각을 예로 들고, 값이 불투명하므로 클라이언트가 태그의 구성 방식을 알 필요가 없다고 말합니다. 그러니 클라이언트는 "v7"을 보고 버전 번호라고 짐작해 숫자를 올려 보내거나, 해시라고 가정해 직접 계산해 비교하면 안 됩니다. 받은 값을 그대로 저장했다가 그대로 돌려보내는 것이 전부입니다.

같은 절은 변경을 합리적이고 일관되게 감지할 수 있는 표현이라면 원 서버가 ETag를 보내야 한다(SHOULD)고 권합니다. 예전 정의에서는 따옴표 안을 quoted-string으로 다뤘기 때문에 백슬래시를 이스케이프로 해석하는 수신자가 있을 수 있어서, 서버는 태그에 백슬래시를 쓰지 않는 편이 좋다는 주의도 붙어 있습니다.

강한 ETag와 약한 ETag

RFC 9110 8.8.1절의 정의는 이렇습니다.

  • 강한 검증자(strong validator): GET에 대한 200 응답 본문에서 관찰할 수 있는 표현 데이터가 바뀔 때마다 값이 바뀝니다.
  • 약한 검증자(weak validator): 표현 데이터가 바뀌어도 값이 바뀌지 않을 수 있습니다. 1초 단위 시각처럼 계산 방식에 한계가 있거나, 서버가 스스로 정한 기준으로 여러 표현을 “같은 것”으로 묶고 싶을 때 생깁니다.

8.8.3절에 따르면 ETag는 기본이 강한 검증자이고, 만드는 방식이 강한 검증자의 조건을 다 채우지 못하면 원 서버는 반드시 W/를 붙여야 합니다(MUST). 8.8.1절은 gzip을 적용한 표현과 적용하지 않은 표현에 같은 검증자를 보내면 그 검증자는 약한 것이라는 예도 듭니다. 표현 데이터가 다르기 때문입니다. 콘텐츠 협상과 ETag의 관계, 그리고 응답을 고르는 캐시 키는 HTTP Vary 헤더와 캐시 키에서 따로 다뤘습니다.

두 태그를 비교하는 함수도 두 가지입니다(8.8.3.2절).

  • 강한 비교: 두 태그가 모두 약하지 않고, 따옴표 안의 문자열이 한 글자씩 일치해야 같습니다.
  • 약한 비교: W/ 여부와 상관없이 따옴표 안의 문자열이 한 글자씩 일치하면 같습니다.
태그 1 태그 2 강한 비교 약한 비교
W/"1" W/"1" 불일치 일치
W/"1" W/"2" 불일치 불일치
W/"1" "1" 불일치 일치
"1" "1" 일치 일치

이 표는 RFC 9110의 표 3을 옮긴 것입니다. 첫 줄이 실무에서 중요합니다. 강한 비교에서는 약한 태그끼리 문자열이 같아도 일치하지 않습니다. 8.8.1절이 강한 검증자는 모든 조건부 요청에 쓸 수 있고, 약한 검증자는 캐시 항목 검증처럼 이전 표현 데이터와 정확히 같을 필요가 없는 경우에만 쓸 수 있다고 정리하는 이유가 여기 있습니다.

조건부 요청의 두 용도

RFC 9110 13절은 조건부 요청을 대상 리소스에 메서드를 적용하기 전에 검사할 전제 조건(precondition)을 요청 헤더로 지정한 요청이라고 정의합니다. 같은 절은 조건부 GET이 HTTP 캐시를 갱신하는 가장 효율적인 방법이고, PUT이나 DELETE 같은 상태 변경 메서드에 조건을 걸면 한 클라이언트가 병렬로 작업하던 다른 클라이언트의 작업을 실수로 덮어쓰는 “lost update” 문제를 막을 수 있다고 설명합니다.

If-None-Match If-Match
주된 용도 캐시 재검증(조건부 GET) 상태 변경 메서드의 덮어쓰기 방지
태그 비교 약한 비교 강한 비교
조건이 거짓일 때 GET·HEAD는 304, 그 밖의 메서드는 412 412, 또는 같은 변경이 이미 반영된 것으로 보이면 2xx
평가하는 쪽 캐시 또는 원 서버 원 서버(캐시와 중개자는 무시해도 됨)
* 값의 의미 현재 표현이 없을 때만 참 현재 표현이 있을 때만 참

근거는 RFC 9110 13.1.1절, 13.1.2절, 13.2.2절입니다. 아래에서 두 경우를 따로 봅니다.

캐시 재검증: If-None-Match와 304

클라이언트가 처음 받은 응답이 다음과 같다고 해 봅시다.

GET /notes/42 HTTP/1.1
Host: api.example.com
Accept-Encoding: gzip
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v7"
Vary: Accept-Encoding

{"text": "draft"}

RFC 9111 4.3절에 따르면 캐시가 저장된 응답을 그대로 쓸 수 없을 때(예를 들어 신선하지 않을 때) 조건부 요청으로 다음 서버에 확인을 맡길 수 있고, 이를 검증 또는 재검증이라고 부릅니다. 저장된 응답에 엔티티 태그가 있었다면 캐시는 검증 요청에 그 태그를 반드시 넣어야 합니다(MUST, 4.3.1절). RFC 9110 13.1.2절도 엔티티 태그가 있는 저장 응답을 갱신하려는 클라이언트는 GET 요청에 그 태그 목록을 담은 If-None-Match를 만들어야 한다(SHOULD)고 말합니다.

GET /notes/42 HTTP/1.1
Host: api.example.com
Accept-Encoding: gzip
If-None-Match: "v7"

If-None-Match는 약한 비교를 씁니다(MUST, 13.1.2절). 약한 태그도 캐시 검증에는 쓸 수 있기 때문입니다. 목록의 태그 중 하나가 선택된 표현의 태그와 일치하면 조건은 거짓이고, 원 서버는 메서드를 실행하지 않고 GET·HEAD라면 304를 보내야 합니다(MUST).

HTTP/1.1 304 Not Modified
Date: Tue, 13 Oct 2026 04:05:00 GMT
ETag: "v7"
Vary: Accept-Encoding

304에는 본문이 없습니다. RFC 9110 15.4.5절은 304가 헤더 섹션 끝에서 끝나며 콘텐츠나 트레일러를 담을 수 없다고 정합니다. 대신 같은 요청에 200을 보냈다면 포함했을 Content-Location, Date, ETag, Vary, Cache-Control, Expires를 보내야 합니다(MUST). 위 304 예시에는 Date가 들어 있습니다.

304를 받은 캐시는 저장된 응답 중 갱신할 대상을 고르고, 그 응답의 헤더를 304에 담긴 헤더로 갱신한 뒤 다시 씁니다(RFC 9111 4.3.3절, 4.3.4절). 304에 강한 검증자가 있으면 같은 강한 검증자를 가진 저장 응답이 갱신 대상이고, 그런 응답이 하나도 없으면 그 304로 아무것도 갱신해서는 안 됩니다(MUST NOT). 반대로 본문이 있는 전체 응답이 오면, 조건부 요청에서 지목한 저장 응답 중 쓸 만한 것이 없다는 뜻이므로 캐시는 그 전체 응답으로 요청을 처리해야 합니다(MUST).

304는 “읽기 요청에 대해, 이미 가진 표현을 계속 쓰라”는 응답입니다. 쓰기 요청이 성공했다는 신호로 쓰는 상태 코드가 아닙니다. GET·HEAD가 아닌 메서드에서 If-None-Match가 거짓이면 응답은 412입니다.

캐시가 검증 요청을 보낼 때는 If-Modified-Since도 함께 보내는 경우가 많습니다. RFC 9111 4.3.1절은 엔티티 태그가 더 낫더라도 엔티티 태그 조건을 모르는 오래된 중개자를 위해 두 검증자를 함께 만드는 경우가 대부분이라고 적습니다. 이때 두 헤더가 함께 오면 어떻게 되는지는 아래 평가 순서에서 다룹니다.

동시 수정 방지: If-Match와 412

두 클라이언트 A와 B가 같은 메모를 고치는 상황을 RFC 9110의 규칙대로 따라가 보겠습니다. 실제로 관찰한 사고가 아니라 13절과 13.1.1절의 의미를 순서대로 적용한 예시입니다.

  1. A와 B가 각각 GET /notes/42를 보내고, 둘 다 ETag: "v7"인 표현을 받습니다.
  2. A가 수정한 내용을 If-Match: "v7"과 함께 PUT합니다. 현재 태그가 "v7"이므로 조건은 참이고, 서버는 내용을 저장합니다. 이제 현재 태그는 "v8"입니다.
  3. B도 자기가 수정한 내용을 If-Match: "v7"과 함께 PUT합니다.
  4. 서버는 "v7"과 현재 태그 "v8"을 강한 비교로 맞춰 보고 조건이 거짓이라고 판단합니다. 메서드를 실행하지 않고 412로 답합니다.

조건 없이 3번의 PUT을 보냈다면 B의 내용이 A의 변경을 덮어썼을 것이고, A는 그 사실을 알 길이 없습니다. RFC가 lost update라고 부르는 상황이 이것입니다.

A의 요청과 응답은 다음과 같습니다.

PUT /notes/42 HTTP/1.1
Host: api.example.com
Content-Type: application/json
If-Match: "v7"

{"text": "edited by A"}
HTTP/1.1 204 No Content
ETag: "v8"

PUT 성공 응답에 ETag를 넣는 데는 조건이 있습니다. RFC 9110 9.3.4절은 요청 내용을 아무 변환 없이 그대로 저장했고 그 값이 새 표현을 반영할 때만 성공 응답에 ETag 같은 검증자를 보낼 수 있다고 정합니다(MUST NOT … unless). 서버가 저장하면서 내용을 고친다면 위 응답에서 ETag를 빼야 합니다.

B의 요청과 응답입니다.

PUT /notes/42 HTTP/1.1
Host: api.example.com
Content-Type: application/json
If-Match: "v7"

{"text": "edited by B"}
HTTP/1.1 412 Precondition Failed

412는 요청 헤더에 준 조건 중 하나 이상이 서버에서 거짓으로 평가됐다는 뜻입니다(15.5.13절). 이후 B가 할 일은 RFC가 정하지 않습니다. 흔한 선택은 다시 GET해서 "v8" 표현을 받고, 사용자에게 충돌을 보여 주거나 내용을 합친 뒤 If-Match: "v8"로 다시 보내는 것입니다.

If-Match에 관해 RFC 9110 13.1.1절이 정한 내용을 정리하면 다음과 같습니다.

  • 원 서버는 강한 비교를 써야 합니다(MUST). 클라이언트가 이 조건으로 표현 데이터가 조금이라도 바뀌었으면 메서드가 적용되지 않기를 바라기 때문입니다. 앞의 비교 표에서 따라 나오는 결과로, 서버가 약한 태그만 내준다면 그 태그를 그대로 If-Match에 넣어도 조건은 참이 될 수 없습니다.
  • 조건이 거짓이면 원 서버는 메서드를 실행해서는 안 됩니다(MUST NOT). 실패를 알리는 방법으로 412를 보낼 수 있고(MAY), 상태 변경 요청이 이미 반영된 것으로 보이면 2xx로 답할 수도 있습니다(MAY). 이전 응답이 유실되어 클라이언트가 성공을 모르고 같은 요청을 다시 보낸 경우가 RFC가 드는 예입니다.
  • 그 2xx 허용은 효율적이지만, 비슷하되 서로 협력하지 않는 변경 요청이 여러 개 오는 경우 위험이 있습니다. RFC는 원자적이지 않은 증가처럼 공유 리소스를 세마포어로 쓰는 예를 들고, 그런 리소스라면 안전하지 않은 메서드의 실패한 조건마다 412를 보내는 편이 낫다고 말합니다.
  • 캐시나 중개자는 If-Match를 무시해도 됩니다(MAY). 이 조건은 원 서버에서만 의미가 있기 때문입니다.
  • 값이 *이면 원 서버에 대상 리소스의 현재 표현이 있을 때 참입니다. *와 다른 값을 한 목록에 섞는 것은 문법상 잘못입니다.

ETag는 잠금이 아니다

If-Match는 리소스를 미리 잡아 두는 잠금이 아닙니다. 요청이 도착한 시점의 현재 상태를 조건과 비교할 뿐이고, A가 GET한 뒤 PUT할 때까지 다른 클라이언트의 쓰기를 막지 않습니다. 막아 주는 것은 오래된 상태를 전제로 한 쓰기가 적용되는 일입니다.

서버 구현에는 한 가지가 더 필요합니다. 위 순서에서 A와 B의 PUT이 거의 동시에 도착하면, 서버가 두 요청 모두에 대해 “현재 태그는 "v7"“이라고 확인한 다음 각각 저장할 수도 있습니다. 그러면 두 조건이 모두 참으로 평가되고 결국 하나가 다른 하나를 덮어씁니다. 그래서 태그 비교와 저장은 서버 안에서 하나의 원자적 단계로 처리되어야 합니다. 이것은 RFC가 보장하는 내용이 아니라 RFC의 의미를 지키기 위한 구현 쪽 요구를 추론한 것입니다. RFC 9110 9.3.4절은 HTTP가 리소스 상태를 어떻게 저장하는지 정의하지 않는다고 밝힙니다. 데이터베이스라면 버전 열을 조건으로 건 UPDATE나 트랜잭션 같은 방식이 쓰일 수 있지만, 어느 방식을 쓸지는 HTTP 바깥의 선택입니다.

새로 만들 때만 허용하기: If-None-Match: *

If-None-Match: *는 원 서버에 대상 리소스의 현재 표현이 있으면 거짓입니다(13.1.2절). RFC는 이를 클라이언트가 리소스에 아직 표현이 없다고 믿을 때 PUT 같은 안전하지 않은 메서드가 기존 표현을 실수로 바꾸지 않도록 하는 용도로 설명하고, 여러 클라이언트가 처음 표현을 동시에 만들려 할 때 생기는 lost update의 변형이라고 부릅니다.

PUT /notes/43 HTTP/1.1
Host: api.example.com
Content-Type: application/json
If-None-Match: *

{"text": "first version"}
  • /notes/43에 현재 표현이 없으면 조건은 참이고, PUT이 표현을 새로 만들면 원 서버는 201 Created로 알려야 합니다(MUST, 9.3.4절).
  • 다른 클라이언트가 먼저 만들었다면 조건은 거짓이고, 메서드가 GET·HEAD가 아니므로 412입니다(MUST, 13.1.2절).

여기서도 “있는지 확인”과 “만들기”가 서버 안에서 원자적으로 이루어져야 한다는 점은 앞 절과 같은 구현 쪽 추론입니다.

날짜 조건과 평가 순서

마지막 수정 시각으로 된 조건도 있습니다. If-Unmodified-Since는 클라이언트에게 엔티티 태그가 없을 때 If-Match와 같은 목적으로 쓰고(13.1.4절), If-Modified-Since는 엔티티 태그가 없는 캐시 표현을 갱신하는 데 쓰입니다(13.1.3절). ETag 조건과 함께 올 때의 규칙은 짧습니다.

  • 요청에 If-Match가 있으면 If-Unmodified-Since는 무시해야 합니다(MUST, 13.1.4절).
  • 요청에 If-None-Match가 있으면 If-Modified-Since는 무시해야 합니다(MUST, 13.1.3절). GET·HEAD가 아닌 메서드에 붙은 If-Modified-Since도 무시해야 합니다.

RFC는 엔티티 태그 조건이 날짜 조건보다 더 정확한 대체물이고, 둘을 함께 보내는 것은 엔티티 태그 조건을 구현하지 않은 오래된 중개자와 맞추기 위해서라고 설명합니다.

조건 헤더가 여러 개 오면 RFC 9110 13.2.2절의 순서대로 평가해야 합니다(MUST).

순서 조건 평가하는 경우 참일 때 거짓일 때
1 If-Match 수신자가 원 서버 3으로 412, 단 상태 변경이 이미 성공했다고 판단할 수 있으면 예외
2 If-Unmodified-Since 수신자가 원 서버이고 If-Match가 없을 때 3으로 1과 같음
3 If-None-Match 있을 때 5로 GET·HEAD는 304, 그 밖은 412
4 If-Modified-Since GET·HEAD이고 If-None-Match가 없을 때 5로 304
5 If-Range GET이고 Range와 함께 올 때 Range가 적용되면 206 Range를 무시하고 200
6 (없음) 그 밖 메서드를 실행하고 결과대로 응답

If-Range는 이 글의 범위 밖이라 표에만 넣었습니다.

평가 시점도 정해져 있습니다. 13.2.1절에 따르면 캐시나 원 서버는 일반적인 요청 검사를 마친 뒤, 요청 내용을 처리하거나 메서드의 동작을 수행하기 직전에 조건을 평가해야 합니다(MUST). 또 조건을 뺀 같은 요청에 대한 응답이 2xx나 412가 아닌 다른 상태 코드였을 것이라면, 서버는 받은 조건을 모두 무시해야 합니다. 큰 처리 없이 알 수 있는 리다이렉트나 실패가 조건 평가보다 먼저라는 뜻입니다.

평가하는 주체에 대한 규칙도 있습니다.

  • 대상 리소스의 원 서버도 아니고 그 요청의 캐시 역할도 할 수 없는 서버는 이 조건 헤더들을 평가해서는 안 되며(MUST NOT), 요청을 전달한다면 헤더도 함께 전달해야 합니다(13.2.1절).
  • 캐시에는 If-Match와 If-Unmodified-Since가 적용되지 않습니다. 캐시가 저장된 200이나 206 응답으로 요청을 처리할 수 있으면 If-None-Match 같은 해당 조건을 저장 응답의 검증자로 평가하는 것이 권장됩니다(SHOULD, RFC 9111 4.3.2절).
  • CONNECT, OPTIONS, TRACE처럼 표현을 고르거나 바꾸지 않는 메서드에 붙은 조건 헤더는 무시해야 합니다(13.2.1절).

구현하고 시험할 때 확인할 것

  • 태그 값을 클라이언트가 해석하거나 만들어 내지 않고, 받은 값을 그대로 돌려보내는가.
  • 강한 검증자의 조건을 채우지 못하는 방식으로 만든 태그에 W/를 붙이는가. gzip 표현과 원문 표현이 같은 강한 태그를 공유하지 않는가.
  • If-None-Match 처리에 약한 비교를, If-Match 처리에 강한 비교를 쓰는가. 테스트 데이터에 RFC 표 3의 네 쌍을 넣어 볼 만합니다.
  • 일치하는 If-None-Match가 붙은 GET은 본문 없는 304로, 같은 조건이 붙은 PUT은 412로 끝나는가.
  • 200 응답에 있었을 ETag, Vary, Date, Cache-Control, Expires, Content-Location이 304에도 나가는가.
  • 오래된 태그를 단 If-Match PUT이 412를 받고, 리소스가 바뀌지 않았는가. 같은 태그로 두 PUT을 동시에 보냈을 때 하나만 성공하는가.
  • 실패한 If-Match에 2xx를 허용한다면 “이미 반영된 것으로 보이는” 판단 기준이 명확한가. 증가나 카운터처럼 협력하지 않는 비슷한 요청이 몰리는 리소스라면 항상 412를 보내는가.
  • If-None-Match: *가 붙은 PUT이 리소스가 없을 때 201, 있을 때 412를 받는가.
  • If-Match와 If-Unmodified-Since, If-None-Match와 If-Modified-Since를 함께 보냈을 때 날짜 조건이 무시되는가.
  • 조건 없이 보냈어도 리다이렉트나 412가 아닌 오류로 끝났을 요청에 조건 헤더를 붙였을 때, 그 리다이렉트나 오류 응답이 그대로 나오는가.
  • PUT 성공 응답의 ETag가 저장된 그대로의 새 표현을 가리키는가. 저장 중 내용을 바꾸는 서버라면 ETag를 빼는가.

출처

확인일: 2026-10-13

블로그로