콘텐츠로 이동

Cache-Control과 Age 헤더는 어떻게 같이 읽어야 할까요?

응답에 max-age=3600이 있으면 앞으로 1시간 동안 무조건 새것처럼 보일까요? 사실은 중간 캐시가 이미 얼마 동안 들고 있었는지도 같이 봐야 해요.

CDN, Cache, 그리고 Edge Delivery에서는 사용자 가까이에 복사본을 두면 오리진까지 매번 가지 않아도 된다는 큰 그림을 봤어요. 그리고 브라우저 waterfall에서는 같은 요청이라도 캐시에서 끝난 것과 네트워크로 나간 것이 다르게 보일 수 있다는 감각을 잡았죠.

이번에는 그 복사본이 아직 믿고 써도 되는지를 응답 헤더에서 읽어볼게요.

실제 운영 중에는 이런 장면을 자주 만나요.

HTTP/2 200
cache-control: public, max-age=3600
age: 1240
content-type: text/css

처음 보면 max-age=3600만 눈에 들어와요.

"아, 1시간 캐시구나."

맞아요. 그런데 여기서 끝내면 반쪽만 읽은 거예요. Age: 1240은 이 응답이 공유 캐시 안에서 이미 1240초 정도 나이를 먹었다는 힌트거든요. 오늘의 질문은 이거예요.

"이 응답은 지금 새 원본일까요, 아직 신선한 복사본일까요, 아니면 곧 다시 확인해야 할 사본일까요?"

HTTP 캐시의 기준 동작은 RFC 9111: HTTP Caching에 정리돼 있어요. 이 글에서는 표준 용어를 바닥에 두되, 브라우저와 CDN 앞에서 어떤 순서로 헤더를 읽으면 덜 헷갈리는지에 집중할게요.

이 글의 범위

여기서는 Cache-ControlAge를 중심으로 fresh, stale, shared cache, private cache, revalidation 감각을 잡아요. ETag, Last-Modified, If-None-Match처럼 다시 확인하는 조건부 요청은 다음 글에서 더 자세히 열어볼게요.


빵집 진열대에도 만든 시간과 판매 가능 시간이 같이 붙어 있어요

동네 빵집에서 빵을 산다고 해볼게요.

  • 빵에는 만든 시간이 있어요.
  • 진열대에는 오늘 안에 판매 가능 같은 규칙이 있어요.
  • 손님은 빵이 막 나왔는지, 몇 시간 지났는지 같이 봐요.
  • 같은 빵이라도 냉장 진열대와 카운터 진열대의 규칙이 다를 수 있어요.

웹 캐시도 비슷해요.

빵집 장면 HTTP 캐시 장면
만든 지 몇 시간이 지났는지 Age
몇 시간까지 팔아도 되는지 max-age
냉장 진열대용 별도 규칙 s-maxage
팔기 전에 다시 확인해야 함 no-cache
아예 진열대에 두면 안 됨 no-store
내 집 냉장고에는 둬도 됨 private
모두가 쓰는 진열대에도 둬도 됨 public
판매 가능 시간이 지난 빵 stale response

핵심은 Cache-Control얼마나 오래 믿어도 되는지를 말하고, Age이미 얼마나 오래 캐시에 있었는지를 말한다는 점이에요.

flowchart LR
    A[오리진 응답 생성<br/><small>Age 0초</small>] --> B[CDN / 공유 캐시 저장<br/><small>Age 증가</small>]
    B --> C[브라우저가 응답 수신<br/><small>Cache-Control과 Age를 함께 봄</small>]
    C --> D{아직 fresh인가요?}
    D -->|예| E[복사본 재사용]
    D -->|아니오| F[오리진에 다시 확인]

이 그림에서 max-age는 전체 유효 시간표이고, Age는 그 시간표에서 이미 지나간 시간이에요. 그래서 둘을 따로 읽으면 캐시 상태를 오해하기 쉬워요.

먼저 Cache-Control을 큰 방향으로 읽어요

Cache-Control은 하나의 값처럼 보이지만, 안쪽에는 여러 directive가 쉼표로 붙어요.

Cache-Control: public, max-age=3600, s-maxage=86400

처음에는 아래 순서로 읽으면 좋아요.

먼저 볼 질문 관련 directive 읽는 감각
저장해도 되나요? no-store, private, public 캐시에 둘 수 있는 응답인지
저장한다면 얼마나 fresh인가요? max-age, s-maxage 몇 초 동안 다시 확인 없이 쓸 수 있는지
stale이 되면 어떻게 하나요? no-cache, must-revalidate 쓰기 전에 다시 확인해야 하는지
어떤 캐시에 적용되나요? s-maxage, private 브라우저 캐시인지, CDN 같은 공유 캐시인지

여기서 fresh는 "캐시가 아직 새것으로 취급해도 되는 상태"예요. 반대로 stale은 "저장된 사본은 있지만, 그대로 쓰기 전에 다시 확인해야 할 수 있는 상태"예요.

처음엔 세 단어로 나눠보세요

Cache-Control을 보면 먼저 저장 가능 여부, fresh 시간, 다시 확인 필요 여부로 나눠 읽으면 좋아요. directive 이름을 외우는 것보다 이 세 질문이 먼저예요.

max-age는 "지금부터"가 아니라 "응답이 만들어진 뒤부터"예요

많이 헷갈리는 지점이 여기예요.

Cache-Control: public, max-age=3600
Age: 1200

이걸 브라우저가 받았다면, 단순히 "이제부터 3600초 동안 새것"이라고 읽으면 안 돼요. 공유 캐시가 이미 1200초 동안 들고 있었으니, 남은 freshness는 대략 2400초로 봐야 해요.

gantt
    title max-age와 Age를 같이 읽는 시간표
    dateFormat X
    axisFormat %s초
    section Fresh lifetime
    max-age 3600초 :0, 3600
    section 이미 지난 시간
    Age 1200초 :0, 1200
    section 남은 fresh 시간
    재사용 가능한 시간 :1200, 3600

이 예시는 계산 감각을 보여주기 위한 단순화예요. 실제 캐시는 응답의 Date, 로컬 시계, 전송 지연, 저장 시간 같은 값을 함께 써서 현재 나이를 계산할 수 있어요. 그래도 운영자가 처음 읽을 때의 핵심은 분명해요. max-age에서 Age를 빼는 감각으로 봐야 해요.

헤더 처음 읽기
Cache-Control: max-age=3600 최대 3600초 동안 fresh로 볼 수 있어요
Age: 0 막 저장됐거나 나이가 거의 없어요
Age: 1200 이미 20분 정도 캐시 안에 있었어요
Age: 3590 곧 stale이 될 수 있어요
Age: 3700 freshness lifetime을 넘겼을 가능성이 커요

max-age=3600만 보고 '방금 받은 새 응답'이라고 단정하지 마세요

공유 캐시를 거쳐 온 응답이라면 Age가 이미 커져 있을 수 있어요. 응답이 빠르게 왔다는 사실과 응답이 새 원본이라는 사실은 달라요.

s-maxage는 공유 캐시에 따로 주는 시간표예요

이번에는 이런 응답을 볼게요.

Cache-Control: public, max-age=60, s-maxage=600
Age: 120

여기서 s-maxages는 shared cache 쪽으로 보면 좋아요. CDN, 프록시처럼 여러 사용자가 함께 쓰는 캐시가 s-maxage를 이해하면, 공유 캐시에서는 max-age보다 s-maxage가 우선이에요.

보는 위치 적용 감각
브라우저 같은 private cache max-age=60 중심으로 봐요
CDN 같은 shared cache s-maxage=600 중심으로 봐요
Age: 120 공유 캐시 안에서는 아직 480초 정도 fresh일 수 있어요

이런 설정은 꽤 현실적이에요. 브라우저에는 짧게 저장하게 하고, CDN에는 조금 더 오래 들고 있게 해서 오리진 부담을 줄이고 싶을 수 있거든요.

flowchart LR
    A[브라우저 캐시<br/><small>private cache</small>] -->|max-age=60| B[짧게 재사용]
    C[CDN 캐시<br/><small>shared cache</small>] -->|s-maxage=600| D[더 오래 재사용]
    E[오리진] --> A
    E --> C

이 그림은 같은 응답이라도 어느 캐시가 읽느냐에 따라 시간표가 달라질 수 있다는 점을 보여줘요. 그래서 브라우저 Network 탭만 보고 CDN의 판단까지 단정하면 안 돼요.

no-cache는 "저장 금지"가 아니에요

이름 때문에 가장 많이 헷갈리는 directive가 no-cache예요.

Cache-Control: no-cache

이름만 보면 "캐시하지 마세요"처럼 보이죠. 그런데 HTTP 캐시 문맥에서 no-cache는 보통 저장은 할 수 있지만, 재사용하기 전에는 반드시 다시 확인해야 한다는 뜻으로 읽어야 해요.

directive 저장 가능성 재사용 조건
no-cache 저장할 수 있어요 쓰기 전에 오리진에 재검사해야 해요
no-store 저장하면 안 돼요 저장 자체를 피해야 해요
max-age=0 사실상 바로 stale로 봐요 대개 재검사 흐름으로 이어져요

그래서 개인정보, 결제 화면, 민감한 API 응답에서는 no-cache만 보고 안심하면 부족할 수 있어요. 정말 저장 자체를 피해야 한다면 no-store가 필요한 장면이 많아요.

no-cache를 '아무 데도 저장하지 않는다'로 번역하면 위험해요

no-cache는 캐시에 저장된 사본을 쓰기 전에 다시 확인하라는 쪽에 가까워요. 민감한 응답을 디스크나 공유 캐시에 남기면 안 되는 요구라면 no-store까지 별도로 봐야 해요.

public과 private은 누가 써도 되는 사본인지 가르는 힌트예요

캐시에는 크게 두 감각이 있어요.

  • private cache: 한 사용자만 쓰는 브라우저 캐시 같은 곳
  • shared cache: 여러 사용자가 함께 거치는 CDN, 프록시 같은 곳

이 차이는 로그인 응답에서 특히 중요해요.

Cache-Control: private, max-age=300

이건 브라우저처럼 한 사용자에게 묶인 캐시에는 저장해도 되지만, CDN 같은 공유 캐시가 여러 사용자에게 재사용하면 안 되는 응답이라는 힌트예요.

반대로 이런 응답은 여러 사용자가 같은 사본을 봐도 되는 정적 파일에서 자주 봐요.

Cache-Control: public, max-age=31536000, immutable

파일명에 해시가 붙은 CSS나 JavaScript라면, 내용이 바뀔 때 URL 자체가 바뀌도록 만들 수 있어요.

/assets/app.8f31c2.css
/assets/app.a901bd.css

이런 구조에서는 오래 캐시해도 안전해져요. 같은 URL의 내용을 바꾸는 대신, 새 내용은 새 URL로 배포하기 때문이에요.

응답 종류 어울리는 캐시 감각
해시가 붙은 정적 파일 public, 긴 max-age, immutable
로그인한 사용자 페이지 private 또는 저장 제한
결제/개인정보 응답 no-store 검토
자주 바뀌는 HTML 짧은 max-age, 재검사, CDN 정책 검토
공용 API 응답 public 가능 여부와 Vary 조건 확인

여기서 Vary는 다음 글에서 더 크게 볼 주제예요. 같은 URL이라도 요청 헤더가 다르면 다른 사본으로 봐야 할 수 있거든요.

브라우저 Network 탭에서는 이렇게 좁혀봐요

브라우저에서 캐시가 의심될 때는 응답 헤더만 보지 말고, 요청 행의 상태와 크기도 같이 봐요.

Name              Status   Size                    Time
app.css           200      (memory cache)           0 ms
logo.svg          200      (disk cache)             1 ms
/api/products     200      4.2 KB                   86 ms
document          200      18.5 KB                  420 ms

그리고 응답 헤더에서 아래 신호를 같이 봐요.

신호 먼저 묻는 질문
Cache-Control 저장 가능하고, 얼마 동안 fresh인가요?
Age 공유 캐시 안에서 이미 얼마나 지났나요?
Date 응답이 만들어졌거나 기록된 기준 시각은 언제인가요?
Expires 예전 방식의 만료 시각 힌트가 있나요?
ETag / Last-Modified stale일 때 다시 확인할 단서가 있나요?
CDN 상태 헤더 HIT, MISS, BYPASS 같은 벤더 힌트가 있나요?
Vary 어떤 요청 헤더 차이까지 캐시 키에 들어가나요?

CDN 상태 헤더 이름은 제품마다 달라요

어떤 서비스는 cf-cache-status, 어떤 서비스는 x-cache, x-cache-status, cdn-cache-control 같은 다른 이름을 쓸 수 있어요. 이름보다 HIT인지 MISS인지, BYPASS인지, Age와 서로 말이 맞는지를 먼저 보세요.

예시로 세 응답을 같이 읽어볼게요

1. 아직 신선한 정적 파일

HTTP/2 200
cache-control: public, max-age=31536000, immutable
age: 86400
content-type: text/css

이 응답은 Age가 하루나 됐지만, max-age가 1년이라면 아직 fresh로 볼 수 있어요. 해시 파일명까지 붙어 있다면 꽤 자연스러운 설정이에요.

읽는 순서는 이래요.

질문
저장해도 되나요? public이라 공유 캐시도 가능해 보여요
얼마나 fresh인가요? max-age=31536000
이미 얼마나 지났나요? Age: 86400
위험한가요? URL 버전 관리가 되어 있다면 보통 괜찮아요

2. 곧 다시 확인해야 할 HTML

HTTP/2 200
cache-control: public, max-age=300
age: 295
content-type: text/html

이 응답은 아직 fresh일 수 있지만 거의 끝에 가까워요. 몇 초 뒤 같은 요청은 stale로 판단되어 재검사나 새 요청으로 이어질 수 있어요.

이럴 때 어떤 사용자는 빠르게 응답받고, 바로 다음 사용자는 오리진 확인 때문에 조금 느려질 수 있어요. 그래서 캐시가 얽힌 성능 문제는 "방금 내 요청" 하나만 보면 흔들릴 수 있어요.

3. 저장은 되지만 매번 확인해야 하는 응답

HTTP/2 200
cache-control: no-cache
etag: "profile-v42"
content-type: application/json

이건 저장 자체가 완전히 금지된 응답이 아니에요. 대신 다음에 재사용하려면 ETag 같은 validator로 "이 사본 그대로 써도 되나요?"를 다시 물어봐야 해요.

sequenceDiagram
    participant B as 브라우저
    participant C as 캐시
    participant O as 오리진

    B->>C: 같은 URL 다시 요청
    C->>O: 이 ETag 아직 맞나요?
    alt 바뀌지 않음
        O-->>C: 304 Not Modified
        C-->>B: 저장된 본문 재사용
    else 바뀜
        O-->>C: 200 OK + 새 본문
        C-->>B: 새 응답 전달
    end

이 흐름에서 캐시는 본문을 다시 내려받지 않을 수 있지만, 오리진 확인 자체는 필요해요. 그래서 no-cache 응답은 "항상 네트워크 비용 0"이 아니에요.

잘못 읽기 쉬운 함정

Age가 있으면 무조건 CDN HIT라고 보기

Age는 공유 캐시를 거친 힌트가 될 수 있지만, 제품별 헤더와 로그를 같이 봐야 해요. CDN이 Age를 갱신하는 방식, 중간 프록시, 브라우저 캐시 표시가 섞일 수 있어요. 가능하면 CDN 상태 헤더와 함께 읽으세요.

Age가 없으면 캐시가 안 됐다고 단정하기

브라우저 메모리 캐시나 디스크 캐시는 Network 탭에 (memory cache), (disk cache)처럼 보일 수 있고, 응답 헤더에 항상 기대한 형태의 Age가 남지는 않을 수 있어요. 공유 캐시와 브라우저 캐시는 관측 신호가 달라요.

no-cacheno-store를 같은 뜻으로 보기

둘은 달라요. no-cache는 재사용 전 재검사, no-store는 저장 금지에 가까워요. 민감한 응답에서는 이 차이가 보안과 개인정보 노출로 이어질 수 있어요.

max-age를 HTML에 아무 생각 없이 붙이기

해시가 붙은 정적 파일은 긴 캐시가 잘 맞지만, HTML은 배포와 라우팅의 시작점일 때가 많아요. HTML을 너무 오래 공유 캐시에 두면 새 JavaScript 파일 경로나 새 설정이 늦게 퍼질 수 있어요.

public이면 모든 응답이 안전하다고 보기

public은 공유 캐시에 저장될 수 있다는 강한 힌트예요. 사용자별 내용, 쿠키 영향, 인증 헤더, Vary 조건을 제대로 보지 않으면 다른 사람에게 보여주면 안 되는 응답이 재사용될 수 있어요.

장애나 이상 증상을 만나면 이렇게 좁혀봐요

flowchart TD
    A[캐시가 의심되는 증상] --> B{오래된 내용이 보이나요?}
    B -->|예| C[Cache-Control, Age, CDN 상태 확인]
    B -->|아니오| D{생각보다 느린가요?}
    C --> E{Age가 max-age에 가까운가요?}
    E -->|예| F[stale 직전 / 재검사 타이밍 확인]
    E -->|아니오| G[캐시 키, Vary, purge 여부 확인]
    D -->|예| H[HIT/MISS, Waiting, 오리진 요청 여부 확인]
    D -->|아니오| I[브라우저 캐시와 Service Worker 조건 확인]

실전에서는 아래 질문을 남기면 좋아요.

질문 답을 찾을 곳
이 응답은 공유 캐시에 저장돼도 되나요? Cache-Control, 인증/쿠키 조건
남은 freshness는 어느 정도인가요? max-age, s-maxage, Age
브라우저 캐시인가요, CDN 캐시인가요? Size 열, CDN 상태 헤더, Age
stale이 되면 다시 확인하나요? no-cache, must-revalidate, validator
같은 URL이어도 요청 조건이 다른가요? Vary, 쿠키, Accept-Encoding, 언어 헤더
배포 직후 오래된 파일이 남았나요? purge/invalidation 기록, 파일명 해시

자, 정리해볼까요?

오늘 우리가 배운 것

  • Cache-Control은 캐시가 응답을 저장해도 되는지, 얼마나 fresh로 볼지, 언제 다시 확인할지를 알려줘요.
  • Age는 공유 캐시 안에서 응답이 이미 얼마나 오래 있었는지 보여주는 중요한 힌트예요.
  • max-age=3600은 "브라우저가 지금 받은 순간부터 3600초"가 아니라, 응답 freshness lifetime을 말해요. 중간 캐시의 Age를 같이 봐야 해요.
  • s-maxage는 CDN 같은 shared cache에서 max-age보다 우선할 수 있어요.
  • no-cache는 저장 금지가 아니라 재사용 전 재검사에 가깝고, no-store는 저장 자체를 피해야 하는 장면에 가까워요.
  • 정적 파일, HTML, 로그인 응답, API 응답은 캐시 전략을 다르게 읽어야 해요.

이어서 보면 좋은 글

이어서 볼 질문

다음에는 같은 URL처럼 보여도 왜 캐시가 서로 다른 사본을 만들 수 있는지, Vary와 캐시 키를 중심으로 이어서 볼 수 있어요.

댓글