
"왜 수정한 게 반영이 안 되지?" — 모든 웹 개발자가 한 번쯤 겪는 악몽이다.
이 글에서는 HTTP 캐시의 원리를 근본부터 파헤쳐, 캐시를 제대로 이해하고 활용하는 방법을 알아본다.
캐시는 "한번 가져온 것을 저장해두고 다시 쓰는 것"이다.
편의점에 물을 사러 간다고 생각해보자.
캐시 없이:
집 → 편의점 → 집 (물 1병)
집 → 편의점 → 집 (물 1병)
집 → 편의점 → 집 (물 1병)
매번 왕복 10분...
캐시 사용:
집 → 편의점 → 집 (물 10병 사서 냉장고에 저장)
냉장고에서 꺼냄 (10초)
냉장고에서 꺼냄 (10초)
웹에서도 똑같다.
캐시 없이:
브라우저 → 서버 요청 → 응답 (500ms)
브라우저 → 서버 요청 → 응답 (500ms)
캐시 사용:
브라우저 → 서버 요청 → 응답 → 저장 (500ms)
로컬 캐시에서 읽음 (5ms) ← 100배 빠름!🔥
HTTP에서 리소스(Resource)란 브라우저가 요청할 수 있는 모든 파일이다.
| 리소스 종류 | 예시 | 캐시 중요도 |
|---|---|---|
| HTML | index.html, about.html | 높음 |
| CSS | style.css, main.css | 높음 |
| JavaScript | app.js, bundle.js | 높음 |
| 이미지 | logo.png, hero.jpg | 높음 |
| 폰트 | Pretendard.woff2 | 중간 |
| API 응답 | /api/users, /api/products | 중간 |
| 측면 | 캐시 없음 | 캐시 있음 |
|---|---|---|
| 속도 | 매번 네트워크 왕복 | 즉시 로드 |
| 서버 부하 | 모든 요청 처리 | 대부분 요청 안 옴 |
| 트래픽 비용 | 매번 데이터 전송 | 거의 전송 없음 |
| 사용자 경험 | 느리고 답답함 | 빠르고 쾌적함 |
실제 수치로 보면 이렇다.
https://example.com에 접속하면, 브라우저는 HTML 하나만 요청하는 게 아니다.
1. 사용자가 URL 입력 후 엔터 (HTML 요청)
└─ GET /index.html
2. HTML 파싱 중 발견된 리소스들 (자동 요청)
└─ <link href="style.css"> → GET /style.css
└─ <script src="app.js"> → GET /app.js
└─ <img src="logo.png"> → GET /logo.png
3. JavaScript 실행 중 동적 요청
└─ fetch('/api/users') → GET /api/users
4. 브라우저 자동 요청
└─ favicon.ico (자동)
Chrome DevTools Network 탭에서 확인하면 이런 식이다.
Name Status Type Size Time
───────────────────────────────────────────────────────
localhost 200 document 15.2 KB 45ms ← HTML
style.a1b2c3.css 200 stylesheet 8.5 KB 23ms ← CSS
app.d4e5f6.js 200 script 125 KB 89ms ← JS
logo.png 200 png 12.3 KB 34ms ← 이미지
api/users 200 fetch 2.1 KB 156ms ← API
중요한 점: 각 요청은 독립적으로 캐시된다. 같은 페이지의 리소스라도 각각 다른 캐시 정책을 가질 수 있다.
사용자 컴퓨터 인터넷 서버
────────────── ────── ──────
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Memory │ │ │ │ │
│ Cache │ │ CDN │ │ 원본 │
│ (브라우저) │◄──────────►│ Cache │◄────────►│ 서버 │
├─────────────┤ │ │ │ │
│ Disk │ │ │ │ │
│ Cache │ │ │ │ │
│ (브라우저) │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
Private Cache Shared Cache Origin
(이 사용자만) (모든 사용자 공유) (원본 데이터)
| Memory Cache | Disk Cache | |
|---|---|---|
| 저장 위치 | RAM | SSD/HDD |
| 속도 | 가장 빠름 (~0ms) | 빠름 (~5ms) |
| 수명 | 탭 닫으면 사라짐 | 수 주 ~ 수 개월 |
| 대상 | 현재 페이지 리소스 | 큰 파일, 자주 방문 |
| DevTools | (memory cache) | (disk cache) |
CDN(Content Delivery Network)은 전세계에 분산된 서버 네트워크다.
상황: 버그 수정 후 배포
1. CDN Invalidation 실행
→ CDN 캐시 삭제됨
2. 하지만 사용자 A의 브라우저에는?
→ 여전히 이전 버전이 Disk Cache에 있음
→ max-age 만료 전까지 서버에 요청조차 안 보냄
→ 사용자 A는 계속 버그 버전을 봄 😱
결론: 브라우저 캐시는 서버에서 제어할 수 없다. 처음부터 신중하게 설정해야 한다.
브라우저는 HTTP 응답의 Cache-Control 헤더를 보고 캐시 여부를 결정한다.
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000
ETag: "abc123"
HTTP 응답 수신
│
▼
┌─────────────────────────────────┐
│ Cache-Control: no-store 있음? │
└─────────────┬───────────────────┘
│
Yes │ No
│ │ │
▼ │ ▼
저장 안 함 │ Cache-Control 또는 Expires 있음?
│ │
│ Yes │ No
│ │ │ │
│ ▼ │ ▼
│ 해당 규칙대로 │ 브라우저 휴리스틱
│ 캐시 저장 │ (기본 캐시 적용)
│ │ │
│ ▼ │
│ private 있음? │
│ │ │
│ Yes │ No │
│ │ │ │ │
│ ▼ │ ▼ │
│ 브라우저만 │ 브라우저 + CDN
│ 저장 │ 모두 저장 가능
Cache-Control: max-age=3600
의미: "이 리소스는 3600초(1시간) 동안 신선하다"
| 값 | 시간 | 용도 |
|---|---|---|
max-age=0 | 0초 | 매번 재검증 |
max-age=60 | 1분 | 빈번히 바뀌는 데이터 |
max-age=3600 | 1시간 | 적당히 바뀌는 데이터 |
max-age=86400 | 1일 | 하루 단위 갱신 |
max-age=31536000 | 1년 | 영구 캐시 |
immutable: immutable을 추가하면 브라우저가 max-age 기간 동안 재검증 시도조차 하지 않는다. 파일명에 해시가 포함된 정적 파일(app.a1b2c3.js)에 사용하면, 사용자가 새로고침해도 불필요한 304 요청을 방지할 수 있다.
이 두 가지는 이름만 비슷하고 완전히 다르다.
Cache-Control: no-cache
동작 과정:
1단계: 저장
─────────────────────────────────────────────────────────
• 브라우저 Disk Cache에 저장함
• CDN 캐시에도 저장 가능 (public인 경우)
2단계: 다음에 이 리소스가 필요할 때
─────────────────────────────────────────────────────────
"다음에 필요할 때"란?
• 페이지 새로고침
• 다른 페이지에서 같은 리소스 요청
• fetch()로 같은 URL 호출
• 뒤로가기/앞으로가기
브라우저 → 원본 서버
"이거 바뀌었어?"
(If-None-Match: "abc123")
│
▼
┌─────────────────────────────────────┐
│ 서버가 ETag 또는 Last-Modified 비교 │
└─────────────────────────────────────┘
│
안 바뀜 │ 바뀜
│ │ │
▼ │ ▼
304 Not │ 200 OK
Modified │ + 새 파일
(본문 없음) │
│ │ │
▼ │ ▼
캐시된 버전 │ 새 버전으로
그대로 사용 │ 캐시 갱신
304 응답의 효율성: 59KB 파일을 324바이트로 검증 → 99.5% 절약
Cache-Control: no-store
사용 케이스: 은행 계좌 잔액, 의료 기록, 결제 정보
| no-cache | no-store | |
|---|---|---|
| 캐시 저장 | 저장함 ✅ | 저장 안 함 ❌ |
| 재검증 | 매번 필요 ✅ | 해당 없음 ❌ |
| 304 응답 | 가능 ✅ | 불가능 ❌ |
| 보안 수준 | 중간 | 최고 |
Cache-Control: public, max-age=3600 # CDN도 저장 가능
Cache-Control: private, max-age=3600 # 브라우저에만 저장
public: 모든 캐시(브라우저, CDN, 프록시)에 저장 가능
private: 브라우저 캐시에만 저장, CDN/프록시는 저장 금지
Cache-Control: s-maxage=31536000, max-age=0
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 브라우저 │ │ CDN │ │ 원본 │
│ │ │ │ │ 서버 │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ max-age=0 │ s-maxage=1년 │
│ (매번 확인) │ (1년 캐시) │
│ │ │
▼ ▼ ▼
저장하되 매번 1년 동안 저장 원본 데이터
CDN에 재검증 배포 시 무효화
결과:
왜 브라우저는 캐시 안 하고 CDN만 캐시할까?
핵심: 서버에서 제어 가능한가?
브라우저 캐시:
├─ 서버에서 제어 불가능
├─ 사용자 컴퓨터에 저장됨
├─ 문제 시: "고객님, 브라우저 캐시 지워주세요..."
CDN 캐시:
├─ 서버에서 제어 가능
├─ Invalidation으로 즉시 삭제
├─ 문제 시: aws cloudfront create-invalidation 실행
이 전략은 프로덕션 배포에서 가장 많이 쓰이는 패턴이다.
Cache-Control: max-age=60, stale-while-revalidate=300
의미:
맞다, 절반은. HTTP 스펙상 Authorization 헤더가 있는 요청의 응답은 기본적으로 CDN에 캐시되지 않는다. 하지만 서버가 명시적으로 public이나 s-maxage를 설정하면 CDN이 캐시한다.
Authorization 헤더와 캐시의 관계
─────────────────────────────────────────────────────────
요청:
GET /api/my-profile
Authorization: Bearer eyJhbGciOiJIUzI1...
CDN 판단:
┌─────────────────────────────────────┐
│ Authorization 헤더 있음? │
└──────────────┬──────────────────────┘
Yes │
│ │
▼ │
┌─────────────────────────────────────┐
│ 응답에 public 또는 s-maxage 있음? │
└──────────────┬──────────────────────┘
Yes │ No
│ │ │
▼ │ ▼
CDN 캐시 │ CDN 캐시 안 함 (기본 동작, 안전)
(위험!) │
즉, Authorization이 있어도 서버가 public을 명시하면
CDN이 캐시해버릴 수 있음!
s-maxage 추가해도 private이 우선결론: 개인 데이터에는 항상 private을 명시하는 것이 안전하다.
서버는 파일이 변경됐는지 판단하기 위해 두 가지 방법을 사용한다.
ETag (권장): 파일 내용의 해시값
# 첫 응답
ETag: "abc123def456"
# 재검증 요청
If-None-Match: "abc123def456"
Last-Modified: 파일 수정 시간
# 첫 응답
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT
# 재검증 요청
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT
| ETag | Last-Modified | |
|---|---|---|
| 정확도 | 높음 (내용 기반) | 낮음 (시간 기반, 1초 단위) |
| 권장 | 우선 사용 | 보조적 사용 |
재검증 상세 흐름
─────────────────────────────────────────────────────────
브라우저 CDN 원본 서버
│ │ │
│ 1. 재검증 요청 (캐시 만료됨) │
│─────────────►│ │
│ │ │
│ │ 2. CDN도 재검증 필요?
│ │──────────────────►│
│ │ │
│ │ │ 3. ETag 비교
│ │ │
│ │ 4-A. 304 Not Modified (안 바뀜)
│ │◄──────────────────│
│ │ │
│ │ 4-B. 200 OK + 새 파일 (바뀜)
│ │◄──────────────────│
│ │ │
│ 5. 결과 전달 │ │
│◄─────────────│ │
2023년 3월, ChatGPT에서 다른 사용자의 채팅 기록이 보이는 심각한 버그가 발생했다.
문제 상황:
• 사용자들이 ChatGPT 사이드바에서 자신의 채팅 기록 대신
다른 사람의 채팅 제목이 보이는 현상 발생
• 일부 사용자는 타인의 결제 정보 일부까지 노출됨 😱
원인 (OpenAI 공식 발표):
• Redis 캐시 라이브러리의 버그
• 특정 조건에서 캐시된 데이터가 잘못된 사용자에게 반환
• 개인 데이터에 대한 캐시 설정 문제
교훈:
• 개인 데이터는 반드시 private 또는 no-store 설정
• 캐시 키에 사용자 식별자 포함 필수
문제 시나리오:
요청 A:
GET /api/profile
Authorization: Bearer token_user_A
→ 응답: {"name": "김철수", "balance": 1000000}
→ CDN: 캐시 키 = "GET /api/profile"로 저장
요청 B (5분 후):
GET /api/profile
Authorization: Bearer token_user_B ← 다른 토큰!
→ CDN: "GET /api/profile" 캐시 있네? 반환!
→ B에게 A의 프로필이 반환됨!
CDN 입장에서는 Authorization 헤더를 안 보기 때문에
두 요청이 "같은 요청"으로 보임
해결 방법: 개인 데이터에는 항상 private 또는 no-store를 명시하자.
Day 1: 첫 배포
• style.css 배포
• Cache-Control: max-age=31536000 (1년)
• 사용자 브라우저에 캐시됨
Day 2: 버그 수정 후 재배포
• style.css 내용 수정 후 배포
• 서버에는 새 버전이 있음
하지만...
• 사용자 브라우저: "max-age 아직 안 지났네? 캐시 쓸게~"
• 서버에 요청조차 안 보냄
• 사용자는 364일 동안 버그 버전을 봄
원리:
• 파일 내용이 바뀌면 → 파일명도 바뀜
• 파일명이 바뀌면 → 브라우저 입장에서 "새로운 파일"
• 새로운 파일이므로 → 캐시 없이 새로 다운로드
Day 1:
파일명: style.a1b2c3d4.css
내용: .button { color: red; }
└───── 이 내용의 해시 = a1b2c3d4
Day 2: 버그 수정
파일명: style.x7y8z9w0.css ← 파일명 자체가 바뀜!
내용: .button { color: blue; }
└───── 이 내용의 해시 = x7y8z9w0
정답: npm run build 시점에 빌드 도구가 붙인다.
1. 소스 코드
src/
├── styles/main.css
└── scripts/app.js
2. npm run build 실행 ← 여기서 해시가 붙음! 🔥
3. 빌드 결과물
dist/
├── index.html (해시된 파일명을 참조)
└── assets/
├── main.a1b2c3d4.css
└── app.e5f6g7h8.js
4. 배포 (Docker, S3, Vercel 등)
• 이미 해시가 붙은 파일들을 업로드
5. 사용자 요청
• 해시가 포함된 URL로 요청
• 예: GET /assets/style.a1b2c3d4.css
HTML에 해시를 붙이면 안 되는 이유
─────────────────────────────────────────────────────────
HTML에 해시를 붙이면?
─────────────────────────────────────────────────────────
index.a1b2c3d4.html
문제 1: 사용자가 어떤 URL로 접속?
• https://example.com/ → index.html을 찾음
• https://example.com/index.a1b2c3d4.html ???
문제 2: 배포할 때마다 URL이 바뀜
• v1: https://example.com/index.a1b2c3d4.html
• v2: https://example.com/index.x7y8z9w0.html
• 북마크, 공유 링크가 다 깨짐!
문제 3: SEO 문제
• 검색엔진이 인덱싱한 URL이 무효화됨
올바른 전략:
HTML: 고정 URL + 캐시 안 함
├── index.html (URL 고정)
├── Cache-Control: no-cache
└── 항상 최신 버전 확인
JS/CSS: 해시 포함 + 영구 캐시
├── app.a1b2c3d4.js (해시 포함)
├── Cache-Control: max-age=31536000
└── 내용 바뀌면 URL도 바뀌므로 안전
흐름:
사용자 → index.html (매번 최신 확인)
└─ <script src="app.a1b2c3d4.js"> ← 캐시 있으면 사용
└─ <link href="style.e5f6g7h8.css"> ← 캐시 있으면 사용
배포 후:
사용자 → index.html (새 버전)
└─ <script src="app.NEW_HASH.js"> ← 새 URL이므로 다운로드
캐시를 이해하는 데 가장 중요한 원칙 하나가 있다.
캐시는 URL을 키로 저장된다.
브라우저 캐시 저장소 (개념적):
┌──────────────────────────────────────┬─────────────────┐
│ URL (캐시 키) │ 저장된 파일 │
├──────────────────────────────────────┼─────────────────┤
│ /images/cat.jpg │ 고양이 사진 │
│ /images/cat.jpg?v=2 │ 다른 고양이 사진 │ ← 다른 캐시!
│ /images/dog.jpg │ 강아지 사진 │
└──────────────────────────────────────┴─────────────────┘
이 부분이 많이 헷갈리니까 케이스별로 정리해보자.
| 상황 | URL 변경 | 캐시 동작 | 결과 |
|---|---|---|---|
| 백엔드 API에서 다른 이미지 URL 반환 | O | 캐시 미스 | 새 이미지 다운로드 |
| 백엔드 API에서 같은 이미지 URL 반환 | X | 캐시 히트 | 캐시된 이미지 사용 |
| public 이미지 파일 교체 (같은 경로) | X | 캐시 히트 | 이전 이미지가 보임 |
| public 이미지 삭제 | X | 캐시 히트 | 삭제 전 이미지가 보임 |
케이스 1: 백엔드 API에서 다른 URL 반환
// Before: 백엔드 API 응답
{ "avatar": "/images/profile-old.jpg" }
// After: 백엔드 API 응답 변경
{ "avatar": "/images/profile-new.jpg" }
// 결과: URL이 다름 → 캐시 미스 → 새 이미지 다운로드
// 정상 동작한다!
케이스 2: 파일만 교체 (같은 URL)
// public/logo.png 파일을 다른 이미지로 교체
<img src="/logo.png" />
// 결과: URL이 같음 → 캐시 히트 → 이전 이미지가 보임
// max-age 만료 전까지 새 이미지가 안 보인다!
케이스 3: 파일 삭제
// public/banner.png 파일 삭제
<img src="/banner.png" />
// 결과:
// - 캐시에 있는 사용자: 삭제 전 이미지가 보임
// - 캐시에 없는 사용자: 404 에러
// 사용자마다 다른 결과가 나온다!
브라우저의 관점에서 생각해보면 명확하다.
브라우저가 <img src="/logo.png">를 만났을 때:
─────────────────────────────────────────────────────────
1. "내 캐시에 /logo.png 있어?" 확인
└─ 있고, max-age 안 지남 → 캐시 사용! (서버에 안 물어봄)
└─ 있고, max-age 지남 → 서버에 재검증 요청
└─ 없음 → 서버에 요청
2. 서버에 요청하지 않았다면?
└─ 서버의 파일이 바뀌든, 삭제되든 모름
└─ 브라우저 입장에서는 "캐시에 있으니까 그거 쓸게~"
핵심: 브라우저는 URL만 보고 캐시를 찾는다.
서버의 파일 상태는 요청을 보내야만 알 수 있다.
전략 1: 파일명에 버전/해시 포함 (권장)
<img src="/logo-v2.png" />
<img src="/logo.a1b2c3.png" />
// 장점: 확실한 캐시 무효화
// 단점: 파일명 관리 필요
전략 2: 쿼리스트링 사용
// 버전 번호
<img src="/logo.png?v=2" />
// 타임스탬프 (매번 새로 받음)
<img src={`/logo.png?t=${Date.now()}`} />
// 장점: 파일명 안 바꿔도 됨
// 단점: 일부 CDN에서 무시될 수 있음
전략 3: Next.js Image 최적화 사용
import Image from 'next/image'
// Next.js가 자동으로 최적화 + 캐시 관리
<Image src="/logo.png" width={200} height={100} alt="Logo" />
// _next/image?url=... 형태로 변환됨
// 나쁜 예: 파일명 고정
const avatarUrl = "/users/123/avatar.jpg"
// 문제: 사용자가 프로필 사진 바꿔도 캐시된 이전 사진이 보임
// 좋은 예 1: 업데이트 시간 포함
const avatarUrl = `/users/123/avatar.jpg?updated=${user.updatedAt}`
// 좋은 예 2: 해시 포함
const avatarUrl = `/users/123/avatar-${user.avatarHash}.jpg`
// 좋은 예 3: CDN URL에 버전 포함
const avatarUrl = `https://cdn.example.com/users/123/${user.avatarVersion}/avatar.jpg`
| 리소스 | 변경 빈도 | 권장 전략 | Cache-Control |
|---|---|---|---|
| HTML | 배포마다 | 해시 불가, 캐시 안 함 | no-cache |
| JS/CSS (빌드됨) | 배포마다 | 파일명 해시 | public, max-age=31536000, immutable |
| 로고, 아이콘 | 드묾 | 버전 쿼리스트링 | public, max-age=86400 |
| 사용자 프로필 사진 | 자주 | URL에 timestamp 포함 | max-age=3600 |
| 폰트 | 거의 없음 | 영구 캐시 | public, max-age=31536000 |
| API (공개) | 상황에 따라 | 태그 기반 무효화 | public, max-age=60 |
| API (개인) | 매 요청 | CDN 캐시 금지 | private, no-cache |
| 민감 정보 | 매 요청 | 저장 자체 금지 | no-store |
HTML: no-cache, no-store, must-revalidate
JS/CSS: s-maxage=31536000, max-age=0
왜 이렇게 설정할까?
HTML에 no-cache, no-store, must-revalidate
───────────────────────────────────────────────────────────────
index.html 내용:
<script src="/static/js/main.a1b2c3.js"></script>
──────
해시 포함!
새 버전 배포 후:
<script src="/static/js/main.x7y8z9.js"></script>
──────
해시가 바뀜!
HTML이 캐시되면?
→ 옛날 해시(a1b2c3)를 가진 JS를 계속 요청
→ 새 JS(x7y8z9)가 있어도 못 받음!
그래서 HTML은 절대 캐시하면 안 됨!
JS/CSS에 s-maxage=31536000, max-age=0
───────────────────────────────────────────────────────────────
s-maxage=31536000 (CDN용: 1년)
• 파일명에 해시 있음 (main.a1b2c3.js)
• 같은 URL = 내용이 절대 안 바뀜
• CDN에서 1년 캐시해도 안전!
max-age=0 (브라우저용: 매번 확인)
• 브라우저는 매번 CDN에 확인 요청
• 하지만! CDN이 304로 빠르게 응답
• 실제 다운로드는 안 함 (ETag 매칭)
이 조합의 장점
───────────────────────────────────────────────────────────────
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 브라우저 │ │ CDN │ │ Origin │
│ │ │ │ │ (S3) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ max-age=0이니까 │ │
│ 확인 요청 보냄 │ │
│──────────────────────▶│ │
│ │ │
│ │ s-maxage=31536000 │
│ │ 아직 유효! Origin │
│ │ 안 가도 됨 │
│ │ │
│◀──────────────────────│ │
│ 바로 응답 (또는 304) │ │
결과:
• Origin(S3) 부하: 거의 없음
• 배포 후 반영: 즉시 (새 해시니까)
• 브라우저 캐시 문제: 없음 (매번 확인하니까)
• 비용: CDN 캐시 덕분에 S3 요청 최소화
# 절대 캐시 안 함
Cache-Control: no-store
# 캐시하되 매번 확인
Cache-Control: no-cache
# 1시간 캐시
Cache-Control: max-age=3600
# 1년 캐시 (영구)
Cache-Control: max-age=31536000
# 브라우저만 캐시 (CDN 금지)
Cache-Control: private, max-age=3600
# CDN도 캐시
Cache-Control: public, max-age=31536000
# 브라우저는 확인, CDN은 캐시
Cache-Control: max-age=0, s-maxage=31536000
# 영구 캐시 + 재검증도 하지 마
Cache-Control: public, max-age=31536000, immutable
정성스럽게 정리하셨네요.. 캐시에 대한 깊은 지식과 흐름도를 정확하게 배울 수 있었습니다.
좋은 글 감사합니다.