ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 304 Not Modified는 오류일까: ETag로 갱신 여부 읽기
    Programming 2026. 9. 20. 10:53
    728x90
    반응형

    같은 물결 무늬의 나무 토큰 두 개와 다른 무늬 토큰, 닫힌 봉투
    작은 식별 정보를 대조해 내용 변경을 확인하는 AI 생성 개념 이미지입니다.

    304 응답을 보면 오류로 재시도하기 전에 요청의 검증자와 기존 캐시 본문을 함께 확인하세요.

    Network 창에 304가 보이면 200이 아니라는 이유로 실패라고 생각하기 쉽습니다. 하지만 304 Not Modified는 조건부 GET이나 HEAD에서 이미 가진 버전을 계속 써도 된다는 응답일 수 있습니다. 서버가 본문을 보내다 실패한 상황과는 다릅니다.

    문제는 숫자만 따로 볼 때 생깁니다. 무엇과 비교해서 바뀌지 않았다는 것인지, 그 버전의 본문을 누가 가지고 있는지까지 알아야 응답을 해석할 수 있습니다. 이 글은 2026년 9월 13일 MDN과 Fetch 명세를 기준으로 설명합니다. 요청과 응답은 가상 예시이며 실제 서버에서 캡처하거나 실행하지 않았습니다.

    ETag는 서버가 붙인 버전 식별자입니다

    ETag는 특정 응답 표현의 버전을 구분하는 값입니다. 서버가 응답 헤더로 보내고, 클라이언트는 저장한 응답과 함께 보관할 수 있습니다. 값의 생성 방식은 하나로 정해져 있지 않습니다. 반드시 파일의 MD5라고 해석하거나 숫자를 시간으로 읽어서는 안 됩니다. ETag의 의미와 형식

    다음은 가상의 공개 JSON 문서를 처음 받은 경우입니다. 응답 본문과 핵심 헤더만 간추린 예시이며 실제 HTTP 메시지의 모든 헤더를 나열한 것은 아닙니다.

    HTTP/1.1 200 OK
    Content-Type: application/json
    Cache-Control: no-cache
    ETag: "guide-7"
    
    {"title":"브라우저 캐시 안내","revision":7}
    

    여기서 저장할 대상은 guide-7이라는 문자열만이 아닙니다. 그 버전에 대응하는 본문도 함께 있어야 합니다. 식별자는 본문을 다시 만들어 주는 데이터가 아니기 때문입니다.

    예시의 no-cache는 재사용 전에 확인하도록 한 조건입니다. 어떤 Cache-Control을 고를지와 ETag로 어떻게 버전을 검증하는지는 연결되지만 같은 기능은 아닙니다. ETag만 있다고 모든 재요청이 서버에 확인을 보내는 것도 아닙니다. 캐시의 신선도와 요청의 캐시 모드도 관여합니다. 브라우저 요청의 캐시 모드

    다음 GET에서는 If-None-Match로 비교를 요청합니다

    클라이언트가 저장한 버전을 확인할 때는 응답의 ETag 값을 요청의 If-None-Match에 넣을 수 있습니다. 앞의 공개 문서에 대한 핵심 요청 헤더는 다음과 같습니다.

    GET /guide.json HTTP/1.1
    Host: api.example.test
    If-None-Match: "guide-7"
    

    이름을 풀어 보면 나열한 태그와 일치하지 않을 때 본문을 보내 달라는 조건입니다. GET에서 서버의 현재 태그가 guide-7과 같다면 조건이 충족되지 않으므로 본문을 다시 보내지 않고 304로 답할 수 있습니다. 반대로 현재 버전이 달라졌다면 그에 맞는 본문을 보내는 흐름입니다. If-None-Match의 비교

    같은 경우의 응답 핵심은 다음처럼 볼 수 있습니다. 아래 304에는 JSON 본문을 넣지 않습니다.

    HTTP/1.1 304 Not Modified
    Cache-Control: no-cache
    ETag: "guide-7"
    

    304는 본문을 포함하지 않는 응답입니다. 관련 캐시 메타데이터는 전달될 수 있고, 클라이언트는 검증된 저장 본문을 사용합니다. 그래서 응답 크기가 작다는 것이 데이터가 사라졌다는 뜻은 아닙니다. 304의 본문과 응답 헤더

    이 흐름에서 서버가 확인한 것은 조건에 해당하는 표현의 버전입니다. 사용자의 화면이 실제로 갱신됐는지, 다른 API의 데이터까지 같았는지, 앱 전체가 최신 배포인지까지 보증한 것은 아닙니다.

    내용이 바뀌면 새 본문과 검증자가 함께 와야 합니다

    이제 서버의 문서 제목이 바뀌고 revision도 8이 됐다고 하겠습니다. 클라이언트는 여전히 guide-7을 가지고 조건부 요청을 보냅니다. 서버가 현재 표현에 guide-8을 사용한다면 예시의 정상 흐름은 새 200 응답입니다.

    HTTP/1.1 200 OK
    Content-Type: application/json
    Cache-Control: no-cache
    ETag: "guide-8"
    
    {"title":"브라우저 캐시 진단 안내","revision":8}
    

    클라이언트는 새 본문과 새 태그를 함께 저장합니다. 이후 guide-8을 확인하는 요청에서는 다시 304가 가능해집니다. 첫 200, 같은 버전의 304, 변경 뒤 200이라는 세 경우를 나눠 보면 테스트에서 무엇을 바꿔야 하는지도 분명해집니다.

    버전이 달라졌는데 서버가 ETag를 고정 문자열로 유지하면 잘못된 304가 나올 수 있습니다. 반대로 내용이 같은데 매 요청마다 태그를 불필요하게 바꾸면 본문 재전송이 계속될 수 있습니다. 헤더의 존재가 아니라 표현과 식별자의 대응을 검토해야 합니다.

    예시의 revision 필드와 ETag 문자열이 비슷한 것은 읽기 쉽게 만든 설정입니다. 실제 서비스에서 JSON 안의 revision을 보고 임의로 ETag를 조립해 보내라는 뜻은 아닙니다. 서버가 준 검증자를 그 형식 그대로 취급하는 편이 안전합니다.

    브라우저가 관리하는 캐시와 수동 요청을 구분합니다

    일반적인 브라우저 fetch에서는 HTTP 캐시 계층이 저장된 응답을 찾고 조건부 요청을 처리할 수 있습니다. 재검증으로 네트워크에서 304를 받으면 저장된 응답의 헤더를 갱신한 뒤 저장된 응답을 사용하도록 Fetch 명세에 정의돼 있습니다. Fetch의 304 재검증 처리

    이 때문에 Network에 표시된 원시 네트워크 상태가 코드의 response.status와 반드시 같다고 가정하면 안 됩니다. 브라우저가 관리하는 재검증을 거쳐 본문을 가진 응답을 전달받는 경우와 앱이 조건부 헤더를 직접 넣어 원시 304를 다루는 경우는 다릅니다.

    가상의 같은 출처 문서를 브라우저의 캐시 기능에 맡겨 읽는 예시는 다음과 같습니다. 서버의 조건부 응답 처리가 올바르고 서비스 워커가 별도 응답을 만드는 상황은 없다고 가정합니다.

    const response = await fetch("/guide.json", { cache: "no-cache" });
    
    if (!response.ok) {
      throw new Error("HTTP " + response.status);
    }
    const guide = await response.json();
    

    여기서는 If-None-Match 값을 직접 만들어 넣지 않습니다. 브라우저가 대응하는 캐시를 찾으면 검증을 시도하고, 없다면 일반 요청을 할 수 있습니다. 코드는 최종적으로 전달된 응답의 상태와 본문을 처리합니다. Request.cache의 no-cache

    반면 직접 관리하는 HTTP 클라이언트에서 304를 받았다면 그 클라이언트가 저장 본문을 재사용해야 합니다. 원시 304 자체에 JSON 본문이 있다고 생각하고 파싱해서는 안 됩니다. 본문 없이 태그만 보관한 구현이라면 사용할 데이터가 없는 상태를 먼저 해결해야 합니다.

    curl로 확인할 때는 비교할 값을 직접 맞춥니다

    다음은 앞의 가상 서버가 아직 guide-7 버전일 때 비교하는 명령 형태입니다. 실제 실행하지 않았으며 .test 주소는 동작하는 공개 API가 아닙니다.

    curl -i 'https://api.example.test/guide.json' \
      -H 'If-None-Match: "guide-7"'
    

    서버가 이 조건을 평가해 304를 반환하더라도 이 명령이 브라우저에 저장된 본문을 찾아 출력해 주지는 않습니다. 여기서는 응답 헤더와 상태를 확인하는 요청을 직접 만든 것입니다. 브라우저의 캐시 재사용까지 검증한 명령이 아닙니다.

    비교 실험을 설계한다면 먼저 실제 200 응답에서 받은 ETag를 확인하고, 같은 URL과 표현 조건으로 그 값을 보내 봅니다. 이후 서버의 내용을 통제해서 바꿀 수 있는 테스트 환경에서 새 200과 새 태그가 오는지 확인합니다. 이 글의 guide-7을 아무 API에 넣고 304가 오기를 기대해서는 안 됩니다.

    인증 정보나 사용자별 응답이 있는 API라면 공개 예시처럼 단순히 비교해서도 안 됩니다. 사용자와 표현을 구분하는 캐시 키, Vary와 공유 제한, 권한 검사를 함께 봐야 합니다. 캐시 재검증이 인증을 대신하지는 않습니다.

    약한 태그와 다른 조건부 요청은 범위를 구분합니다

    ETag 앞의 W/는 약한 검증자를 나타냅니다. 의미상 같은 표현을 나타낼 수 있지만 바이트 단위로 완전히 같다는 보증으로 읽지는 않습니다. 파일 무결성 검증이나 범위 요청의 검증 요구와 일반 GET 캐시 확인을 같은 것으로 취급하지 않아야 합니다. 강한 검증자와 약한 검증자

    If-None-Match는 약한 비교를 사용합니다. 또한 GET·HEAD가 아닌 메서드에서 조건이 맞지 않는 경우에는 304와 다른 상태인 412 Precondition Failed가 관련될 수 있습니다. “조건부 요청은 일치하면 늘 304”라고 일반화하지 않습니다. 메서드별 조건 실패 처리

    Last-Modified와 If-Modified-Since로 확인하는 방식도 있습니다. 두 조건부 헤더가 함께 있을 때 지원되는 If-None-Match가 우선하므로, 날짜만 보고 왜 304가 나왔는지 판단하지 않아야 합니다. 이 글에서는 ETag가 있는 GET 흐름을 중심으로 봅니다.

    304 자체보다 잘못된 반복과 본문 연결을 찾습니다

    문제를 좁힐 때는 요청의 조건부 헤더, 비교한 응답의 ETag, 저장한 본문의 존재, 실제로 소비한 응답을 함께 봅니다. 내용이 바뀌었는데 304가 계속되면 검증자 생성과 표현 선택이 맞는지 확인합니다. 원시 304를 JSON으로 읽다가 실패한다면 클라이언트의 저장·재사용 설계를 확인할 차례입니다.

    304를 전부 오류로 집계해 무한 재시도하거나 모든 요청에 임의의 시간 값을 붙여 캐시를 회피하면 정상적인 검증 흐름까지 없앨 수 있습니다. 필요한 것은 304를 없애는 것이 아니라 같은 버전은 재사용하고 바뀐 버전은 새로 받는 동작입니다.

    Network의 304 한 줄만으로 성공도 실패도 끝까지 판정할 수는 없습니다. 어떤 본문을 검증했는지, 그 본문이 실제 화면이나 코드에 전달됐는지까지 연결해 확인하면 정상적인 캐시 적중과 진짜 갱신 오류를 구분할 수 있습니다.

    728x90
    반응형
Designed by Tistory.