HTTP 캐시 강좌: ETag·Cache-Control·304 실측

php -S 로 ETag 와 Cache-Control 을 직접 구현하고 304 로 줄어드는 바이트와 시간을 실측. 매번 바뀌는 ETag, 1초 안에 바뀐 파일에 304 가 오는 함정, no-cache 오해, CDN 주의점까지.

시리즈: HTTP 강좌 4/6 (중급). curl -v 출력을 읽고 헤더를 붙일 줄 아는 분이, 이제 "브라우저와 CDN 이 응답을 얼마나 오래 들고 있어도 되는가"를 정할 수 있게 되는 회차입니다. 다 읽으면 Cache-Control·ETag·Last-Modified 를 제대로 설정하고, 조건부 요청에 304 Not Modified 로 답하고, 캐시를 쓸모없게 만들거나 위험할 만큼 오래된 데이터를 보여 주는 버그 세 가지를 알아볼 수 있습니다.

이 글은 "배포했는데 고객 화면은 그대로예요, 강력 새로고침(Ctrl+F5) 하라고 안내하면 되나요?"라는 질문을 받은 분을 위한 글입니다. 반대로 "앱이 실행될 때마다 같은 180 KB 카탈로그를 받아서 데이터 요금이 많이 나온다"는 리뷰도 원인은 같은 응답 헤더 몇 개에 있습니다. 아래 내용은 모두 Apple Silicon Mac(M1 Pro)에서 curl 8.7.1, PHP 8.5.6(php -S), Python 3.9.6(http.server)을 127.0.0.1 에서 직접 돌린 결과와, example.com 및 이 사이트에 보낸 몇 번의 읽기 전용 요청 결과입니다.

캐시가 확인하는 두 가지

캐시는 나중에 다시 쓰려고 보관한 응답 사본입니다. 브라우저 디스크 캐시, CDN 엣지, 리버스 프록시, 앱의 HTTP 클라이언트가 들고 있는 것도 모두 캐시입니다. 보관한 응답을 다시 쓰기 전에 캐시는 두 가지를 확인합니다.

  1. 아직 신선한가? 서버는 "묻지 않고 몇 초 동안 써도 되는지"를 주로 Cache-Control: max-age=<초> 로 알려 줍니다. 그 시간 안에는 캐시가 혼자 응답하고, 서버에는 요청조차 오지 않습니다.
  2. 오래됐다면 내용이 바뀌었나? 오래된 사본을 바로 버리지는 않습니다. 캐시는 같은 GET 에 If-None-Match: <저장한 ETag> 나 If-Modified-Since: <저장한 Last-Modified> 를 붙인 조건부 요청을 보냅니다. 바뀌지 않았으면 서버는 본문 없는 304 Not Modified 를 보내고, 캐시는 가진 사본을 계속 씁니다.

두 번째 확인에 쓰는 값을 검증자(validator)라고 합니다. ETag(엔티티 태그)는 "41455fbfcdb6b7a0" 처럼 큰따옴표로 감싼 문자열이고, 내용이 바뀌면 함께 바뀝니다. Last-Modified 는 날짜이고 정밀도가 1초입니다. 이 1초가 뒤에 나올 버그의 원인입니다.

흐름도. 요청이 캐시에 도착하면 저장된 사본이 max-age 기준으로 아직 신선한지 확인한다. 신선하면 캐시가 바로 응답하고 서버는 요청을 받지 않는다. 오래되었으면 저장해 둔 ETag 를 If-None-Match 에 담아 조건부 요청을 보낸다. ETag 가 같으면 서버는 본문 없는 304 Not Modified 를 돌려주고 캐시는 가진 사본을 쓴다. 다르면 새 본문과 새 ETag 가 담긴 200 을 돌려준다.
캐시가 판단하는 흐름. 신선하면 묻지 않고 재사용하고, 오래되면 재검증해서 304(본문 없음) 또는 200(새 본문)을 받는다.

기본 정적 서버는 무엇을 보내나

먼저 이미 있는 서버로 확인합니다. 상품 1,500개를 담은 179,500바이트 JSON(random.seed(42) 로 생성)을 파이썬 내장 서버로 내보냈습니다.

cd ~/demo/cache/site
python3 -m http.server 18150 --bind 127.0.0.1
curl -sI http://127.0.0.1:18150/products.json
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:41 GMT
Content-type: application/json
Content-Length: 179500
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT

Last-Modified 는 있지만 ETag 도 Cache-Control 도 없습니다. 그래도 If-Modified-Since 에는 답하므로 재검증은 됩니다.

$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
    -H "If-Modified-Since: Thu, 12 Nov 2026 14:28:40 GMT" http://127.0.0.1:18150/products.json
304 0
$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
    -H 'If-None-Match: "abc"' http://127.0.0.1:18150/products.json
200 179500

PHP 내장 서버는 정적 파일에 더 적게 보냅니다. Content-Type 과 Content-Length 뿐이고 검증자가 하나도 없어서, 재검증 자체가 불가능합니다.

Cache-Control 이 없으면 캐시는 추측해도 됩니다. RFC 9111 은 이를 휴리스틱 신선도라고 부르고, 흔한 값으로 "Last-Modified 이후 지난 시간의 10%"를 제시합니다. 열흘 전에 바뀐 파일이라면 하루쯤은 묻지 않고 쓰일 수 있다는 뜻입니다. "캐시 헤더를 하나도 안 붙였다"가 "아무것도 캐시되지 않는다"는 뜻은 아닙니다(브라우저 동작은 명세를 바탕으로 한 설명이며 여기서 실행하지는 않았습니다. curl 은 캐시가 없습니다).

php -S 라우터로 ETag 와 If-None-Match 구현하기

이제 서버 쪽을 직접 씁니다. site 폴더 옆에 router.php 로 저장하고 cd site && php -S 127.0.0.1:18151 ../router.php 로 실행합니다.

<?php
// php -S 127.0.0.1:18151 router.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = __DIR__ . '/site/products.json';

if ($path === '/good.json') {
    $body = file_get_contents($file);
    $etag = '"' . substr(hash('sha256', $body), 0, 16) . '"';
    $lastMod = gmdate('D, d M Y H:i:s', filemtime($file)) . ' GMT';
    header('Content-Type: application/json');
    header('Cache-Control: no-cache');          // 저장 가능, 매번 재검증
    header("ETag: $etag");
    header("Last-Modified: $lastMod");
    $inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
    if ($inm !== '' && in_array($etag, array_map('trim', explode(',', $inm)), true)) {
        http_response_code(304);                // 본문 없음
        exit;
    }
    echo $body;
    exit;
}

if ($path === '/bad.json') {                    // 흔한 실수: 매번 바뀌는 ETag
    $body = file_get_contents($file);
    header('Content-Type: application/json');
    header('ETag: "' . md5($body . microtime()) . '"');
    $inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
    if ($inm === md5($body)) { http_response_code(304); exit; }
    echo $body;
    exit;
}
return false;   // 나머지는 php -S 가 그대로 내보낸다

ETag 가 본문의 해시이므로 내용이 바뀔 때만 바뀌고, 같은 파일을 가진 서버라면 몇 대든 같은 값이 됩니다. 첫 요청에는 전체가 옵니다.

HTTP/1.1 200 OK
Host: 127.0.0.1:18151
Date: Thu, 12 Nov 2026 14:28:53 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-Type: application/json
Cache-Control: no-cache
ETag: "41455fbfcdb6b7a0"
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT

받은 ETag 를 따옴표까지 그대로 돌려보내면 헤더만 옵니다.

$ curl -si -H 'If-None-Match: "41455fbfcdb6b7a0"' http://127.0.0.1:18151/good.json
HTTP/1.1 304 Not Modified
Host: 127.0.0.1:18151
Date: Thu, 12 Nov 2026 14:28:54 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-Type: application/json
Cache-Control: no-cache
ETag: "41455fbfcdb6b7a0"
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT

따옴표를 빼고 If-None-Match: 41455fbfcdb6b7a0 로 보내면 200 179500 이었습니다. 따옴표도 값의 일부입니다.

여기서 no-cache 는 "저장하지 마라"가 아닙니다. "저장해도 되지만 쓰기 전에 매번 물어봐라"입니다. ETag 와 함께 쓰면 언제 바뀔지 모르는 데이터에 가장 안전한 설정이 됩니다. 재사용할 때마다 드는 비용은 작은 왕복 한 번이지, 전체 다운로드가 아닙니다.

Cache-Control 고르기: 판단표

내용 헤더 결과
비밀번호 화면, 일회용 토큰 Cache-Control: no-store 어떤 캐시에도 저장되지 않음
언제 바뀔지 모르는 API 데이터 Cache-Control: no-cache + ETag 저장되고 쓸 때마다 재검증, 그대로면 304
블로그 HTML Cache-Control: public, max-age=600 + ETag 10분 재사용, 그 뒤 재검증
사용자별 응답(마이페이지, 장바구니) Cache-Control: private, no-cache 브라우저는 저장 가능, CDN 같은 공유 캐시는 불가
파일 이름에 해시가 든 CSS/JS(app.3f9c1e.css) Cache-Control: public, max-age=31536000, immutable 1년 재사용, 배포 때는 새 이름 사용
이름은 그대로, 배포 때 내용만 바뀜(app.css) 짧은 max-age 나 no-cache + ETag 여기에 긴 max-age 를 주면 "예전 CSS 가 보여요" 버그
CDN 과 브라우저 기간을 다르게 s-maxage=<초> 추가 공유 캐시는 s-maxage, 브라우저는 max-age 를 씀
판단 그림. 첫 질문: 비밀번호 화면이나 일회용 토큰처럼 비밀인가? 예이면 no-store. 아니면 특정 사용자 전용인가? 예이면 private, no-cache 와 ETag. 아니면 URL 이 그대로인 채 내용이 바뀌는가? 파일 이름에 해시가 들어 있어 바뀌지 않으면 public, max-age=31536000, immutable. 같은 URL 에서 바뀔 수 있으면 짧은 max-age 나 no-cache 와 ETag.
Cache-Control 값 고르기. 먼저 저장해도 되는지, 다음으로 URL 이 그대로인 채 내용이 바뀌는지로 정한다.

실측: 304 가 줄여 주는 것

good.json 을 요청 한 번마다 curl 을 한 번 띄워서 쟀습니다. 전체 다운로드 50번, 맞는 If-None-Match 를 붙인 재검증 50번을 한 세트로 3세트. 그리고 curl 의 --limit-rate 1M 으로 느린 모바일 회선을 흉내 내 10번씩 2세트를 돌렸습니다.

경우 상태 요청당 본문 바이트 중앙값
루프백, 전체 다운로드(3×50) 200 179,500 1.57 / 1.43 / 1.47 ms
루프백, 재검증(3×50) 304 0 1.38 / 1.40 / 1.38 ms
1 MB/s 제한, 전체 다운로드(2×10) 200 179,500 173.29 / 173.01 ms
1 MB/s 제한, 재검증(2×10) 304 0 1.43 / 1.46 ms

응답 헤더는 200 이 252바이트, 304 가 262바이트로, 304 라고 헤더가 작아지지는 않습니다. 전체 다운로드 50번은 본문 8,975,000바이트, 재검증 50번은 0바이트였습니다.

이 표를 정직하게 읽으면 두 가지입니다. 빠른 회선에서는 304 로 시간이 거의 줄지 않습니다. 왕복과 curl 시작 시간이 대부분이라서이고, 이득은 대역폭과 사용자의 데이터 요금입니다. 그리고 서버가 하는 일은 그대로입니다. 이 라우터는 304 를 줄 때도 파일을 읽고 해시를 계산했습니다. 본문 만들기가 비싸다면(DB 쿼리 등) 버전 컬럼이나 배포 때 저장해 둔 해시처럼 싼 값으로 ETag 를 만드세요.

요청 시간 중앙값 막대그래프. 루프백에서 179,500바이트 JSON 의 200 전체 다운로드는 1.57 ms, 304 재검증은 1.38 ms. curl 을 1 MB/s 로 제한하면 전체 다운로드 173.29 ms, 304 는 1.43 ms 였다.
요청 1회당 중앙값(1회차. 루프백 50회, 1 MB/s 제한 10회). Apple M1 Pro, curl 8.7.1, php -S 에서 실측.

흔한 실수: 실제로 일어나는 일과 해결

1. 응답마다 바뀌는 ETag. bad.json 은 해시에 microtime() 을 섞었습니다. 받은 ETag 를 다음 요청에 넣는 일을 10번 반복했습니다.

bad.json x10: 200 200 200 200 200 200 200 200 200 200

재검증이 매번 전체 다운로드가 되고, 캐시 헤더는 순수한 낭비가 됩니다. 로드밸런서 뒤의 서버 여러 대가 inode 번호나 수정 시각으로 ETag 를 만들 때도 똑같은 일이 생깁니다. 해결: ETag 는 내용(해시)이나 직접 관리하는 버전 번호로 만듭니다.

2. Last-Modified 는 1초 단위. 파일을 쓰고, Last-Modified 를 읽고, 같은 1초 안에 다른 내용으로 다시 쓴 다음 파이썬 서버에 재검증했습니다.

Last-Modified seen: Thu, 12 Nov 2026 14:29:52 GMT
  -> 304
actual file: {"version": 2}

파일은 바뀌었는데 서버는 "안 바뀌었다"고 답하고, 클라이언트는 version 1 을 계속 씁니다. 파일을 쓰고 곧바로 다시 쓰는 빌드 스크립트에서 그대로 일어나는 일입니다. 해결: ETag 를 보냅니다. 둘 다 있으면 If-None-Match 가 If-Modified-Since 보다 우선합니다(RFC 9110).

3. ETag 를 단순 문자열로 비교. good.json 은 완전히 같은지만 보므로 W/"41455fbfcdb6b7a0" 를 보내는 클라이언트는 200 을 받습니다. W/ 는 약한 ETag 표시로 "의미는 같지만 바이트까지 같다는 보장은 없다"는 뜻이며, 압축 뒤에 자주 붙습니다. If-None-Match 는 약한 비교를 쓰므로 올바른 서버는 W/ 를 무시하고 비교합니다. 아래 연습문제 1에서 고칩니다.

4. no-cache 를 "캐시 금지"로 읽기. 로그인 화면에 no-cache 를 붙이고 안심하는 경우가 있는데, 응답은 저장됩니다. 비밀 정보에는 no-store 입니다.

실제 헤더: example.com 과 이 사이트

글을 쓴 날 읽기 전용으로 확인했습니다. example.com 은 CDN 을 거쳐 나옵니다.

HTTP/2 200
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Thu, 05 Nov 2026 21:17:41 GMT
age: 8757
cf-cache-status: HIT

Cache-Control 도 ETag 도 없습니다. age: 8757 은 엣지가 사본을 약 2.4시간 들고 있었다는 뜻입니다. If-Modified-Since 로 재검증하니 본문 0바이트의 304 였습니다. Last-Modified 만 있으니 휴리스틱이 적용되고, 페이지가 580,303초 전 것이므로 그 10%인 약 16시간 동안 재사용될 수 있다는 계산이 나옵니다.

이 사이트(호스팅 업체 고유 헤더는 생략):

HTTP/2 200
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
last-modified: Thu, 12 Nov 2026 04:13:31 GMT
etag: W/"22d0-6af53ceb-1d219362a7cb89c4;gz"
cache-control: public, max-age=600

재미있는 점은 Accept-Encoding 없이 받으면 ETag 가 약한 W/"…;gz" 이고, curl --compressed 로 받으면 gzip 으로 오면서 강한 "…;gz" 가 된다는 것입니다. 약한 값을 그대로 If-None-Match 에 넣어 보내도 본문 0바이트의 304 가 왔습니다. 약한 비교가 명세대로 동작한 것입니다.

CDN 의 함정

CDN 은 공유 캐시라서 위 내용이 모두 적용되고, 함정이 세 가지 더 있습니다.

  • 의도하지 않은 것까지 캐시한다. 리다이렉트와 API 응답이 엣지에 저장되면, 원본 서버를 고쳐도 사본이 만료될 때까지 반영되지 않습니다. 301 로 실제로 겪은 일은 호스팅 CDN 이 API 응답과 301 을 캐시하고 있었다 에 정리했습니다.
  • Vary 가 캐시 키를 정한다. Accept-Encoding 이나 Origin 에 따라 응답이 달라지면 Vary 로 밝혀야 합니다. 안 그러면 다른 클라이언트용 버전이 나갑니다.
  • 퍼지(삭제)는 모든 거점에 즉시 적용되지 않는다. 정적 파일은 버전이 붙은 파일 이름을 쓰면 퍼지할 일 자체가 없어집니다.

정직한 한계와 버전 메모

  • 실행한 것: curl 8.7.1, PHP 8.5.6 내장 서버, Python 3.9.6 http.server. 모두 127.0.0.1, Apple M1 Pro. 루프백 시간에는 curl 을 한 번 띄우는 시간이 들어 있습니다. --limit-rate 는 대역폭만 흉내 내고 지연이나 패킷 손실은 재현하지 않습니다.
  • 실행하지 않은 것: 브라우저 캐시, 서비스 워커, CDN 의 stale-while-revalidate 처리. 브라우저 동작은 명세를 바탕으로 설명했습니다.
  • 명세: HTTP 캐시는 RFC 9111(2022년 6월)이며 RFC 7234 를 대체했습니다. ETag·Last-Modified·조건부 요청은 RFC 9110, immutable 은 RFC 8246, stale-while-revalidate/stale-if-error 는 RFC 5861 입니다. 10% 휴리스틱은 RFC 9111 의 제안일 뿐 모든 캐시가 따르는 규칙은 아닙니다.
  • 이 사이트와 example.com 관찰은 하루치 스냅숏입니다. 설정이 바뀌면 값도 바뀝니다.

연습문제

1. good.json 의 If-None-Match 를 약한 비교로 고치세요. W/"x" 는 "x" 와 일치하고, "old", "x" 같은 목록도 일치하고, * 도 일치해야 합니다.

2. 열흘 전에 수정되었고 Last-Modified 만 있고 Cache-Control 은 없는 파일이 있습니다. RFC 9111 의 흔한 값(10%)이라면 캐시는 묻지 않고 얼마나 쓸 수 있을까요? 추측을 없애는 헤더는 무엇일까요?

정답

1. in_array 판정을 아래 함수로 바꿉니다.

function etag_matches(string $etag, string $header): bool {
    if (trim($header) === '*') return true;
    $strip = fn(string $t): string => preg_replace('#^W/#', '', trim($t));
    foreach (explode(',', $header) as $t) {
        if ($strip($t) === $strip($etag)) return true;   // 약한 비교
    }
    return false;
}

헤더 값 다섯 가지로 실행한 결과입니다.

"41455fbfcdb6b7a0"           => 304
W/"41455fbfcdb6b7a0"         => 304
"old", "41455fbfcdb6b7a0"    => 304
41455fbfcdb6b7a0             => 200
*                            => 304

따옴표 없는 값은 여전히 200 인데, 이것이 맞습니다. 올바른 엔티티 태그가 아니기 때문입니다.

2. 약 하루(열흘의 10%)입니다. max-age=600 이나 no-cache 처럼 Cache-Control 을 명시하면 캐시가 더는 추측하지 않습니다.

다음 강좌

자주 묻는 질문

Cache-Control: no-cache 를 붙이면 캐시에 저장되지 않나요?

저장됩니다. no-cache 는 '저장해도 되지만 쓰기 전에 매번 서버에 확인하라'는 뜻입니다. 아예 저장하지 않게 하려면 no-store 를 씁니다. 실험에서 no-cache 와 ETag 를 붙인 응답은 재검증할 때마다 본문 0바이트의 304 Not Modified 로 돌아왔습니다.

304 Not Modified 로 실제로 얼마나 아낄 수 있나요?

아끼는 것은 본문이고 왕복은 그대로입니다. 179,500바이트 JSON 을 50번 받으면 본문이 8,975,000바이트, 50번 재검증하면 0바이트였습니다. 루프백에서는 시간 차이가 거의 없었지만(중앙값 1.4~1.6 ms 대 1.4 ms), curl 을 1 MB/s 로 제한하면 전체 다운로드 173 ms, 304 는 1.4 ms 였습니다.

서버가 304 를 한 번도 안 돌려주는 이유는 무엇인가요?

가장 흔한 원인은 응답할 때마다 바뀌는 ETag 입니다. microtime() 을 섞은 ETag 로 10번 연속 재검증했더니 10번 모두 200 이었습니다. If-None-Match 값을 따옴표 없이 비교하거나, W/ 가 붙은 약한 ETag 를 받아 주지 않는 것도 원인입니다.

  • #HTTP
  • #중급
  • #캐시
  • #ETag
  • #Cache-Control
  • #304
  • #curl
  • #PHP

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑