thenullpage.com
다시 받지 않기 위한 장치
같은 이미지 한 장을 화면을 옮길 때마다 새로 내려받으면 시간도 데이터 요금도 그만큼 든다. HTTP는 이 낭비를 줄이려고 한 번 받은 응답을 어딘가에 두었다가 다시 쓰는 절차를 규약 안에 넣어 두었다. 그 저장소가 캐시이고, 무엇을 얼마나 오래 보관할지는 응답을 주는 서버가 헤더로 지시한다.
보관하는 주체는 하나가 아니다. 브라우저는 자기 디스크에 두고, 그 앞에 놓인 CDN이나 회사 프록시도 자기 몫을 따로 둔다. 앞의 것은 사용자 한 명만 쓰므로 개인 캐시, 뒤의 것은 여러 사용자가 함께 쓰므로 공유 캐시라 부른다. 이 구분을 놓치면 한 사람에게만 보여야 할 화면이 공유 캐시에 저장돼 다음 사람에게 그대로 나가는 사고가 생긴다.
Cache-Control이 지시하는 것
캐시 방침은 거의 전부 Cache-Control 한 줄에 담긴다. 쉼표로 지시자를 이어 적는 형식이다.
Cache-Control: public, max-age=31536000, immutable
max-age는 응답을 받은 시점부터 몇 초 동안 그대로 써도 되는지를 뜻한다. 이 시간 안에는 서버에 묻지 않고 저장해 둔 것을 바로 꺼내 쓴다. public은 공유 캐시도 저장해도 된다는 표시이고, private은 브라우저만 저장하라는 표시다. s-maxage를 따로 적으면 공유 캐시는 그 값을 쓰고 브라우저는 max-age를 쓴다.
no-cache와 no-store는 이름이 비슷해 자주 뒤바뀐다. no-store는 아예 저장하지 말라는 뜻이라 매번 새로 받는다. no-cache는 저장은 하되 쓰기 전에 서버에 바뀌었는지 물어보라는 뜻이다. 결제 정보처럼 디스크에 남는 것 자체가 문제인 응답에만 no-store를 쓰고, 최신 상태만 보장되면 되는 응답에는 no-cache를 쓰는 편이 트래픽에 유리하다.
Expires로 만료 시각을 직접 적는 방식은 예전 문법이다. 둘이 함께 있으면 Cache-Control이 이긴다. 시각을 적는 방식은 사용자 기기의 시계가 틀어지면 같이 어긋나므로 초 단위 수명을 적는 쪽이 안전하다.
만료 뒤에 다시 묻는 절차
max-age가 지났다고 저장한 것을 버리지는 않는다. 서버에 바뀌었는지 확인하고 그대로면 계속 쓴다. 이 확인을 재검증이라 하고, 대조에 쓸 기준값은 서버가 응답에 미리 붙여 둔다.
HTTP/1.1 200 OK
ETag: "v2-8a7b"
Last-Modified: Tue, 21 Jul 2026 04:11:00 GMT
Cache-Control: max-age=60
ETag는 내용이 바뀌면 같이 바뀌는 짧은 식별값이고 Last-Modified는 마지막 수정 시각이다. 수명이 다한 뒤의 요청에는 브라우저가 이 값을 되돌려 보낸다.
GET /articles/42
If-None-Match: "v2-8a7b"
서버가 대조해서 그대로면 304를 본문 없이 돌려준다. 브라우저는 저장해 둔 본문을 그대로 쓴다. 왕복이 한 번 생기기는 하지만 실어 나르는 바이트가 거의 없어 파일이 클수록 이득이 크다. Last-Modified는 초 단위라 같은 초 안에 두 번 바뀌면 구분하지 못하므로, 두 가지를 다 붙일 수 있는 상황이면 ETag 쪽이 정확하다.
공유 캐시를 거쳐 온 응답에는 Age 헤더가 붙는다. 그 응답이 저장된 지 몇 초가 지났는지 적힌 값이라, max-age에서 Age를 빼면 남은 수명이 나온다. 화면이 갱신되지 않는다는 말을 들었을 때 이 숫자부터 보면 아직 수명이 남은 것인지 서버가 옛 내용을 그대로 내주고 있는 것인지 갈라 볼 수 있다.
기다리지 않고 내주는 방법
재검증이 오가는 동안 사용자는 기다린다. stale-while-revalidate는 수명이 지난 응답을 일단 화면에 내주고, 새 응답은 뒤에서 받아 두었다가 다음 요청부터 쓰게 한다.
Cache-Control: max-age=60, stale-while-revalidate=600
60초 동안은 그대로 쓰고, 그 뒤 600초 안에 요청이 오면 옛 응답을 즉시 내주면서 갱신을 함께 돌린다. 조금 지난 내용이 잠깐 보여도 되는 목록에 어울리고, 잔액이나 재고처럼 틀리면 안 되는 값에는 쓰지 않는다.
immutable은 수명이 남아 있는 동안 재검증조차 하지 말라는 표시다. 사용자가 새로고침을 눌러도 확인 요청이 나가지 않는다. 내용이 바뀌면 주소도 반드시 바뀌는 자산에만 붙일 수 있다.
응답이 섞이는 자리
같은 주소라도 요청에 따라 다른 응답을 줄 때가 있다. 압축을 받을 수 있는 쪽에는 압축본을, 한국어를 원하는 쪽에는 한국어 화면을 준다. 캐시는 주소를 열쇠로 삼기 때문에 이 사정을 모르면 먼저 저장된 하나를 모두에게 돌려준다. Vary는 어떤 요청 헤더가 응답을 가르는지 알려 주는 헤더다.
Vary: Accept-Encoding, Accept-Language
더 위험한 것은 로그인 상태에 따라 달라지는 화면이다. 여기에 public이 붙으면 앞단 캐시가 남의 이름이 박힌 화면을 그대로 뿌린다. 개인화된 응답에는 private을 붙이고, 필요하면 Vary에 Cookie를 적는다. 다만 Cookie를 적으면 쿠키 값이 조금만 달라도 별개 항목이 되어 공유 캐시의 적중률이 거의 사라진다는 점은 감안해야 한다.
고쳤는데 그대로일 때
스타일 파일을 고쳐 올렸는데 화면이 그대로인 상황은 대개 남은 수명이 아직 끝나지 않아서다. 한번 내보낸 응답을 서버가 되돌아가 회수할 방법은 없다. 그래서 오래 캐시할 자산은 내용이 바뀔 때 주소도 함께 바꾼다. 파일 이름에 내용 해시를 넣거나 주소 뒤에 버전 값을 붙이면, 새 주소는 캐시에 없으므로 무조건 새로 받는다.
이 방식을 쓰면 자산에는 아주 긴 수명과 immutable을 마음 놓고 붙일 수 있다. 대신 그 주소를 적어 둔 HTML 문서 쪽은 수명을 짧게 잡거나 매번 재검증하게 둬야 한다. 문서까지 길게 캐시하면 주소가 바뀐 사실을 알릴 통로가 사라져 옛 자산이 계속 불린다.
확인하는 방법
개발자 도구 Network 탭의 Size 열을 본다. 숫자 대신 memory cache나 disk cache로 적혀 있으면 서버까지 가지 않은 것이고, 304로 잡혔다면 재검증만 하고 본문은 받지 않은 것이다. Disable cache를 켜면 캐시를 무시하므로 켠 상태와 끈 상태를 번갈아 보면 어느 쪽 효과인지 갈린다.
새로고침하는 방식마다 동작이 다르다는 점도 알아 둘 필요가 있다. 일반 새로고침은 재검증을 걸고 강제 새로고침은 캐시를 건너뛴다. 사용자가 링크를 눌러 들어오는 평소 이동이 진짜 조건이므로 판단은 그쪽 기준으로 해야 한다.
명령줄에서는 헤더만 받아 보면 된다.
curl -I https://example.com/app.9f3a1.js
curl -I -H 'If-None-Match: "v2-8a7b"' https://example.com/articles/42
첫 줄은 Cache-Control과 ETag가 실제로 붙어 나오는지 보는 것이고, 둘째 줄은 그 값을 되돌려 보내 304가 오는지 보는 것이다. 여기서 본문이 실린 200이 다시 오면 재검증이 동작하지 않는 것이다. 앞에 CDN을 두었다면 저장된 것을 썼는지 알려 주는 헤더가 응답에 따로 붙으므로, 같은 주소를 연달아 두 번 불러 그 값이 적중으로 바뀌는지 보면 앞단에 실제로 쌓였는지 갈라 볼 수 있다.