← 컴퓨팅 인프라
실전 가이드DEEPER INTO TECHNOLOGY

배포했는데 옛 화면이 보인다면: HTTP 캐시와 304 재검증

배포했는데 사용자가 이전 응답을 볼 때, HTTP 캐시의 어느 조건부터 확인해야 할까?

먼저 읽는 핵심

배포 완료는 원점의 파일이 바뀌었다는 사실이고, 사용자가 받은 응답이 바뀌었다는 사실은 따로 확인해야 한다. 캐시가 아직 신선하다고 판단하면 원점에 묻지 않고 저장본을 돌려줄 수 있다. 같은 URL·같은 요청 조건에서 응답의 나이, 검증자, 본문을 비교하면 캐시 정책과 배포 경로 중 어디를 조사할지 좁힐 수 있다.

먼저 “옛 화면”이 어느 응답인지 찾는다

공지의 날짜를 고쳤는데 고객 한 명은 어제 내용을 본다. 개발자 PC에서는 새 날짜가 나온다. 이때 캐시 전체를 지우면 현상은 사라질 수 있지만, 다음 배포에서도 같은 문제가 생길지 알기 어렵다.

먼저 바뀌어야 하는 자원을 정한다. 공지 본문을 담은 HTML인지, 화면이 나중에 읽는 JSON인지, 문구가 포함된 JavaScript인지에 따라 비교할 URL이 다르다. 화면 전체를 하나의 파일처럼 생각하면 새 HTML과 옛 데이터가 섞인 상황을 놓친다.

이 글의 진단 대상은 HTTP 응답의 재사용이다. 서버 내부 데이터 캐시, Service Worker의 별도 저장 로직, 이미 열린 탭의 상태는 다른 조사 대상이다. 그림은 실제 제품 화면이 아닌 설명용 구성도이며, 아래 시간표는 가상 계산이다. 로컬 HTTP 실험은 별도 절에서 실행 범위를 밝힌다.

신선하다는 것은 “최신 배포와 같다”는 뜻이 아니다

원점은 콘텐츠를 제공하는 서버이고, 그 앞에는 여러 사용자가 함께 쓰는 CDN·프록시 캐시와 사용자별 브라우저 캐시가 있을 수 있다. HTTP 캐시는 저장한 응답을 다시 써도 되는지 판단한다. 신선한 응답은 다른 재사용 조건도 맞으면 원점 확인 없이 사용할 수 있다. 신선도가 끝난 응답은 보통 검증하거나 새로 가져와야 한다. 이는 RFC 9111의 신선도·검증 모델이다.

가상의 공개 공지 /notice가 다음 응답 헤더와 함께 저장됐다고 하자. 개인화가 없고, 캐시 키가 일치하며, 재검증을 강제하는 별도 지시도 없다고 가정한다.

Cache-Control: public, max-age=300
ETag: "notice-v1"

10:00에 생성한 응답을 10:01에 저장했고, 10:02에 원점을 v2로 배포했다면 10:03의 캐시에는 여전히 v1이 신선할 수 있다. 서버 파일이 바뀌는 사건과 캐시가 다시 확인하는 사건이 따로 일어나기 때문이다. max-age=300은 배포 후 5분을 뜻하지도, 모든 계층에서 받을 때마다 5분을 새로 시작한다는 뜻도 아니다.

Age가 240인 응답을 받았다면

다음은 응답이 생성·검증된 뒤 이미 일정 시간이 지난 경우다. Age는 그 나이를 전달하는 초 단위 값이며, 정확한 나이 계산에는 Date, 전송 지연, 캐시에 머문 시간도 쓰인다. RFC 9111 §4.2.3에 계산 규칙이 있다.

Cache-Control: public, max-age=300
Age: 240

전송 지연과 시계 오차를 무시하고 수신 시점의 나이를 정확히 240초로 둔 설명용 계산은 다음과 같다.

수신 후 경과 계산한 응답 나이 이 가정의 상태
0초 240초 신선, 남은 여유 60초
30초 270초 신선, 남은 여유 30초
60초 300초 신선도 경계 도달, 더 이상 fresh가 아님

판정식은 현재 나이 < 신선도 수명이다. 60초가 지나도 파일이 삭제되거나 다음 요청이 무조건 200이 되는 것은 아니다. 저장본이 남아 있으면 검증에 사용할 수 있다. 또한 stale 응답의 사용에는 별도 허용·금지 조건이 있으므로 “만료되면 절대 못 쓴다”로 일반화하지 않는다.

이 예시가 알려 주는 실무 판단은 간단하다. 사용자가 방금 받았다는 이유만으로 캐시의 나이가 0은 아니다. 반대로 Age가 없다는 사실만으로 원점에서 방금 생성됐다고 확정할 수도 없다. 원점 경로·응답 헤더·본문을 함께 확인한다.

no-cache와 no-store는 해결하는 문제가 다르다

다음은 인자가 없는 일반 응답 지시자의 요약이다. 자세한 의미는 MDN의 Cache-Control 문서에 연결돼 있다.

응답 지시자 맡기는 일 이것만으로 보장하지 않는 것
no-cache 저장본 재사용 전 성공적인 검증 저장 금지, 매번 본문 전체 전송
no-store 요청·응답의 캐시 저장 금지 이미 퍼진 모든 저장본의 회수
private 공유 캐시에 저장하지 않기 브라우저 저장 금지, 암호화
max-age=300 신선도 수명을 300초로 지정 배포 즉시 전파
s-maxage=60 공유 캐시의 수명 지정 브라우저에 같은 수명 적용
must-revalidate stale이 된 뒤 검증 없이 재사용하지 않기 fresh 상태에서도 매번 검증

공유 캐시에는 s-maxage가 max-age보다 우선하고 개인 캐시는 이를 무시한다. 따라서 두 계층이 같은 시간표로 움직인다고 가정하면 안 된다. 헤더를 아예 생략하는 것도 저장 금지 선언은 아니다. 조건에 따라 휴리스틱 캐싱이 가능하다.

선택은 자원의 역할에서 출발한다. 다음은 서비스별로 검증할 설계 예시이지 일괄 적용할 설정 목록이 아니다.

  • 같은 URL에서 내용이 바뀌는 공개 공지 HTML: 저장본을 활용하되 변경 확인이 필요하면 no-cache와 검증자를 검토한다.
  • 파일명이 내용 해시를 포함하고 같은 URL의 바이트를 다시 바꾸지 않는 정적 자산: 긴 신선도 수명을 검토한다. 새 파일을 올린 뒤 HTML이 그 새 URL을 가리켜야 한다.
  • 개인별 계정 화면: 공유 저장이 부적절하다면 private가 출발점이다. 기기 캐시에도 남기지 않을 요구가 있으면 no-store를 검토한다. 인증·접근 제어를 헤더로 대신하지 않는다.

모든 응답에 no-store를 넣으면 정책은 단순해져도 재사용의 이점을 포기할 수 있다. 반대로 긴 수명을 쓰려면 잘못된 콘텐츠를 어느 시간 안에 교체해야 하는지부터 정해야 한다. 이 절의 선택표는 성능 개선을 측정한 결과가 아니다.

ETag와 304: 본문을 보내지 않아도 검증 통신은 남는다

ETag는 응답 표현의 검증자다. 서버가 정하는 불투명한 값으로, 항상 파일 해시이거나 배포 번호인 것은 아니다. W/가 붙은 약한 검증자는 바이트까지 같다는 의미를 갖지 않는다. 이 구분은 MDN의 ETag 설명에서 확인할 수 있다.

아래는 흐름을 이해하기 위해 헤더를 줄인 가상 대화다. 첫 GET에서 본문 v1과 ETag: "notice-v1"을 받은 클라이언트가 이후 다음 조건을 보낸다.

GET /notice HTTP/1.1
If-None-Match: "notice-v1"

선택한 표현이 조건과 맞으면 GET에 대한 응답은 304이고, 바뀌어 맞지 않으면 이 정상 예시에서는 200과 새 본문을 받는다. RFC 9110 §13.1.2는 If-None-Match의 비교와 메서드별 응답을 정의한다. 다른 오류나 접근 거부가 없는 경우를 설명한 것이며 모든 요청의 결과가 200·304 둘뿐인 것은 아니다.

304의 정의에 따르면 본문을 담지 않는다. 수신한 캐시는 해당 저장 응답의 메타데이터를 갱신하고 본문을 재사용할 수 있다. 그래서 개발자 도구에서 “304인데 화면 내용이 있다”는 현상은 모순이 아니다. 본문을 다시 받지 않은 것이다.

여기에는 두 가지 함정이 있다. 첫째, 304여도 검증 요청과 응답의 왕복은 존재한다. 둘째, 304는 배포 시스템 전체가 최신이라는 증명서가 아니다. 요청을 처리한 경로가 옛 서버를 가리키거나 검증자를 잘못 생성했다면 그 경로를 더 조사해야 한다. 무슨 조건을 보냈고 누가 그 조건에 답했는지가 상태 코드보다 먼저다.

직접 확인한 로컬 원점 실험

단일 파일 실험 내려받기를 저장한 뒤 다음과 같이 실행할 수 있다. Node.js 22 이상, 추가 패키지는 필요 없다.

node http-cache-origin-lab.mjs

이 파일은 루프백의 임시 포트에 작은 원점 서버를 열고 HTTP 요청을 보낸 뒤 닫는다. 서버 내부의 가상 JSON을 v1에서 v2로 바꾸며 비교한다. 실행 후 포트가 계속 열려 있는 대화형 서버가 아니다.

2026-10-12, Node.js v22.23.1·macOS arm64에서 실제 루프백 HTTP 요청 8건의 단언이 통과했다. 핵심 관측은 다음과 같다.

요청 조건 관측 응답 의미
최초 GET 200, {"version":1} 본문과 검증자를 받음
같은 ETag로 GET 304, 빈 본문 바뀌지 않은 표현의 조건 확인
같은 값에 W/를 붙여 GET 304, 빈 본문 이 GET 조건의 약한 비교
서버를 v2로 바꾼 뒤 옛 ETag로 GET 200, {"version":2} 옛 조건과 다른 표현 수신
새 ETag로 GET 304, 빈 본문 v2의 조건 확인

나머지는 no-store 헤더 반환, 없는 경로 404, 지원하지 않는 메서드 405다. 전체 관측 JSON에서 응답을 비교할 수 있다. no-store 시험은 헤더를 확인했을 뿐, 브라우저 디스크에 아무것도 저장되지 않는지 검증한 시험이 아니다.

실험 클라이언트에는 HTTP 캐시가 없다. ETag를 직접 다음 요청에 넣었다. 브라우저·CDN의 캐시 적중, 300초 만료, TLS, 실제 배포나 지연 개선은 측정하지 않았다. 서버도 단일 ETag 비교에 한정한 교육용이다. 검증자 목록·*·여러 선행 조건·콘텐츠 협상까지 구현한 운영 서버로 사용하면 안 된다.

배포 장애에서는 동일 URL을 세 번 비교한다

가상의 공지 교체 장애를 조사한다면 다음 순서를 쓸 수 있다. 아래 curl 명령은 독자가 자신의 공개 점검 URL로 바꿔 실행할 절차이며, 이 글에서 실제 운영 서비스에 실행한 결과가 아니다. 민감한 계정 응답은 이 공개 점검 예시에서 제외한다.

  1. 증상이 난 자원의 원래 URL을 일반 GET으로 받는다. 응답 헤더와 본문을 각각 저장한다.
  2. 같은 URL·언어·인증 조건을 유지하고 재검증을 요청한다. 새 쿼리 문자열은 붙이지 않는다.
  3. 기존에 마련된 승인된 원점 점검 경로가 있다면 같은 표현과 비교한다. 없다면 CDN 로그·배포 경로 기록으로 연결하고, 임의로 원점 주소를 공개하지 않는다.
curl -sS -D normal.headers -o normal.body \
  'https://example.com/notice'

curl -sS -D revalidate.headers -o revalidate.body \
  -H 'Cache-Control: no-cache' \
  'https://example.com/notice'

curl 공식 매뉴얼의 -D는 헤더를 파일로 쓰고, -o는 본문을 파일로 쓰며, -H는 요청 헤더를 추가한다. 이 명령은 GET이다. -I로 바꾸면 HEAD가 되므로 본문 비교가 빠진다. 예시에는 리다이렉트 자동 추적이 없다. 3xx를 받았다면 Location과 최종 URL을 먼저 확인한다.

요청의 no-cache는 재검증을 요구하는 신호다. “모든 CDN을 무조건 우회해 원점 IP로 접속”하는 옵션은 아니다. curl 두 번 실행도 브라우저의 기존 저장 상태를 재현하지 않는다. 브라우저에만 증상이 있다면 Network 패널에서 해당 요청의 URL·상태·응답 헤더와 실제 응답 내용을 함께 비교해야 한다. 제품별 강력 새로고침의 세부 동작은 여기서 검증하지 않았다.

다음 양식은 조사자가 직접 채우는 진단 기록이다. 응답 원문 전체 대신 바뀌어야 할 문구나 안전한 버전 표식만 남겨도 비교할 수 있다.

대상 URL / 기대한 변경 / 배포 시각:
요청 조건: 메서드 / 언어 / 로그인 유무 / 조건부 헤더
일반 GET: 상태 / Date / Age / Cache-Control / ETag / Vary / 본문 표식
재검증 요청: 같은 항목
원점 비교: 확인 경로 / 본문 표식 / 확인 못 했으면 unknown
차이가 난 계층과 다음 조사:
조치 후 다시 비교한 시각과 결과:

관측을 해석할 때는 다음처럼 분기한다. 각 문장은 원인 확정이 아닌 다음 확인의 우선순위다.

  • 일반 GET은 v1, 재검증 요청은 v2: 저장 응답의 수명·재검증 정책을 먼저 살핀다. 요청별로 다른 서버를 탔을 가능성도 남는다.
  • 공유 경로는 v1, 원점 점검은 v2: CDN의 캐시 키·보관 정책·무효화 대상과 실제 원점 연결을 비교한다.
  • 원점에서도 v1: 배포 산출물·라우팅·서버 내부 데이터 갱신을 먼저 확인한다. 브라우저 삭제만으로 해결할 문제로 단정하지 않는다.
  • HTTP 본문은 v2인데 화면만 v1: 화면이 읽는 다른 API, 열린 탭의 상태, Service Worker 등 HTTP 응답 이후 경로를 추적한다.

캐시 삭제 전에 놓치기 쉬운 두 가지

첫째, 같은 URL이어도 같은 표현인지 확인한다. 언어에 따라 내용이 바뀐다면 Vary: Accept-Language 같은 구분이 필요할 수 있다. 언어별 응답을 나누지 않으면 “옛 내용”처럼 보이는 것이 사실 다른 표현의 재사용일 수 있다. 개인화 응답은 공유 저장 가능 여부부터 별도로 판단한다. MDN의 Vary 설명이 이 조건을 다룬다.

둘째, 새 헤더는 새 응답을 받아야 도착한다. 옛 응답이 긴 수명으로 저장된 상태에서 원점 헤더만 no-cache로 바꿔도 기존 저장본이 즉시 그 사실을 알지는 못한다. MDN의 저장 응답 삭제 설명처럼 이미 배포된 정책의 수명도 고려해야 한다.

따라서 급한 공지 수정은 관리하는 CDN의 무효화 기능, 실제 캐시 키, 브라우저에 남은 응답의 수명을 함께 점검할 일이다. CDN purge가 성공했다고 모든 브라우저의 저장본까지 지워졌다고 보고하지 않는다. 특정 CDN의 purge 방식과 기본값은 이 글의 확인 범위 밖이다.

정적 자산의 내용 해시 URL도 혼자서는 충분하지 않다. 새 JavaScript 파일을 올려도 캐시된 HTML이 옛 파일명을 가리키면 사용자는 그 파일을 계속 요청한다. 진입 HTML의 재검증과 새 자산의 배포 순서를 한 묶음으로 설계해야 한다. 반대로 오류를 찾으려고 매 요청에 임의의 쿼리를 붙이면 원래 URL의 문제를 우회해 진단 근거가 사라질 수 있다.

다음 배포 전에 이 글의 기록 양식으로 HTML 한 개와 그 HTML이 읽는 주요 자원 한 개를 비교해 보자. 어떤 응답을 얼마 동안 재사용해도 되는지, 급히 교체할 때 어느 계층까지 제어할 수 있는지 적으면 캐시 설정을 업무 요구로 설명할 수 있다. 그 뒤에는 요청 지연을 구간별로 읽는 글에서 변경 확인 비용이 사용자 대기에 미치는 영향을 이어 살펴볼 수 있다. 이 점검표를 다시 쓰려면 글의 저장 기능을, 후속 글을 받아보려면 RSS를 이용할 수 있다. 관련 기반 기술은 인프라 주제 모음에 모아 두었다.

다시 읽을 때의 기준
  • 신선도는 원점에 묻지 않고 재사용할 수 있는 조건이고, 재검증은 저장본이 여전히 유효한지 확인하는 통신이다.
  • no-cache는 재사용 전 검증, no-store는 저장 금지다. 새 헤더를 배포해도 기존 저장본에 즉시 전달되지는 않는다.
  • 304는 조건부 요청에 본문을 다시 보내지 않는 응답이다. 상태 코드만으로 최신 배포 여부를 판정하지 않는다.
SOURCES & CONTEXT

근거와 원자료

자료의 발행 시점과 이번 글에서 참고한 범위를 함께 기록합니다.

  1. RFC 9111: HTTP Caching ↗IETF · RFC Editor · 2022

    Fixed RFC 9111, June 2022. Sections 4.1–4.3 and 5.2: cache selection, freshness, Age calculation, revalidation and directives. Not a vendor CDN test.

  2. RFC 9110: HTTP Semantics ↗IETF · RFC Editor · 2022

    Fixed RFC 9110, June 2022. Sections 8.8.3, 13.1.2, 15.4.5: validators, weak comparison for If-None-Match, GET/HEAD 304 and body prohibition.

  3. HTTP caching ↗MDN Web Docs · 상시 갱신 문서

    Private/shared/managed caches, Vary, deleting stored responses, versioned asset URLs. Retrieved snapshot hashed; no browser/version-specific behavior verified.

  4. Cache-Control header ↗MDN Web Docs · 상시 갱신 문서

    Ordinary unqualified response directives no-cache/no-store/private/max-age/s-maxage/must-revalidate and request no-cache. No blanket cache bypass claim.

  5. ETag header ↗MDN Web Docs · 상시 갱신 문서

    Opaque entity tags, strong/weak validators, generation not prescribed as content hash, conditional revalidation examples.

  6. curl command line manual ↗curl project · 상시 갱신 문서

    -D/--dump-header, -o/--output, -H/--header and -I/--head. Reader diagnostic GET commands are not claimed as production measurements.

갱신 기록

첫 원고. RFC·공식 문서를 대조하고 로컬 원점 HTTP 실험 8건과 배포 진단 양식을 작성했다.

오류를 발견했다면 →
S
Starhunter

테크독해 운영·편집. AI·IT의 개념과 기술을 연결해 읽습니다.