HTTP 캐시 — 같은 이미지를 왜 다시 받지 않을까요?
쇼핑몰 상품 페이지에서 logo.svg를 받았는데, 서버가 응답에 이런 헤더를 붙여 보냈다고 해 보겠습니다.
HTTP/1.1 200 OK
Cache-Control: max-age=60
ETag: "v1"
다른 페이지에 갔다가 돌아오면 브라우저는 이 이미지를 또 받을까요? 언제 돌아왔느냐에 따라 다릅니다. 10초 만에 돌아오면 서버에 아예 묻지 않고, 1분이 지나서 돌아오면 묻기는 하는데 이미지는 다시 받지 않아요. 그사이 서버 파일이 바뀌었을 때만 새로 받고요. 처음 받을 때까지 치면 이미지 하나로 결과가 네 가지인 셈인데, 브라우저가 따지는 건 결국 하나입니다. 저장해 둔 걸 지금 다시 써도 되는가.
1. 캐시는 어디에 있을까
캐시라고 하면 보통 브라우저를 떠올리지만, 사용자와 서버 사이 곳곳에 있습니다. 여기서 다룰 건 두 군데예요. 내 기기에 복사본을 두는 브라우저 캐시, 그리고 여러 사용자가 같은 복사본을 나눠 쓰는 캐시. 원래 파일은 원본 서버(Origin Server)에 있습니다. 여기서 끝나면 요청 자체가 없음 여기서 끝나면 원본까지 안 감 원래 파일이 있는 마지막 단계
요청은 가까운 쪽부터 확인합니다. 브라우저에 쓸 수 있는 복사본이 있으면 거기서 끝나고, 없으면 CDN, 거기에도 없으면 원본 서버까지 가요. 저장된 걸 꺼내 쓰면 Cache Hit, 원본에서 다시 가져오면 Cache Miss라고 부릅니다. (메모리에 둘지 디스크에 둘지는 브라우저가 정하니까 여기선 넘어갈게요.)
2. 같은 이미지를 네 번 요청하면
max-age=60은 응답을 받고 60초 동안은 , 그러니까 서버에 묻지 않고 써도 되는 상태로 본다는 뜻입니다. 는 서버가 응답 버전을 표시하려고 붙이는 식별자예요.
아래 그림에서 '다음 장면'을 누르면 같은 CDN은 빼고 브라우저 캐시와 원본 서버 사이에서 요청이 어디까지 가는지 봐요. 캐시가 비어 있으니 서버에서 이미지를 받아 옵니다. 응답에 max-age=60과 ETag가 있어서 v1을 저장해 둬요.logo.svg를 네 번 요청해요. 어디서 멈추는지만 보면 됩니다.logo.svg를 네 번 요청하면
처음엔 캐시가 비어 있으니 서버에서 200 OK와 이미지를 받아 저장합니다. 10초 뒤 다시 오면 아직 Fresh라서 저장본을 그냥 쓰는데, 이때는 서버로 요청이 아예 나가지 않아요. 네 장면 중 네트워크를 안 타는 건 이것뿐이에요.
61초가 지나면 저장본은 이 됩니다. 그렇다고 바로 버리진 않고, 저장해 둔 ETag를 보내서 서버에 아직 그대로인지 물어봐요. 그대로면 본문 없이 304 Not Modified만 오고, 브라우저는 갖고 있던 이미지를 계속 씁니다. 서버 파일이 v2로 바뀌었다면 그때 200 OK와 새 이미지가 옵니다.
3. Cache-Control이 정하는 규칙
이 규칙은 응답 헤더로 정합니다. 값은 많지만 처음엔 세 개면 돼요. 10초 전에 한 번 받은 logo.svg를 다시 요청하는 상황입니다. 바로 재사용서버 요청 없음 서버 확인 후 사용변경 없으면 304 매번 새로 받기저장된 복사본 없음max-age는 유효기간, no-cache는 쓰기 전에 확인, no-store는 저장 금지.정책별로 다시 요청하면
max-age=60no-cacheno-store
제일 많이 헷갈리는 건 no-cache입니다. 이름만 보면 캐시하지 말라는 것 같은데, 실제로는 저장은 하되 쓰기 전에 매번 서버에 확인하라는 뜻이에요. 그래서 no-cache가 붙은 응답도 내용이 그대로면 304로 끝납니다. 정말 저장하면 안 되는 응답에는 no-store를 붙이세요.
CDN이 끼면 신경 쓸 게 하나 더 생깁니다. CDN 캐시는 여러 사람이 같이 쓰니까, 로그인한 사용자의 마이페이지 응답이 거기 저장되면 다음 사람한테 그대로 나갈 수 있어요. 그래서 public(공유 캐시에 저장해도 됨)과 private(한 사람만 쓰는 캐시에만 저장)을 구분하고, 개인 데이터가 담긴 응답엔 private이나 no-store를 붙입니다. 공유 캐시에만 유효기간을 따로 주고 싶으면 s-maxage=300처럼 쓰면 되고요.조금 더 · must-revalidate, immutable, stale-while-revalidate
must-revalidate는 유효기간이 지난 응답을 서버 확인 없이 쓰지 못하게 합니다. immutable은 유효기간 안에는 내용이 안 바뀐다는 표시라서, 파일명에 해시가 붙은 JS·CSS에 같이 써요.stale-while-revalidate=30이면 만료 후 30초 동안은 저장본을 먼저 보여 주고 뒤에서 새 버전을 확인합니다.Cache-Control: public, max-age=60, s-maxage=300이면 브라우저는 60초, CDN 같은 공유 캐시는 300초.
4. ETag와 304
받아 둔 ETag: "v1"은 만료 후 확인할 때 If-None-Match: "v1"에 담겨 서버로 갑니다. 서버는 지금 ETag와 같으면 304를 돌려줘요.
GET /logo.svg HTTP/1.1
If-None-Match: "v1"
HTTP/1.1 304 Not Modified
응답에는 본문이 없습니다. 그런데 요청은 분명히 서버까지 갔다 왔어요. 304를 "캐시에서 가져왔다"로만 읽으면 곤란한 이유가 이겁니다. 아낀 건 내려받는 시간이고, 서버까지 다녀오는 시간은 그대로 썼습니다. ETag는 버전을 구분하는 식별자고 Last-Modified는 시각을 비교한다는 차이가 있어요. 둘 다 오면 보통 ETag 대신 수정 날짜로 비교할 수도 있을까?
Last-Modified는 서버가 마지막 수정 시각을 응답 헤더에 넣는 방식입니다. 브라우저가 나중에 If-Modified-Since로 그 시각을 보내면 서버가 바뀌었는지 판단해요.If-None-Match를 먼저 봅니다. ETag가 꼭 버전 번호나 파일 해시일 필요는 없고요.
5. 같은 URL인데 응답이 다를 때
/docs 하나로 언어 설정에 따라 한국어 문서와 영어 문서를 내주는 사이트가 있다고 해 볼게요. 캐시가 URL만 보고 응답을 하나로 저장하면, 한국어 사용자가 먼저 받아 간 문서가 영어 사용자에게도 나갑니다.
그래서 캐시는 저장된 응답을 꺼낼 때 라는 기준을 씁니다. 기본은 URL과 요청 메서드인데, 응답에 Vary: Accept-Language 같은 헤더가 있으면 요청의 Accept-Language 값까지 비교해서 언어별로 따로 저장해요.
6. 배포했는데 예전 화면이 보일 때
CSS를 고쳐 배포했는데 몇몇 사람한테만 예전 디자인이 보일 때가 있습니다. 그 사람 브라우저에 있는 CSS가 아직 Fresh라서, 새 파일이 나왔는지 서버에 묻지도 않는 거예요. 캐시는 시킨 대로 동작했을 뿐이라 고칠 버그도 없습니다.
그래서 해결은 파일 이름을 바꾸는 쪽입니다. 빌드 도구가 app.abc123.js처럼 파일명에 내용 해시를 넣어 주니까, 내용이 바뀌면 app.def456.js가 되고 브라우저 입장에선 처음 보는 URL이 돼요. 이걸 Cache Busting이라고 부르고, 응답마다 정책은 보통 이렇게 나눕니다.
| 응답 | Cache-Control | 이유 |
|---|---|---|
HTML (index.html) | no-cache | 매번 확인하니까 새 파일명을 바로 가리킴 |
해시 붙은 JS·CSS (app.def456.js) | max-age=31536000, immutable | 내용이 바뀌면 이름도 바뀌니 1년 동안 다시 받을 일 없음 |
로그인 사용자 응답 (/api/me) | private 또는 no-store | CDN 같은 공유 캐시에 남으면 안 됨 (3장) |
CDN 캐시를 지우는(purge) 것만으로 안 되는 이유도 여기 있어요. 이미 사용자 브라우저에 들어간 Fresh한 복사본은 CDN이 못 건드리거든요.
7. 그래서 얼마나 빨라질까
2장의 네 장면을 비용으로 바꾸면 이렇습니다. 서버까지 왕복이 100ms 걸리는 환경이라고 쳐 볼게요.
| 경우 | 서버 왕복 | 본문 다운로드 | 파일을 쓰기까지 | 언제 생기나 |
|---|---|---|---|---|
| Fresh 저장본 사용 | 없음 | 없음 | 디스크 읽기뿐 | 유효기간 안, 해시 붙은 정적 파일 |
| 304 재검증 | 1번 | 없음 | 최소 100ms | 만료 후, no-cache |
| 200 새로 받기 | 1번 | 있음 | 100ms + 다운로드 | 첫 방문, 파일 변경, no-store |
몇 KB짜리 아이콘이라면 304와 200은 거의 차이가 없으니, 속도를 가르는 건 서버에 가느냐 마느냐입니다. 그래서 정적 파일에 no-cache를 걸어 두면 손해예요. 재방문 때마다 파일 수만큼 확인 요청이 나가고, 전부 304로 끝나니 Network 탭에선 멀쩡해 보이는데, 첫 화면에 필요한 CSS나 이미지는 그 응답이 올 때까지 못 씁니다.
지표로는 재방문 LCP에서 차이가 드러나요. 첫 화면의 가장 큰 이미지가 Fresh한 캐시에서 나오면 다운로드 구간이 사실상 사라지고, CSS가 Fresh하면 렌더링을 막던 요청도 없어집니다. 첫 방문엔 캐시가 비어 있으니 이 효과가 없어요. 그쪽은 CDN 몫인데, 원본보다 가까운 곳에서 응답하니 왕복 자체가 짧아집니다. TTFB는 요청을 보내고 첫 바이트가 도착하기까지 걸린 시간, FCP는 화면에 처음 뭔가 그려지는 시점, LCP는 화면에서 가장 큰 콘텐츠가 그려지는 시점입니다.성능 용어 · TTFB, FCP, LCP
8. DevTools로 확인하기
Chrome DevTools의 Network 탭에서 요청을 누르면 응답 헤더의 Cache-Control, ETag를 볼 수 있습니다. 목록에선 Status와 Size를 같이 봐야 해요. Status만 보면 200이 전부 새로 받은 걸로 보이니까요.
| Status | Size | 뜻 |
|---|---|---|
200 | (memory cache), (disk cache) | 서버에 안 감. Fresh 저장본을 그대로 씀 |
304 | 헤더 크기 정도 (수백 B) | 서버에 확인만 하고 저장본을 씀 |
200 | 실제 파일 크기 | 서버에서 본문을 새로 받음 |
캐시 효과를 숫자로 보려면 Network 탭 아래 상태줄의 transferred 값을 첫 방문과 재방문 때 비교해 보세요. 실제로 받은 양이 얼마나 줄었는지 바로 나옵니다. 확인할 때 걸리는 함정은 세 가지예요.
- Disable cache가 체크돼 있으면 DevTools가 열려 있는 동안 캐시를 아예 안 씁니다.
- 일반 새로고침은 메인 HTML을 서버에 다시 확인하고, 강력 새로고침(Shift + 새로고침)은 캐시를 무시해요. 평소 재방문을 보려면 다른 페이지로 갔다가 링크로 돌아오는 게 가장 가깝습니다.
- 뒤로 가기는 bfcache가 페이지를 통째로 복원하는 거라 HTTP 캐시와 별개예요. Service Worker의 Cache Storage도 마찬가지고요.
정리
브라우저는 저장된 응답을 꺼내기 전에 아직 Fresh한지부터 봅니다. Fresh면 묻지 않고 쓰고, Stale이면 ETag로 물어봐서 304면 갖고 있던 걸, 200이면 새 걸 써요. 바뀔 일 없는 파일에 해시를 넣고 길게 캐시하는 게 기본이 된 것도, 서버에 안 가는 쪽이 제일 빠르기 때문이고요.
복습 질문
① 304를 받았으면 서버 요청은 없었던 걸까?
아니요. 조건부 요청을 서버에 보냈고, 바뀐 게 없다는 304를 받은 겁니다. 본문만 다시 안 받았을 뿐이에요.
② CDN 캐시를 지웠는데 예전 화면이 남는 이유는?
CDN과 브라우저는 캐시를 따로 갖고 있어서, 브라우저에 남은 Fresh한 응답은 CDN에 묻지 않고 그대로 쓰입니다. 그래서 파일명에 해시를 넣어 URL 자체를 바꿔요.
③ 정적 파일에 no-cache를 걸면 재방문이 왜 느려질까?
파일마다 확인 요청이 나가고, 응답이 올 때까지 그 파일을 못 씁니다. 전부 304라 본문 전송은 없어도 왕복 시간은 그대로 들어요.
④ Network 탭에서 Status는 200인데 Size가 (disk cache)라면, 서버에 갔을까?
아니요. 디스크 캐시에 저장해 둔 응답을 꺼내 쓴 겁니다. 200은 처음 저장할 때 받은 상태 코드가 그대로 보이는 거예요.