한양과학기술고등학교

에듀테크 프로젝트 대시보드

HTTP 캐싱 (Cache-Control·ETag)

HTTP 캐싱 — 두 번째 요청은 왜 이렇게 빠를까

Cache-Control · ETag · 304 Not Modified · 강제 캐시(fresh)와 조건부 재검증(revalidation)

같은 웹툰 페이지를 새로고침하면 처음보다 훨씬 빨리 뜬다. 브라우저가 이미지·CSS·JS를 로컬에 저장해 두고, 서버에 다시 묻지 않거나(강제 캐시) 아주 짧게만 묻기(재검증) 때문이다. 이 규칙을 정하는 게 서버가 응답에 붙이는 Cache-Control, ETag, Last-Modified 헤더다.

1. 캐시가 노리는 두 가지 낭비

HTTP 캐시는 이 둘을 단계적으로 줄인다. 가장 센 방법은 “묻지도 말고 그냥 로컬 것 써”, 그다음은 “바뀌었는지만 물어보고, 안 바뀌었으면 본문은 받지 마”.

2. 신선도(freshness): 안 물어봐도 되는 시간

서버가 Cache-Control: max-age=60 을 주면, 브라우저는 그 응답을 받은 뒤 60초 동안은 “신선하다(fresh)” 고 보고 네트워크를 아예 건드리지 않는다. 이때 개발자 도구 Network 탭에 (disk cache), (memory cache) 라고 뜨고 시간은 0 ms에 가깝다.

60초가 지나면 캐시는 “상했다(stale)” 상태가 된다. 버리는 게 아니라, 다음 요청 때 서버에 “아직 유효해?” 하고 재검증한다(3절).

지시어뜻
max-age=NN초 동안 fresh. 그동안 요청 안 감
no-cache저장은 하되 쓰기 전에 매번 재검증 (이름과 달리 "캐시 안 함"이 아님)
no-store아예 저장 금지 (은행 거래내역 등 민감 정보)
private / public브라우저만 저장 / 중간 CDN·프록시도 저장 가능
immutablefresh 동안엔 새로고침해도 재검증조차 하지 마 (해시 파일명 정적 자원)

3. 데모 ①: 신선도 타이머와 캐시 HIT / MISS

아래는 브라우저가 logo.png 를 반복 요청하는 상황이다. [요청 보내기] 를 눌러 보라. 캐시가 fresh면 요청이 서버까지 가지 않고(HIT), stale이거나 비어 있으면 서버까지 간다(MISS / 재검증).

브라우저 캐시

남은 신선도 비어 있음
캐시 없음 — 다음 요청은 서버로 갑니다.
GET logo.png
200 OK

서버 (origin)

파일 버전 v1
ETag: "a1b2c3"
응답 본문 크기: 184 KB
요청 0 HIT 0 304 재검증 0 MISS(본문 전송) 0 아낀 전송량 0 KB

해 보기: max-age=10 으로 두고 빠르게 두 번 → 두 번째는 HIT(0 ms). 11초 기다렸다 다시 → stale이라 서버로 가지만 파일이 그대로면 304(본문 0 KB). 중간에 서버 파일 수정 후 요청하면 200(전체 전송).

4. 재검증(revalidation): "바뀐 것만 알려줘"

캐시가 stale이 되면 브라우저는 저장해 둔 검증자(validator) 를 요청 헤더에 실어 보낸다.

저장된 ETag: "a1b2c3" → 요청에 If-None-Match: "a1b2c3" → 서버 비교 같음 → 304 (본문 없음) / 다름 → 200 + 새 본문·새 ETag
검증자응답 헤더요청 헤더특징
ETag (콘텐츠 지문)ETag: "a1b2c3"If-None-Match내용이 1바이트만 달라도 감지. 1초 안에 두 번 바뀌어도 정확
수정 시각Last-ModifiedIf-Modified-Since초 단위 해상도. 내용 같아도 touch하면 바뀜. ETag 없을 때의 대안

304 Not Modified 응답은 상태줄과 헤더뿐, 본문이 없다. 그래서 왕복 1번은 들지만 2MB 대신 몇백 바이트만 오간다. 그리고 브라우저는 기존 캐시의 신선도 타이머를 리셋한다.

5. 데모 ②: ETag 조건부 요청 직접 조립

왼쪽에서 요청을 만들고 [전송] 하면 서버가 어떻게 판단하는지 실제 헤더로 보여준다.

요청 조립

GET
...

서버 판단

전송을 눌러 보세요.
# 여기에 실제 요청/응답 헤더가 표시됩니다

6. 브라우저가 캐시를 쓰는 순서 (요약)

def fetch(url):
    entry = cache.get(url)

    # ① 저장된 게 있고 아직 신선하면 → 네트워크 0회
    if entry and entry.age < entry.max_age and not entry.no_cache:
        return entry.body          # HIT  (disk/memory cache)

    # ② 없거나 stale → 조건부 요청으로 재검증
    req = Request(url)
    if entry:
        req.headers["If-None-Match"]     = entry.etag
        req.headers["If-Modified-Since"] = entry.last_modified

    resp = network.send(req)

    if resp.status == 304:       # 안 바뀜: 본문 없음
        entry.refresh(resp.headers)  # 신선도 타이머 리셋
        return entry.body

    cache.store(url, resp)            # 200: 새 본문 + 새 헤더 저장
    return resp.body

7. 실무에서 자주 쓰는 조합

자원헤더이유
app.4f9a2c.js (해시 파일명)Cache-Control: public, max-age=31536000, immutable내용 바뀌면 파일명이 바뀌니 1년 캐시해도 안전
index.htmlCache-Control: no-cache항상 재검증 — 새 배포를 즉시 반영해야 함
사용자 프로필 APICache-Control: private, max-age=0, must-revalidate + ETag남과 공유 금지, 매번 확인하되 안 바뀌면 304
로그인·결제 응답Cache-Control: no-store어디에도 남기면 안 됨
흔한 실수: HTML을 길게 캐시했다가 배포해도 사용자가 옛 화면을 본다 → HTML은 no-cache, 정적 자원은 해시 파일명 + 1년. 그리고 no-cache ≠ no-store 를 꼭 구분할 것.
Cache-Control: max-age / no-cache / no-store freshness & stale ETag · If-None-Match Last-Modified · If-Modified-Since 304 Not Modified immutable · hashed filename