← Posts

HTTP · Web · Cache

캐시 - 안 보내는 게 가장 빠르다

배포했는데 사용자 화면이 안 바뀐다. 그 사고에서 출발해 Cache-Control·ETag·조건부 요청이 무엇을 정하는지 본다.

목차
  1. 배포했는데 사용자 화면이 안 바뀐다
  2. 가장 빠른 요청은 안 보내는 요청
  3. 안 물어봐도 되는 기간을 정한다
  4. 기간이 지나면 통째로 안 받는다
  5. 무엇으로 바뀌었는지 아나
  6. 누가 보관하나
  7. 낡은 걸 보여주는 대가
  8. 배포 사고를 막는 방법
  9. 실무에서 자주 만나는 자리
  10. 정리

버튼 색을 바꿔 배포했다. 내 브라우저에서는 잘 바뀌었는데 사용자한테서 안 바뀐다는 연락이 온다. 강력 새로고침을 하면 바뀐다고 한다.

배포했는데 사용자 화면이 안 바뀐다

서버에는 새 파일이 올라가 있다. 그런데 사용자 브라우저는 서버에 물어보지도 않았다. 예전에 받아둔 파일을 그대로 쓴 것이다.

이게 캐시다. 그리고 이 사고는 캐시가 망가진 게 아니라 캐시가 시킨 대로 정확히 동작한 결과다. 누군가가 “이 파일은 한동안 다시 안 물어봐도 된다”고 말했고, 브라우저는 그 말을 지켰다.

문제를 풀려면 그 말을 누가 어떻게 하는지를 알아야 한다.

가장 빠른 요청은 안 보내는 요청

앞 글에서 왕복을 줄이는 이야기를 했다. 연결을 재사용하고, 한 연결에 여러 대화를 섞고, 아래층까지 바꿨다. 전부 잘 보내는 법이다.

그런데 근본적으로 더 빠른 방법이 있다. 안 보내는 것.

diagramdiagram

캐시는 이 그림 하나다. 세 갈래가 있고 아래로 갈수록 비싸다. 아예 안 물어보기 → 바뀌었는지만 물어보기 → 통째로 받기. HTTP의 캐시 헤더들은 전부 “이 자원을 어느 갈래로 보낼지”를 정하는 장치다.

안 물어봐도 되는 기간을 정한다

첫 번째 갈래를 만드는 게 Cache-Control이다.

http
HTTP/1.1 200 OK
Content-Type: image/png
Cache-Control: max-age=3600

max-age=3600은 “이 응답이 만들어진 지 3600초 안이면 신선하다”는 뜻이다. 오리진에서 곧장 받았다면 받은 시점부터 한 시간이지만, 중간 캐시가 30분 들고 있던 응답을 받았다면 내게 남은 건 30분이다. 응답에 붙어 오는 Age 헤더가 “이미 얼마나 늙었는지”를 알려준다. 그 안에는 브라우저가 서버에 아무것도 안 보낸다. 요청이 0건이니 네트워크 상황과 무관하게 즉시다.

여기서 처음 사고의 정체가 드러난다. 누군가가 HTML이나 JS에 긴 max-age를 걸어놨다면, 배포를 해도 그 기간이 끝나기 전까지는 사용자가 새 파일을 받을 기회 자체가 없다. 서버는 새 파일을 갖고 기다리는데 아무도 물어보러 오지 않는다.

Cache-Control에는 자주 쓰는 값이 몇 개 더 있다.

max-age=NN초 동안 신선하다. 그동안 안 물어본다
no-cache보관은 하되 쓰기 전에 반드시 물어본다
no-store아예 보관하지 마라
private브라우저만 보관해라
public중간 캐시도 보관해도 된다
주의

no-cache는 이름 때문에 “캐시하지 마라”로 읽히는데 아니다. 캐시는 하고, 쓸 때마다 서버에 확인한다는 뜻이다. 진짜로 저장을 막는 건 no-store다. 개인정보가 담긴 응답에 no-cache를 걸어놓고 안심하는 게 흔한 착각이다.

기간이 지나면 통째로 안 받는다

max-age가 지났다고 파일이 바뀐 건 아니다. 대부분의 경우 서버 파일은 그대로다. 그런데 다시 통째로 받으면 똑같은 걸 또 받는 낭비가 생긴다.

그래서 두 번째 갈래가 있다. 바뀌었는지만 물어본다.

diagramdiagram

응답이 304 Not Modified다. 상태 코드와 헤더만 오고 바디가 없다. 파일이 크면 클수록 이득이 크다.

이걸 조건부 요청이라고 부른다. “조건이 맞으면 주고 아니면 안 줘도 된다”는 요청이다. 왕복은 한 번 일어나므로 첫 번째 갈래보다는 비싸지만, 통째로 받는 것보다는 훨씬 싸다.

무엇으로 바뀌었는지 아나

“내 것은 v7인데”를 실제로 어떻게 표현하나. 두 가지 방식이 있다.

ETag - 내용에 붙은 꼬리표. 서버가 응답에 자원의 버전 표식을 붙여준다.

http
HTTP/1.1 200 OK
ETag: "v7abc"
Cache-Control: max-age=60

브라우저는 다음에 물어볼 때 그 표식을 그대로 되돌려준다.

http
GET /app.js HTTP/1.1
If-None-Match: "v7abc"

서버는 지금 자원의 표식과 비교해서, 같으면 304를, 다르면 새 내용과 함께 200을 준다.

Last-Modified - 마지막으로 바뀐 시각. 시각으로 비교하는 방식이다.

http
HTTP/1.1 200 OK
Last-Modified: Wed, 06 May 2026 09:00:00 GMT
http
GET /app.js HTTP/1.1
If-Modified-Since: Wed, 06 May 2026 09:00:00 GMT

둘의 차이는 정밀도와 정확성이다. Last-Modified는 초 단위라 같은 초 안에 두 번 바뀌면 구분을 못 한다. 그리고 내용이 그대로인데 파일을 다시 저장하기만 해도 시각이 바뀌어서, 안 바뀐 걸 바뀌었다고 말하기도 한다. ETag는 내용에서 만들면 이런 문제가 없다.

참고

다만 ETag가 무조건 안전한 것도 아니다. 서버가 여러 대인데 각자 다른 방식으로 ETag를 만들면, 같은 파일인데 서버마다 다른 표식이 나와서 매번 200이 떨어진다. 웹 서버 제품마다 ETag 생성 방식이 달라서 이런 일이 생긴다. 서버를 여러 대 두면 ETag 생성 규칙을 맞춰야 한다.

누가 보관하나

지금까지 “브라우저가 보관한다”고만 말했는데, 캐시는 한 군데가 아니다. 요청이 지나는 길목 곳곳에 있다.

diagramdiagram

이 구분이 중요한 이유는 하나다. 여럿이 나눠 쓰는 캐시에 개인 정보가 들어가면 남에게 보인다.

로그인한 사용자의 이름이 박힌 페이지가 공유 캐시에 저장되면, 다음 사람이 그 페이지를 받아볼 수 있다. 그래서 개인화된 응답에는 private을, 아예 저장을 막아야 하면 no-store를 건다.

응답대체로
로고 이미지, 폰트public, max-age 길게
로그인한 사용자의 마이페이지private 또는 no-store
결제 정보, 개인정보가 담긴 응답no-store
경고

공유 캐시가 응답을 잘못 섞어 내주는 사고는 실제로 있다. 같은 URL인데 헤더에 따라 응답이 달라지는 경우(언어별 응답, 압축 여부 등)에는 Vary 헤더로 “이 헤더가 다르면 다른 응답으로 취급하라”를 알려줘야 한다. 안 그러면 한국어 응답이 영어 사용자에게 나갈 수 있다.

낡은 걸 보여주는 대가

캐시의 본질은 최신성을 팔아 속도를 사는 것이다. max-age를 길게 잡을수록 빨라지고, 그만큼 낡은 걸 보여줄 위험이 커진다.

이 교환이 낯설지 않다면 맞다. DB 복제 글의 그 문제와 같은 종류다.

HTTP 캐시읽기 복제본
사는 것요청을 안 보내는 속도읽기를 나눠 받는 처리량
파는 것최신성최신성
증상방금 배포한 게 안 보인다방금 쓴 게 안 읽힌다
완화짧은 만료, 조건부 요청, 무효화중요한 읽기는 원본으로

층도 다르고 이름도 다르지만 **“사본을 두면 빨라지고 사본은 늙는다”**는 한 문장이다. 그래서 대응도 닮았다. 늙으면 곤란한 것은 사본을 안 쓰거나, 사본의 수명을 짧게 잡는다.

배포 사고를 막는 방법

이제 처음 문제로 돌아간다. 정적 파일은 오래 캐시하고 싶은데, 배포하면 즉시 반영되게 하고 싶다. 둘 다 원한다.

max-age를 줄이면 배포는 빨리 반영되지만 캐시 이득이 사라진다. 늘리면 반대다. 이 줄다리기의 답은 기간을 조절하는 게 아니라 이름을 바꾸는 것이다.

파일 이름에 내용에서 나온 값을 박는다.

plaintext
app.js          →  app.4f2a9c.js
style.css       →  style.8b1e30.css

내용이 바뀌면 이름이 바뀐다. 이름이 바뀌면 브라우저에게는 처음 보는 URL이라 캐시가 있을 수가 없다. 무효화라는 개념 자체가 필요 없어진다.

그러면 이렇게 나눌 수 있다.

파일캐시 정책이유
app.4f2a9c.js 같은 이름 붙은 자원max-age 아주 길게내용이 바뀌면 이름이 바뀐다
index.htmlno-cache어느 이름의 JS를 쓸지 여기 적혀 있다

HTML만 매번 확인하고, 그 HTML이 가리키는 자원들은 마음껏 오래 캐시한다. 배포하면 HTML이 새 이름을 가리키고, 브라우저는 그 새 이름을 처음 받는다. 프런트엔드 번들러들이 이 이름 붙이기를 기본으로 해주는 게 이 때문이다.

실무에서 자주 만나는 자리

개발자도구에서 캐시를 껐는지 확인한다. Network 탭의 “Disable cache”를 켜둔 채로 개발하면 캐시 문제를 절대 못 본다. 사용자가 겪는 걸 재현하려면 꺼야 한다. 반대로 캐시 때문에 개발이 헷갈리면 켠다.

새로고침과 강력 새로고침은 다르다. 일반 새로고침은 대체로 조건부 요청을 보내고, 강력 새로고침은 캐시를 무시하고 통째로 다시 받는다. 다만 정확한 동작은 브라우저마다 차이가 있어서, “우리 사용자 브라우저에서 어떻게 되나”를 실제로 봐야 한다.

응답 헤더를 붙이는 자리는 대개 앱이 아니다. 정적 파일의 캐시 헤더는 Nginx나 CDN 설정에서 붙이는 경우가 많다. 앱 코드를 아무리 봐도 답이 없으면 앞단 설정을 봐야 한다. 미들웨어 글에서 본 “모든 응답이 똑같이 겪는 일”에 캐시 헤더도 들어간다.

CDN 캐시는 따로 비워야 한다. 브라우저 캐시는 사용자 것이라 손댈 수 없지만, CDN 캐시는 관리자가 비울 수 있다. 잘못된 응답이 CDN에 박히면 배포를 다시 해도 안 고쳐진다. 그래서 사고 대응 절차에 “CDN 퍼지”가 들어간다.

API 응답에는 기본적으로 캐시를 안 건다. 목록이나 상세 조회는 자주 바뀌고 개인화된 경우가 많다. 걸더라도 아주 짧은 max-age나 ETag 기반 조건부 요청 정도로 시작하는 편이 안전하다.

정리

본질안 보내기 → 바뀌었는지만 묻기 → 통째로 받기. 위가 쌀수록 좋다
Cache-Control안 물어봐도 되는 기간(max-age)과 보관 범위(private/public)를 정한다
no-cache저장 금지가 아니라 “쓰기 전에 물어봐라”. 저장 금지는 no-store
조건부 요청ETag/Last-Modified로 물어보고, 안 바뀌었으면 304에 바디 없음
공유 캐시개인화된 응답이 들어가면 남에게 보인다. private·no-store·Vary
교환최신성을 팔아 속도를 산다. 복제본이 늙는 것과 같은 문제
배포 사고기간을 줄이는 대신 파일 이름에 내용 해시를 박는다

캐시까지 오면서 요청을 잘 보내고, 덜 보내고, 안 보내는 법을 봤다. 그런데 그 요청이 지나가는 길에서 누군가 읽고 있다면 지금까지의 이야기가 전부 무의미해진다. 다음 글은 그 길을 감싸는 이야기, 그리고 감싸도 감춰지지 않는 것들에 대한 이야기다.

관련 글