HTTP Cache
HTTP 캐시 (HTTP Cache)
섹션 제목: “HTTP 캐시 (HTTP Cache)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”HTTP 캐시란, 서버에서 받은 응답을 브라우저나 중간 서버(CDN 등)에 임시 저장해 두고, 동일한 요청이 다시 올 때 서버에 재요청하지 않고 저장된 데이터를 그대로 반환하는 메커니즘이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”캐시는 단순히 “빠르게 하는 기술”이 아니다. 서버 부하를 줄이고, 네트워크 비용을 절감하며, 사용자 경험을 개선하는 핵심 인프라 레이어다.
- 응답 속도: 서버까지 왕복하지 않고 가까운 캐시에서 응답 → 수백 ms 단축
- 서버 부하 감소: 동일 요청을 Origin 서버가 반복 처리하지 않아도 됨
- 네트워크 비용: CDN 캐시 히트 시 데이터 전송 비용 절감 (특히 AWS CloudFront 사용 시 트래픽 비용 차이 크다)
- 장애 내성:
stale-if-error설정 시 Origin 서버 장애 중에도 캐시로 응답 가능
BackOps/플랫폼 엔지니어 입장에서는 캐시 전략을 잘못 설계하면 배포 후 변경 사항이 반영 안 되거나, 개인정보가 공유 캐시에 저장되는 보안 사고로 이어질 수 있다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. 비유: 도서관 대출 시스템
섹션 제목: “3-1. 비유: 도서관 대출 시스템”도서관에 자주 보는 책이 있다고 상상해보자.
- 책상 위 (브라우저 캐시): 내가 직접 꺼내놓은 책. 가장 빠르게 접근 가능. 나만 볼 수 있다.
- 열람실 공용 책장 (CDN 캐시): 이 건물에 오는 사람이라면 누구나 쓸 수 있는 책. 서가까지 안 가도 됨.
- 본관 서가 (Origin 서버): 진짜 원본이 있는 곳. 가장 최신이지만, 가장 멀다.
“책이 최신인지 확인하는 행위”가 바로 **캐시 유효성 검사(Validation)**이고, “책을 새로 가져오는 행위”가 캐시 갱신이다.
3-1b. 프론트엔드 → 플랫폼 브릿지: 빌드 캐시 vs HTTP 캐시 — 헷갈리는 두 종류의 캐시
섹션 제목: “3-1b. 프론트엔드 → 플랫폼 브릿지: 빌드 캐시 vs HTTP 캐시 — 헷갈리는 두 종류의 캐시”프론트엔드 개발자라면 “캐시”라는 단어를 들었을 때 두 가지를 떠올릴 것이다. 빌드 캐시(webpack/Vite/Next.js)와 HTTP 캐시(Cache-Control 헤더). 이 두 가지는 완전히 다른 시스템이다.
비유: 빌드 캐시는 요리사가 미리 반죽을 만들어두는 것(개발 빌드 속도 최적화)이고, HTTP 캐시는 완성된 요리를 손님 앞에 놓아두는 것(서비스 응답 속도 최적화)이다. 서로 다른 문제를 해결한다.
원리: 두 캐시의 비교
| 항목 | 빌드 캐시 (webpack/Vite) | HTTP 캐시 (Cache-Control) |
|---|---|---|
| 목적 | 개발/빌드 시간 단축 | 사용자 응답 속도·서버 부하 감소 |
| 동작 시점 | npm run build 실행 중 | 브라우저/CDN이 HTTP 응답 수신 시 |
| 저장 위치 | .next/cache/, node_modules/.cache/, dist/.cache/ | 브라우저 메모리/디스크, CDN 엣지 서버 |
| 제어 방법 | webpack/Vite 설정, CI cache 디렉토리 설정 | HTTP 응답 헤더 (Cache-Control, ETag) |
| 무효화 | 소스 코드 변경 → 자동 재빌드 | TTL 만료, CDN Invalidation, Cache Busting |
| 누가 쓰나 | 개발자 워크스테이션, CI/CD 서버 | 사용자 브라우저, CloudFront 엣지 |
코드로 보는 차이
# 빌드 캐시: 개발자 빌드 시간 단축# GitHub Actions에서 Next.js 빌드 캐시 저장- name: Cache Next.js build uses: actions/cache@v4 with: path: .next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}# → 소스 변경 없으면 이전 빌드 결과물 재사용 → CI 빌드 시간 50~80% 단축# → 이 캐시는 사용자가 전혀 알 수 없고, 네트워크와 무관함// HTTP 캐시: 사용자 브라우저와 CDN이 사용// Nest.js API 응답에 Cache-Control 설정@Get('products')findAll(@Res({ passthrough: true }) res: Response) { res.setHeader('Cache-Control', 'public, max-age=300'); // → 이 헤더를 받은 브라우저는 5분 동안 /products를 캐시 // → CloudFront도 5분 동안 엣지에서 직접 응답 // → 빌드 캐시와 완전히 별개의 시스템 return this.productsService.findAll();}프론트엔드 빌드 산출물(JS/CSS)과 HTTP 캐시의 관계
실제로 두 캐시가 연결되는 지점이 있다: 빌드 결과물(app.a1b2c3.js)을 브라우저가 요청할 때 HTTP 캐시가 적용된다.
빌드 프로세스 (빌드 캐시가 담당): npm run build → webpack/Vite가 소스 번들링 → 파일명에 해시 삽입: app.js → app.a1b2c3.js → 이전 빌드 캐시 있으면 재사용 → 빠르게 완료
배포 후 사용자 접속 (HTTP 캐시가 담당): 브라우저 → GET /static/app.a1b2c3.js → CloudFront 엣지에 있으면 바로 반환 (Cache Hit) → Cache-Control: max-age=31536000, immutable (1년) → 파일명에 해시가 있으므로 내용이 바뀌면 URL도 바뀜 → 새 URL → 자동으로 새 파일 요청 (HTTP 캐시 무효화 필요 없음)📖 더 보기: HTTP Cache Headers — Complete Guide (KeyCDN) — max-age, ETag, Vary 등 핵심 HTTP 캐시 헤더의 실전 예제 가이드 (입문)
3-2. 캐시 계층 구조
섹션 제목: “3-2. 캐시 계층 구조”사용자 브라우저 ↓ (캐시 미스)브라우저 캐시 (L1) ↓ (캐시 미스)CDN 엣지 서버 (L2) — 예: CloudFront, Cloudflare ↓ (캐시 미스)Reverse Proxy (L3) — 예: Nginx, Varnish ↓ (캐시 미스)Origin 서버 (Nest.js 앱)캐시 히트(Cache Hit): 저장된 응답을 그대로 반환. 빠름. 캐시 미스(Cache Miss): 저장된 응답이 없거나 만료됨. 다음 계층으로 요청.
3-2b. 브라우저 캐시 신선도 판단: 내부 알고리즘
섹션 제목: “3-2b. 브라우저 캐시 신선도 판단: 내부 알고리즘”왜 이것을 알아야 하는가?
섹션 제목: “왜 이것을 알아야 하는가?”Cache-Control을 설정하지 않은 응답이라도 브라우저는 **자체 판단(휴리스틱)**으로 캐싱한다. 의도치 않게 응답이 캐시되는 이유가 바로 이것이다.
원리: 신선도 판단 순서
섹션 제목: “원리: 신선도 판단 순서”브라우저는 다음 순서로 캐시 신선도(freshness)를 계산한다.
1. Cache-Control: max-age=N → freshness_lifetime = N초 (있으면 이것만 사용. Expires, 휴리스틱 모두 무시)
2. Cache-Control 없음 → Expires 헤더 확인 freshness_lifetime = Expires - Date (절대시간 차이)
3. Expires도 없음 → 휴리스틱 캐싱 freshness_lifetime = (Date - Last-Modified) × 0.1 (최근 수정이 10일 전이면 → 1일 캐시)
현재 나이(age) 계산: apparent_age = max(0, response_time - date_value) age = apparent_age + (now - response_time) (프록시를 거치면 Age 헤더가 더해짐)
최종 판단: if age < freshness_lifetime → FRESH (캐시 사용) else → STALE (서버에 재검증 필요)Age 헤더의 의미:
curl -I https://d1234.cloudfront.net/api/products
# 응답 예시:# cache-control: max-age=3600# age: 2400 ← CDN이 3600초 캐시 중 이미 2400초 경과# → 브라우저 입장: 3600 - 2400 = 앞으로 1200초 더 사용 가능왜 Last-Modified 기반 휴리스틱이 위험한가?
# 개발자가 Cache-Control을 설정하지 않은 경우# Last-Modified가 30일 전이라면:freshness_lifetime = 30일 × 0.1 = 3일 자동 캐시!
# → 배포 후 최대 3일간 이전 콘텐츠가 브라우저에 캐시될 수 있음# 해결: 명시적으로 Cache-Control: no-cache 또는 max-age=0 설정실무 팁: API 서버(Nest.js)에서
Cache-Control을 전혀 설정하지 않으면 CloudFront는 TTL=0으로 캐시를 거부하지만, 브라우저는 휴리스틱으로 캐시할 수 있다. API 응답에는 반드시 명시적인Cache-Control을 설정하라.
3-3. Cache-Control 헤더
섹션 제목: “3-3. Cache-Control 헤더”Cache-Control은 HTTP/1.1에서 도입된 핵심 캐시 제어 헤더다. 응답(서버 → 클라이언트)과 요청(클라이언트 → 서버) 양쪽에 모두 사용할 수 있다.
주요 디렉티브
섹션 제목: “주요 디렉티브”| 디렉티브 | 설명 |
|---|---|
max-age=N | N초 동안 캐시를 신선(fresh)하게 간주 |
s-maxage=N | 공유 캐시(CDN 등)에만 적용되는 max-age. 브라우저는 무시 |
no-cache | 캐시에 저장은 하지만, 매 요청마다 서버에 유효성 검사 필요 |
no-store | 캐시에 아예 저장하지 않음. 응답을 매번 Origin에서 받아옴 |
public | 브라우저 + 공유 캐시(CDN) 모두 저장 가능 |
private | 브라우저(개인 캐시)만 저장. CDN 등 공유 캐시는 저장 불가 |
must-revalidate | max-age 만료 후 반드시 서버에 재검증. stale 상태로 제공 불가 |
stale-while-revalidate=N | 만료 후 N초 동안은 stale 응답을 제공하면서 백그라운드에서 갱신 |
stale-if-error=N | Origin 서버 오류 시 N초 동안 stale 응답으로 대체 |
핵심 혼동 포인트: no-cache vs no-store
섹션 제목: “핵심 혼동 포인트: no-cache vs no-store”no-cache는 “캐시 금지”가 아니다. 저장은 하되 매번 서버에 “이거 아직 유효해?” 라고 물어보는 것이다. 실제로 변경이 없으면 304 Not Modified로 본문 없이 빠르게 응답한다.
no-store는 진짜 “저장 자체를 금지”한다. 민감한 금융/개인정보 응답에 사용한다.
curl로 직접 확인
섹션 제목: “curl로 직접 확인”curl -I https://example.com/api/products예상 출력:
HTTP/2 200content-type: application/jsoncache-control: public, max-age=3600, s-maxage=86400etag: "abc123def456"last-modified: Wed, 01 Jan 2025 00:00:00 GMTx-cache: HIT3-3b. immutable 디렉티브: 재검증 자체를 없애기
섹션 제목: “3-3b. immutable 디렉티브: 재검증 자체를 없애기”왜 이것이 필요한가?
섹션 제목: “왜 이것이 필요한가?”max-age=31536000 (1년)을 설정해도, 사용자가 **새로고침(F5)**을 누르면 브라우저는 서버에 조건부 요청(If-None-Match)을 보낸다. 304를 받더라도 왕복 시간(RTT)은 소요된다. 정적 파일에 Cache Busting을 적용했다면 이 재검증은 완전히 불필요하다.
immutable은 “이 리소스는 fresh한 동안 절대 변경되지 않는다”고 브라우저에 알려서, 새로고침 시에도 조건부 요청을 보내지 않게 한다.
# immutable 없이: 새로고침 시GET /app.a1b2c3.jsIf-None-Match: "v1-hash"→ 304 Not Modified (RTT 소요)
# immutable 있으면: 새로고침 시→ 조건부 요청 자체를 하지 않음 (0 RTT)브라우저 지원 현황 (2026년 기준)
섹션 제목: “브라우저 지원 현황 (2026년 기준)”| 브라우저 | 지원 여부 | 비고 |
|---|---|---|
| Firefox | 지원 | HTTPS에서만 동작 |
| Safari | 지원 | |
| Chrome | 지원 | |
| Edge | 지원 |
미지원 브라우저는 immutable을 무시하고 일반 max-age 동작을 따르므로, 추가해도 하위 호환성 문제가 없다.
Nest.js 적용 예시
섹션 제목: “Nest.js 적용 예시”// 정적 파일 서빙 시 immutable 적용@Controller()export class StaticController { @Get("assets/:filename") serveAsset( @Param("filename") filename: string, @Res({ passthrough: true }) res: Response, ) { // 파일명에 해시가 포함된 경우에만 immutable 적용 if (/\.[a-f0-9]{8,}\.(js|css|png|jpg)$/.test(filename)) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } else { res.setHeader("Cache-Control", "no-cache"); } return this.staticService.getFile(filename); }}실무 효과: BBC는 immutable 적용 후 새로고침 시 로드 시간이 최대 50% 개선되었고, 불필요한 재검증 요청의 90%가 제거되었다고 보고했다.
📖 더 보기: Improving Performance with Cache-Control: immutable — KeyCDN — immutable 디렉티브의 성능 효과와 적용 사례 (중급)
3-3c. Cache-Control 전략 결정 플로우차트
섹션 제목: “3-3c. Cache-Control 전략 결정 플로우차트”실무에서 어떤 Cache-Control 조합을 써야 할지 판단하는 흐름이다.
응답에 민감한 데이터가 있는가? ├── YES → no-store (끝) └── NO │ 응답이 사용자별로 다른가? ├── YES → private, max-age=N │ (CDN 캐시 안 됨, 브라우저만) └── NO │ URL에 해시/버전이 포함되어 있는가? ├── YES → public, max-age=31536000, immutable │ (Cache Busting 패턴) └── NO │ stale 응답을 허용할 수 있는가? ├── YES → public, max-age=N, stale-while-revalidate=M └── NO → public, no-cache (매번 검증)이 플로우차트를 팀 내 캐시 전략 가이드로 활용하면, API 개발자가 Cache-Control 헤더를 일관되게 설정할 수 있다.
3-4. Expires 헤더 (레거시)
섹션 제목: “3-4. Expires 헤더 (레거시)”HTTP/1.0 시절부터 사용하던 구형 캐시 만료 헤더다.
Expires: Thu, 01 Jan 2026 00:00:00 GMT왜 레거시인가?
- 절대 시간을 사용하므로 서버와 클라이언트의 시계가 다르면 오작동한다.
Cache-Control이 있으면Expires는 무시된다 (Cache-Control 우선).- 신규 개발 시
Cache-Control: max-age=N사용을 권장한다.
3-5. ETag와 Conditional Request (304 흐름)
섹션 제목: “3-5. ETag와 Conditional Request (304 흐름)”**ETag(Entity Tag)**는 리소스의 버전을 식별하는 해시값이다. 서버가 응답 시 함께 보내준다.
왜 이 방식을 쓰는가?
max-age가 만료되면 클라이언트는 리소스가 실제로 변경되었는지 알 수 없다. 서버에 다시 물어봐야 하는데, 이때 전체 파일을 다시 받으면 낭비다. ETag로 “버전이 같으면 본문 없이 304만 보내줘”를 구현할 수 있다.
304 Not Modified 흐름 단계별 설명:
1. 클라이언트 → 서버: GET /image.png (최초 요청, 캐시 없음)
2. 서버 → 클라이언트: 200 OK ETag: "v2-a1b2c3" Cache-Control: max-age=3600 (본문 포함, 3.2MB 이미지)
→ 브라우저가 ETag 값을 저장하고, 3600초 동안 캐시 사용
3. 3600초 후, 클라이언트 → 서버: GET /image.png If-None-Match: "v2-a1b2c3" (캐시 만료, 서버에 유효성 검사 요청)
4a. 리소스 변경 없음 → 서버 → 클라이언트: 304 Not Modified (본문 없음, 헤더만) → 브라우저는 기존 캐시를 그대로 사용. 본문 전송 없음!
4b. 리소스 변경됨 → 서버 → 클라이언트: 200 OK ETag: "v3-d4e5f6" (새 본문 포함)curl로 확인:
# 1. 최초 요청으로 ETag 확인curl -I https://example.com/image.png
# 출력 예시:# HTTP/2 200# etag: "v2-a1b2c3"# cache-control: max-age=3600
# 2. Conditional Request (ETag 포함)curl -I -H 'If-None-Match: "v2-a1b2c3"' https://example.com/image.png
# 변경 없으면 예상 출력:# HTTP/2 304# etag: "v2-a1b2c3"# (본문 없음 → 네트워크 절약!)3-6. Last-Modified + If-Modified-Since (시간 기반 검증)
섹션 제목: “3-6. Last-Modified + If-Modified-Since (시간 기반 검증)”ETag와 유사하지만 콘텐츠 해시가 아닌 수정 시간을 기준으로 한다.
서버 응답: Last-Modified: Wed, 01 Jan 2025 12:00:00 GMT다음 요청: If-Modified-Since: Wed, 01 Jan 2025 12:00:00 GMTETag vs Last-Modified 비교:
| 항목 | ETag | Last-Modified |
|---|---|---|
| 기준 | 콘텐츠 해시 | 수정 시간 |
| 정밀도 | 내용이 1바이트라도 바뀌면 다른 값 | 1초 단위 (같은 초에 두 번 수정 시 감지 불가) |
| 서버 부하 | 해시 계산 필요 | 단순 시간 비교 |
| 우선순위 | ETag가 있으면 ETag 우선 사용 | ETag 없을 때 폴백 |
3-6b. Vary 헤더: 캐시 키의 확장
섹션 제목: “3-6b. Vary 헤더: 캐시 키의 확장”도서관 책장에 같은 제목의 책이 “한국어판”과 “영어판”으로 따로 꽂혀있다고 상상해보자. 요청하는 사람의 언어에 따라 다른 책을 꺼내줘야 한다. Vary 헤더는 캐시에게 “이 헤더 값에 따라 응답이 달라진다”고 알려주는 역할을 한다.
원리: Vary 헤더와 캐시 키
섹션 제목: “원리: Vary 헤더와 캐시 키”기본 캐시 키는 URL이다. Vary 헤더를 추가하면 캐시 키에 특정 요청 헤더 값이 포함된다.
# Vary 없는 경우: URL만 캐시 키GET /api/products → 캐시 키: "/api/products"
# Vary: Accept-Encoding인 경우: URL + 인코딩 방식GET /api/productsAccept-Encoding: gzip → 캐시 키: "/api/products + gzip"Accept-Encoding: br → 캐시 키: "/api/products + br" (별도 캐시)Nest.js에서 Vary 설정 예시:
@Controller("products")export class ProductsController { @Get() findAll(@Req() req: Request, @Res({ passthrough: true }) res: Response) { // Accept-Language에 따라 다른 언어로 응답 → Vary 필수 const lang = req.headers["accept-language"] || "ko";
res.setHeader("Cache-Control", "public, max-age=300"); res.setHeader("Vary", "Accept-Language, Accept-Encoding");
return this.productsService.findAll(lang); }}예상 출력 (curl):
curl -I https://api.example.com/products -H "Accept-Language: ko"# HTTP/2 200# cache-control: public, max-age=300# vary: Accept-Language, Accept-Encoding# x-cache: Miss from cloudfront ← ko 버전 최초 요청
curl -I https://api.example.com/products -H "Accept-Language: en"# x-cache: Miss from cloudfront ← en 버전 별도 캐시 (새로 생성)
curl -I https://api.example.com/products -H "Accept-Language: ko"# x-cache: Hit from cloudfront ← ko 버전 캐시 히트중요한 주의사항: CDN에서의 Vary 처리 차이
| CDN/프록시 | Vary 처리 |
|---|---|
| CloudFront | 기본적으로 Vary 헤더를 캐시 키에 반영함 (Cache Policy 설정 필요) |
| Cloudflare | Vary를 완전히 무시하는 경우가 있음. Cache Key Rules로 별도 설정 필요 |
| Nginx | Vary를 존중하여 캐시 키에 반영 |
| 브라우저 | 모두 Vary 헤더를 정확히 준수 |
실무 주의: CloudFront에서
Vary: Accept-Encoding이 올바르게 작동하려면 Cache Policy에서Accept-Encoding헤더를 캐시 키에 포함시켜야 한다. 설정 누락 시 gzip/br/raw 버전이 뒤섞여 잘못된 인코딩 응답이 반환될 수 있다.📖 더 보기: A complete guide to HTTP caching — Vary 헤더의 동작 원리와 CDN별 처리 방식 비교 (중급)
경계 조건: Vary 다중 필드 조합의 캐시 공간 폭발
섹션 제목: “경계 조건: Vary 다중 필드 조합의 캐시 공간 폭발”Vary에 여러 헤더를 나열하면 조합 수만큼 별도 캐시 엔트리가 생성된다. 이것을 combinatorial explosion이라 한다.
Vary: Accept-Encoding, Accept-Language 설정 시:
캐시 엔트리 = (인코딩 종류) × (언어 종류) = {gzip, br, identity} × {ko, en, ja, zh, fr, de, ...} = 3 × N개 언어 = N × 3 캐시 슬롯
# 언어를 10개 지원한다면: 최소 30개의 별도 캐시 엔트리# 각 언어·인코딩 조합이 처음 요청될 때마다 Origin 히트 발생실무 권고 (Fastly, MDN 기준):
| 헤더 | 권고 여부 | 이유 |
|---|---|---|
Accept-Encoding | 허용 | gzip/br/identity 3종, 범위가 명확하고 정규화 가능 |
Accept-Language | 별도 URL 처리 권장 | 언어 변형이 많고 캐시 히트율 급락. /ko/products, /en/products처럼 URL에 언어를 포함 |
User-Agent | 금지에 가까움 | 수천 개의 브라우저/버전 조합 → 캐시 히트율 사실상 0%에 수렴 |
Accept | 주의해서 사용 | JSON/HTML처럼 2종 이내면 허용, 그 이상이면 별도 엔드포인트 분리 |
// 잘못된 예: Accept-Language를 Vary에 추가res.setHeader("Vary", "Accept-Encoding, Accept-Language");// → ko/en/ja 등 언어별로 캐시가 분리되어 CDN 히트율 급락
// 권장 예: 언어를 URL에 포함// GET /ko/api/products, GET /en/api/products// → 캐시 키가 URL로 자연스럽게 분리됨, Vary는 Accept-Encoding만 유지res.setHeader("Vary", "Accept-Encoding");3-7. stale-while-revalidate: 배경 갱신 전략
섹션 제목: “3-7. stale-while-revalidate: 배경 갱신 전략”CloudFront가 2023년 5월에 공식 지원을 발표한 중요한 디렉티브다.
동작 원리:
Cache-Control: max-age=3600, stale-while-revalidate=600max-age=3600: 1시간 동안 신선한 캐시로 간주stale-while-revalidate=600: max-age 만료 후 10분 동안은 stale 응답을 즉시 제공하면서, 백그라운드에서 Origin에 갱신 요청
왜 유용한가?
max-age만 사용하면 만료 직후 첫 요청자가 Origin 응답을 기다려야 한다(캐시 콜드 스타트). stale-while-revalidate를 추가하면 만료 직후에도 사용자는 즉시 응답을 받고, 갱신은 백그라운드로 처리된다.
# CloudFront 응답 헤더 예시curl -I https://d1234abcd.cloudfront.net/api/products
# 예상 출력:# HTTP/2 200# cache-control: max-age=3600, stale-while-revalidate=600# x-cache: Hit from cloudfront# age: 3650 ← max-age(3600) 초과, stale 응답 중3-7b. CloudFront Cache Policy vs Origin Request Policy: 분리의 이유
섹션 제목: “3-7b. CloudFront Cache Policy vs Origin Request Policy: 분리의 이유”왜 분리했는가?
섹션 제목: “왜 분리했는가?”CloudFront의 레거시 설정에서는 “Origin에 전달할 헤더 = 캐시 키에 포함되는 헤더”가 동일했다. 이 때문에 Origin에 Authorization 헤더를 전달하면 자동으로 캐시 키에도 포함되어 사용자별로 별도 캐시가 생겼다. 사실상 캐시가 무용지물이 되는 것이다.
Cache Policy와 Origin Request Policy를 분리하면 이 문제를 해결할 수 있다.
Cache Policy: "캐시 키에 무엇을 포함할 것인가?" → URL + Accept-Encoding만 포함 (캐시 히트율 극대화)
Origin Request Policy: "Origin에 무엇을 전달할 것인가?" → Authorization, X-Custom-Header 등 추가 전달 (Origin 로직에 필요)실무 설정 예시
섹션 제목: “실무 설정 예시”# 공개 API: 캐시 히트율을 최대화하면서 Origin에는 필요한 정보 전달Cache Policy: - Cache key: URL + Accept-Encoding - TTL: min=1, max=86400, default=3600
Origin Request Policy: - Headers: Authorization, X-Request-Id (캐시 키에는 미포함) - Cookies: 전달 안 함 - Query Strings: 전체 전달# AWS CLI로 Cache Policy 확인aws cloudfront list-cache-policies --type managed
# 예상 출력 (주요 관리형 정책):# CachingOptimized: 권장 기본값. Accept-Encoding 자동 처리# CachingDisabled: TTL=0, 캐시 안 함# CachingOptimizedForUncompressedObjects: 압축 미사용 시📖 더 보기: Understand how origin request policies and cache policies work together — AWS 공식 문서 — Cache Policy와 Origin Request Policy의 상호작용 원리 (중급)
3-7c. 캐시 포이즈닝(Cache Poisoning): 공유 캐시의 보안 위협
섹션 제목: “3-7c. 캐시 포이즈닝(Cache Poisoning): 공유 캐시의 보안 위협”비유: 공유 냉장고의 라벨 바꿔치기
섹션 제목: “비유: 공유 냉장고의 라벨 바꿔치기”공유 냉장고에 누군가 라벨을 바꿔서 독이 든 음식을 넣어두는 것과 같다. CDN 캐시에 악의적인 응답을 저장시키면, 이후 같은 URL을 요청하는 모든 사용자에게 오염된 응답이 전달된다.
원리: Unkeyed Input 악용
섹션 제목: “원리: Unkeyed Input 악용”캐시 포이즈닝의 핵심은 캐시 키에는 포함되지 않지만(unkeyed), 응답에는 영향을 주는 입력값을 찾는 것이다.
# 정상 요청GET /page HTTP/1.1Host: example.com→ 캐시 키: "GET /page example.com"→ 응답: <script src="https://example.com/app.js">
# 공격자 요청 (X-Forwarded-Host는 캐시 키에 미포함)GET /page HTTP/1.1Host: example.comX-Forwarded-Host: evil.com→ 캐시 키: "GET /page example.com" (동일!)→ 응답: <script src="https://evil.com/app.js"> (오염!)→ 이 응답이 CDN에 캐시됨 → 모든 사용자에게 evil.com 스크립트 전달방어 전략
섹션 제목: “방어 전략”// 1. 불필요한 헤더 무시 (Nest.js에서 X-Forwarded-Host 사용 금지)@Controller()export class AppController { @Get("page") getPage(@Req() req: Request) { // X-Forwarded-Host를 절대 URL 생성에 사용하지 말 것! const baseUrl = process.env.BASE_URL; // 환경변수로 고정 return { scriptUrl: `${baseUrl}/app.js` }; }}# 2. CloudFront에서 불필요한 헤더를 Origin에 전달하지 않기# Origin Request Policy에서 X-Forwarded-Host 등 제외
# 3. 캐시 포이즈닝 취약점 테스트 (Param Miner 사용)# Burp Suite → Extensions → Param Miner → "Guess headers"# 또는 curl로 수동 테스트:curl -I https://example.com/page -H "X-Forwarded-Host: evil.com"# 응답에 evil.com이 반영되면 취약!최근 사례: 2025년 CVE-2025-4366에서 Cloudflare의 Pingora 프록시에서 캐시 포이즈닝 취약점이 발견되어 수백만 사용자에게 영향을 미쳤다. CDN 설정 감사를 정기적으로 수행해야 하는 이유다.
📖 더 보기: Practical Web Cache Poisoning — PortSwigger Research — 실제 사이트에서 발견된 캐시 포이즈닝 사례와 공격 기법 (중급~고급)
3-7d. stale-while-revalidate CDN별 지원 현황과 실전 주의사항 (2025)
섹션 제목: “3-7d. stale-while-revalidate CDN별 지원 현황과 실전 주의사항 (2025)”비유: 배달 앱의 “재고 있음” 표시
섹션 제목: “비유: 배달 앱의 “재고 있음” 표시”음식 재고가 없을 수도 있는 상황에서 “재고 있음(stale)“으로 일단 표시해두고, 동시에 진짜 재고를 확인(revalidate)하는 방식이다. 손님은 즉시 응답을 받고, 매장은 뒤에서 실제 재고를 동기화한다.
원리: CDN별 실제 동작 차이
섹션 제목: “원리: CDN별 실제 동작 차이”stale-while-revalidate는 명세(RFC 5861)가 동일하지만, CDN마다 동작이 다르다.
Cache-Control: max-age=60, stale-while-revalidate=300
→ 0~60초: FRESH — 캐시 그대로 사용→ 61~360초: STALE — stale 즉시 제공 + 백그라운드 Origin 요청(비동기)→ 361초 이후: STALE (만료) — Origin에서 직접 새로 가져옴 (동기)| CDN/서버 | stale-while-revalidate 지원 | 주의사항 |
|---|---|---|
| CloudFront | 2023년 5월 공식 지원 시작 | Cache Policy TTL과 s-maxage가 우선시될 수 있음. 명시적 설정 권장 |
| Cloudflare | 완전 지원 | Edge TTL 설정이 Origin 헤더보다 우선됨 |
| Azure CDN | 부분 지원 | stale-while-revalidate 자체는 무시, 별도 규칙 필요 |
| Google Cloud CDN | 지원 | 조건부 GET(ETag/Last-Modified)으로 Origin 부하 절감 |
| Nginx (Proxy) | proxy_cache_use_stale로 유사 구현 | 동일 개념이지만 헤더 기반이 아님 |
CloudFront에서 stale-while-revalidate가 작동하지 않는 흔한 원인
섹션 제목: “CloudFront에서 stale-while-revalidate가 작동하지 않는 흔한 원인”# 확인: Age 헤더 보기curl -I https://d1234abcd.cloudfront.net/api/products
# 예상 출력 (stale 구간에서):# cache-control: max-age=60, stale-while-revalidate=300# x-cache: Hit from cloudfront# age: 180 ← max-age(60) 초과 → stale 상태. 정상이면 즉시 응답 후 백그라운드 갱신 중
# 만약 age: 180 인데도 x-cache: Miss 라면 → Cache Policy TTL 설정 확인# CloudFront Console → Cache Policy → Default/Min TTL이 0이면 항상 재검증Nest.js에서 올바른 설정:
@Get('products')findAll(@Res({ passthrough: true }) res: Response) { // max-age + stale-while-revalidate 조합 // CloudFront가 이 헤더를 그대로 따르려면 // Cache Policy에서 "Use origin cache control headers" 선택 필수 res.setHeader( 'Cache-Control', 'public, max-age=60, stale-while-revalidate=300, stale-if-error=86400' ); return this.productsService.findAll();}실무 팁:
stale-if-error=86400을 함께 설정하면 Origin 서버가 장애일 때 최대 24시간 동안 stale 응답으로 서비스를 유지할 수 있다. 새벽 배포 중 Origin이 잠깐 다운되어도 사용자에게는 이전 캐시가 제공된다.📖 더 보기: Understanding Stale-While-Revalidate — DebugBear — SWR의 세 가지 상태(Fresh/Stale/Rotten)와 플랫폼별(Apache, Nginx, CDN) 적용 방법을 예제 중심으로 설명. (입문)
3-8. 캐시 무효화 전략
섹션 제목: “3-8. 캐시 무효화 전략”캐시는 일단 저장되면 쉽게 지울 수 없다. 그래서 “어떻게 무효화할 것인가”가 핵심 설계 문제다.
Cache Busting (파일명 버전 포함)
섹션 제목: “Cache Busting (파일명 버전 포함)”배포 시마다 파일명에 해시를 붙여서 새 URL로 만드는 방법. CDN이나 브라우저 캐시를 지울 필요 없이, 새 URL이니까 자동으로 최신 파일을 가져온다.
# 빌드 전 (캐시되어 있음)/static/app.js
# 빌드 후 (새 URL → 자동으로 최신 버전 사용)/static/app.a1b2c3.js정적 파일은 이 방법이 가장 효율적이다. Cache-Control: max-age=31536000, immutable (1년)을 설정해도 URL이 바뀌면 항상 새 파일을 가져온다.
CloudFront Invalidation (CDN 캐시 강제 삭제)
섹션 제목: “CloudFront Invalidation (CDN 캐시 강제 삭제)”파일명을 바꾸기 어려운 경우 (예: API 응답, 동적 페이지), CDN 캐시를 직접 삭제한다.
# AWS CLI로 CloudFront Invalidation 생성aws cloudfront create-invalidation \ --distribution-id E1234ABCD5678 \ --paths "/api/products" "/api/categories/*"
# 예상 출력:# {# "Invalidation": {# "Id": "I1234ABCDEFG",# "Status": "InProgress",# "CreateTime": "2025-01-01T00:00:00Z"# }# }주의: Invalidation은 즉시 처리되지 않는다. 완전 적용까지 수 분이 걸린다. 또한 매월 1,000개 경로까지 무료이며 초과 시 경로당 $0.005 과금된다.
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”API 응답 vs 정적 파일: 전략 분리
섹션 제목: “API 응답 vs 정적 파일: 전략 분리”| 구분 | 캐시 전략 | 이유 |
|---|---|---|
| 정적 파일 (JS/CSS/이미지) | max-age=31536000, immutable + Cache Busting | 내용이 바뀌면 URL도 바뀌므로 장기 캐시 안전 |
| HTML 진입점 | no-cache 또는 max-age=0 | 항상 최신 JS/CSS URL을 참조해야 함 |
| API 응답 (자주 변경) | no-store 또는 private, max-age=60 | 사용자별 데이터, 또는 짧은 주기로 갱신 |
| API 응답 (공통/변경 드문) | public, max-age=300, stale-while-revalidate=60 | CDN 캐시 활용, stale 허용 |
CloudFront 캐시 동작 이해
섹션 제목: “CloudFront 캐시 동작 이해”CloudFront는 두 가지 요청 유형이 있다.
- Viewer Request: 사용자 → CloudFront 엣지. 캐시가 있으면 Origin까지 가지 않는다.
- Origin Request: CloudFront 엣지 → Origin 서버. 캐시 미스 시에만 발생.
캐시 동작은 Cache Policy로 제어한다. Origin의 Cache-Control 헤더를 그대로 따르도록 설정하는 것이 일반적이다.
# CloudFront 응답 헤더 해석x-cache: Hit from cloudfront ← 캐시 히트x-cache: Miss from cloudfront ← 캐시 미스 (Origin까지 요청)age: 1234 ← 캐시가 저장된 지 1234초 경과Nest.js에서 Cache-Control 설정
섹션 제목: “Nest.js에서 Cache-Control 설정”// Controller에서 직접 헤더 설정import { Controller, Get, Res } from "@nestjs/common";import { Response } from "express";
@Controller("products")export class ProductsController { // 공개 API: CDN 캐시 허용, 5분 + stale 1분 @Get() findAll(@Res({ passthrough: true }) res: Response) { res.setHeader( "Cache-Control", "public, max-age=300, stale-while-revalidate=60", ); return this.productsService.findAll(); }
// 개인화 API: 브라우저 캐시만, 1분 @Get("my-cart") getMyCart(@Res({ passthrough: true }) res: Response) { res.setHeader("Cache-Control", "private, max-age=60"); return this.cartService.getCart(); }
// 민감 데이터: 캐시 완전 금지 @Get("payment-info") getPaymentInfo(@Res({ passthrough: true }) res: Response) { res.setHeader("Cache-Control", "no-store"); return this.paymentService.getInfo(); }}// 인터셉터로 공통 적용import { Injectable, NestInterceptor, ExecutionContext, CallHandler,} from "@nestjs/common";import { Observable } from "rxjs";import { tap } from "rxjs/operators";
@Injectable()export class CacheControlInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const response = context.switchToHttp().getResponse();
return next.handle().pipe( tap(() => { // 기본값: 공개 캐시 5분 if (!response.getHeader("Cache-Control")) { response.setHeader("Cache-Control", "public, max-age=300"); } }), ); }}5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”- 배포 파이프라인: 프론트엔드 빌드 후 S3 → CloudFront 배포 시, 정적 파일에 Cache Busting 해시가 없으면 CloudFront Invalidation을 수동으로 실행해야 한다. CI/CD에 자동화하는 게 핵심.
- API 성능: Nest.js 공통 데이터 API (카테고리 목록, 설정값 등)에
public, max-age=300을 적용하면 CloudFront가 캐시하여 Origin 요청이 크게 줄어든다. - 보안: 사용자 인증 토큰이나 개인정보 응답에 반드시
private또는no-store를 설정해야 한다. CDN 공유 캐시에 저장되면 타인이 접근할 수 있다. - 디버깅:
x-cache,age,cf-cache-status헤더를 보면 어느 레이어에서 캐시 히트가 발생했는지 추적할 수 있다.
6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”HTTP 캐시 vs Redis 캐시
섹션 제목: “HTTP 캐시 vs Redis 캐시”| 항목 | HTTP 캐시 | Redis 캐시 |
|---|---|---|
| 위치 | 브라우저/CDN (클라이언트/네트워크) | 서버 사이드 (인메모리 DB) |
| 제어 주체 | 헤더로 브라우저/CDN에 지시 | 서버 코드에서 직접 관리 |
| 대상 | HTTP 응답 전체 | 임의의 데이터 (DB 쿼리 결과 등) |
| 무효화 | 만료(TTL) 또는 CDN Invalidation | 키 삭제/갱신으로 즉시 제어 |
| 사용처 | 정적 파일, 공개 API 응답 | DB 부하 감소, 세션 저장 |
→ 두 가지는 경쟁 관계가 아니라 보완 관계다. Redis로 DB 부하를 줄이고, HTTP 캐시로 네트워크 비용을 줄인다.
6-1. 캐시 원리의 전이 가능성: 같은 패턴이 모든 계층에 적용된다
섹션 제목: “6-1. 캐시 원리의 전이 가능성: 같은 패턴이 모든 계층에 적용된다”HTTP 캐시에서 배운 세 가지 핵심 원리(TTL, Invalidation, Conditional Check)는 Redis, CDN, DB 쿼리 캐시에도 동일하게 적용된다. 계층이 달라도 문제의 구조는 같다.
| 원리 | HTTP 캐시 | Redis 캐시 | CDN | DB 쿼리 캐시 |
|---|---|---|---|---|
| TTL (만료 시간) | Cache-Control: max-age=300 | SET key value EX 300 / EXPIRE | Cache Policy TTL, s-maxage | MySQL query_cache_limit / 앱 레벨 TTL |
| Invalidation (무효화) | CDN Invalidation API, Cache Busting URL | DEL key, UNLINK key (비동기) | Purge API (CloudFront/Cloudflare) | FLUSH QUERY CACHE / ORM 캐시 clear |
| Conditional Check (재검증) | ETag + If-None-Match → 304 Not Modified | EXISTS key / TTL key로 잔여시간 확인 | stale-while-revalidate 배경 갱신 | 버전 컬럼 비교 후 캐시 갱신 |
실무 시사점: HTTP 캐시에서 “만료 후 재검증”을 이해했다면, Redis에서 GET 전에 TTL을 확인해 캐시 웜업을 미리 트리거하는 패턴, CDN의 stale-while-revalidate 설정, DB 캐시의 write-through/write-behind 전략 모두 같은 사고방식이다. 어느 계층에서 문제가 생기든 “TTL을 어떻게 설정하고, 무효화 시점을 언제로 잡고, 만료 전 재검증을 어떻게 할 것인가” 세 가지를 물으면 된다.
no-cache vs must-revalidate
섹션 제목: “no-cache vs must-revalidate”no-cache: 캐시에 저장하지만 사용 전 항상 서버에 확인 (304 가능)must-revalidate: max-age 만료 후에는 반드시 서버에 확인. 만료 전에는 캐시 사용. 서버가 오류 시 504 반환 (stale 제공 금지)
6.5. 실패 모드: 캐시 스탬피드 (Cache Stampede / Thundering Herd)
섹션 제목: “6.5. 실패 모드: 캐시 스탬피드 (Cache Stampede / Thundering Herd)”무엇이 문제인가
섹션 제목: “무엇이 문제인가”max-age가 만료되는 순간, 동시에 수백 개의 요청이 캐시 미스를 경험하면 전부 Origin 서버(또는 DB)로 쏟아진다. 이것이 캐시 스탬피드다. 트래픽이 많을수록, 그리고 인기 있는 키일수록 피해가 크다.
시나리오: max-age=3600, 인기 API /api/products
00:00 ~ 01:00: CDN이 캐시. Origin 요청 0건/초01:00:01: 캐시 만료 → 동시 요청 500건이 모두 CDN Miss → 500건 동시 Origin 히트 → DB 커넥션 풀 고갈 → 장애해결 패턴 3가지
섹션 제목: “해결 패턴 3가지”1. stale-while-revalidate (RFC 5861, HTTP 캐시 레벨 해결)
Cache-Control: max-age=3600, stale-while-revalidate=300만료 후 300초 동안은 stale 응답을 즉시 제공하면서, 단 하나의 백그라운드 요청만 Origin으로 보낸다. 이미 3-7절에서 다뤘지만, 스탬피드 방지 관점에서 핵심 역할을 한다.
2. Probabilistic Early Expiration — XFetch 알고리즘 (Redis/앱 레벨 해결)
만료 직전에 확률적으로 일부 요청이 미리 캐시를 갱신하도록 한다. Vattani et al.의 논문에서 제안되어 RedisConf 2017에서 발표되었다.
// XFetch 알고리즘 (pseudo-code)function xfetch(key: string, ttl: number, beta = 1.0) { const { value, delta, expiry } = cacheRead(key); // delta = 이전 갱신 소요 시간 if ( !value || Date.now() / 1000 - delta * beta * Math.log(Math.random()) >= expiry ) { // 만료 전이지만 확률적으로 선제 갱신 → 한 요청만 갱신, 나머지는 기존 캐시 사용 const newValue = fetchFromOrigin(); cacheWrite(key, newValue, ttl); return newValue; } return value;}// beta=1이 기본값. 높을수록 더 일찍(공격적으로) 갱신.이 방식은 전체 요청 중 극소수만 선제적으로 갱신하기 때문에 Origin 부하 급증 없이 스탬피드를 방지한다.
3. Mutex Lock (분산 락, 앱 레벨 해결)
Redis의 SET key value NX EX seconds (원자적 조건부 세팅)로 락을 걸어, 캐시 미스 시 단 하나의 요청만 Origin을 조회하고 나머지는 대기하거나 stale 값을 사용한다.
// Redis mutex로 스탬피드 방지 (Node.js pseudo-code)async function getWithLock(key: string): Promise<Data> { const cached = await redis.get(key); if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`; const acquired = await redis.set(lockKey, "1", "NX", "EX", 5); // 5초 락 if (!acquired) { // 락 획득 실패 → 100ms 후 재시도 (stale 값이 있으면 즉시 반환) await sleep(100); return getWithLock(key); } try { const data = await fetchFromDB(); await redis.setex(key, 3600, JSON.stringify(data)); return data; } finally { await redis.del(lockKey); }}패턴 선택 기준
섹션 제목: “패턴 선택 기준”| 상황 | 권장 패턴 |
|---|---|
| CDN/브라우저 HTTP 캐시 만료 | stale-while-revalidate |
| Redis 앱 캐시, 만료 예측 가능 | XFetch (probabilistic early expiration) |
| Redis 앱 캐시, 갱신 비용이 매우 클 때 | Mutex lock |
| 여러 계층 동시 만료 우려 | TTL Jitter — TTL에 ±10% 랜덤 편차 추가 |
📖 참고: Cache stampede — Wikipedia, XFetch: internetarchive/xfetch (GitHub)
6.6. 트러블슈팅
섹션 제목: “6.6. 트러블슈팅”문제 1: 배포 후 변경 사항이 반영되지 않음
섹션 제목: “문제 1: 배포 후 변경 사항이 반영되지 않음”증상:
# 배포 완료 후에도 이전 버전의 JS 파일이 실행됨# 브라우저 콘솔에서 이전 코드 동작 확인# 강력 새로고침(Ctrl+Shift+R)으로만 최신 버전 표시원인:
정적 파일에 Cache-Control: max-age=86400 (24시간)을 설정했는데, Cache Busting 없이 파일명이 동일한 경우. 브라우저와 CDN 모두 이전 파일을 캐시하고 있다.
해결 방법:
# 1. 즉각 조치: CloudFront Invalidationaws cloudfront create-invalidation \ --distribution-id E1234ABCD5678 \ --paths "/*"
# 2. 근본 해결: 빌드 시 파일명에 해시 추가 (webpack/vite 기본 제공)# vite.config.tsbuild: { rollupOptions: { output: { entryFileNames: 'assets/[name].[hash].js', chunkFileNames: 'assets/[name].[hash].js', assetFileNames: 'assets/[name].[hash][extname]', } }}
# 3. HTML 파일은 no-cache 설정 (JS/CSS URL이 바뀌었을 때 반영)# Nest.js or NginxCache-Control: no-cache문제 2: CloudFront Invalidation 후에도 계속 이전 콘텐츠 반환
섹션 제목: “문제 2: CloudFront Invalidation 후에도 계속 이전 콘텐츠 반환”증상:
aws cloudfront create-invalidation --distribution-id E1234 --paths "/api/products"# Status: Completed 표시 후에도 이전 응답이 옴
curl -I https://d1234.cloudfront.net/api/products# x-cache: Hit from cloudfront ← 여전히 캐시 히트# age: 7200원인 3가지:
- Invalidation은 CloudFront 엣지 전체에 전파되는 데 수 분이 필요하다. Completed 상태여도 일부 엣지에서는 아직 이전 캐시를 제공할 수 있다.
- 경로에 와일드카드 없이 정확한 경로만 지정했는데, 쿼리스트링이 다른 URL이 별도로 캐시된 경우.
- Origin 응답 헤더가 CloudFront Cache Policy와 충돌하여 의도치 않게 캐시되는 경우.
해결 방법:
# 1. 수 분 대기 후 재확인 (전파 시간)
# 2. 와일드카드로 하위 경로 전체 삭제aws cloudfront create-invalidation \ --distribution-id E1234 \ --paths "/api/products*"
# 3. CloudFront 캐시 동작 점검# - Cache Policy에서 "Use origin cache control headers" 확인# - Origin이 올바른 Cache-Control을 반환하는지 확인curl -I https://origin.example.com/api/products# cache-control: no-cache ← Origin이 캐시 금지를 지시해야 함문제 3: 로그인 사용자 개인 데이터가 다른 사용자에게 노출
섹션 제목: “문제 3: 로그인 사용자 개인 데이터가 다른 사용자에게 노출”증상:
# 사용자 A로 로그인 후 /api/my-profile 요청# 사용자 B가 같은 URL에 접근하면 A의 데이터가 반환됨원인:
개인정보 API 응답에 public 또는 Cache-Control 헤더 미설정으로 CDN이 응답을 공유 캐시로 저장했다.
해결 방법:
// 인증 필요 API: private 또는 no-store 필수@Get('my-profile')@UseGuards(JwtAuthGuard)getMyProfile(@Res({ passthrough: true }) res: Response) { // 절대 public 설정 금지! res.setHeader('Cache-Control', 'private, no-store'); return this.userService.getProfile();}# 검증: 응답 헤더 확인curl -I -H "Authorization: Bearer <token>" https://api.example.com/my-profile# cache-control: private, no-store ← 반드시 확인문제 4: ETag 검증이 작동하지 않아 매번 전체 응답 수신
섹션 제목: “문제 4: ETag 검증이 작동하지 않아 매번 전체 응답 수신”증상:
# 캐시 만료 후 매번 200 OK와 전체 본문이 내려옴# 304 Not Modified가 한 번도 발생하지 않음원인:
서버가 ETag 헤더를 응답하지 않거나, Last-Modified도 없어서 클라이언트가 조건부 요청을 보낼 수 없는 상황. 또는 서버가 If-None-Match 헤더를 처리하지 않는 경우.
해결 방법:
// Nest.js에서 ETag 직접 설정import { createHash } from 'crypto';
@Get('products')async findAll(@Req() req: Request, @Res({ passthrough: true }) res: Response) { const data = await this.productsService.findAll(); const etag = '"' + createHash('md5').update(JSON.stringify(data)).digest('hex') + '"';
res.setHeader('ETag', etag); res.setHeader('Cache-Control', 'public, max-age=300');
// 조건부 요청 처리 if (req.headers['if-none-match'] === etag) { res.status(304).send(); return; }
return data;}문제 5: CloudFront에서 항상 “Miss from cloudfront” — 캐시가 저장 안 됨
섹션 제목: “문제 5: CloudFront에서 항상 “Miss from cloudfront” — 캐시가 저장 안 됨”증상:
curl -I https://d1234.cloudfront.net/api/products# x-cache: Miss from cloudfront ← 매번 미스# x-cache: Miss from cloudfront ← 다시 요청해도 미스원인 (가장 흔한 3가지):
- Origin 응답에
Cache-Control: no-store또는no-cache가 설정되어 있어 CloudFront가 캐시를 거부 - CloudFront Cache Policy의 캐시 키에 쿠키(Cookie)가 포함되어 있고, 요청마다 쿠키 값이 달라서 캐시 키가 매번 달라짐
Authorization헤더가 요청에 포함되어 있는데 캐시 키에 포함 → 사용자마다 다른 키
해결 방법:
# 1. Origin 응답 헤더 직접 확인 (CloudFront 거치지 않고)curl -I https://api.example.com/products# cache-control: no-cache ← Origin이 캐시 금지 중이면 CloudFront도 불가
# 2. CloudFront Cache Policy 확인 (AWS 콘솔)# CloudFront → Policies → Cache → 해당 Policy 선택# "Cache key settings" → Cookies / Headers / Query Strings 확인# 불필요한 항목 제거
# 3. NestJS에서 Origin이 올바른 헤더를 보내는지 확인@Get('products')findAll(@Res({ passthrough: true }) res: Response) { // 주의: NestJS는 기본적으로 Cache-Control 헤더를 설정하지 않음 // 설정 안 하면 CloudFront는 TTL=0으로 캐시 안 함 res.setHeader('Cache-Control', 'public, max-age=300'); return this.productsService.findAll();}핵심: NestJS는 Spring Boot와 달리 기본
Cache-Control헤더를 설정하지 않는다. CloudFront가 캐시하려면 Origin이 명시적으로Cache-Control: public, max-age=N을 응답해야 한다.
7. 체크리스트
섹션 제목: “7. 체크리스트”기본 이해
섹션 제목: “기본 이해”-
max-age와s-maxage의 차이를 설명할 수 있다 -
no-cache와no-store가 다르다는 것을 안다 -
public과private의 차이를 실무 시나리오로 설명할 수 있다 - ETag를 이용한 304 응답 흐름을 단계별로 설명할 수 있다
실무 적용
섹션 제목: “실무 적용”- API 응답 유형별 (공개/개인/민감) 적절한 캐시 전략을 설계할 수 있다
- Nest.js에서 응답 헤더로 캐시를 제어하는 코드를 작성할 수 있다
- Cache Busting과 CDN Invalidation의 차이를 알고 상황에 맞게 선택할 수 있다
-
stale-while-revalidate가 왜 유용한지 설명할 수 있다 - 캐시 스탬피드 시나리오를 설명하고, stale-while-revalidate / XFetch / Mutex lock 중 상황에 맞는 해결책을 고를 수 있다
-
Vary헤더에 2개 이상 필드를 추가할 때 캐시 공간 폭발 위험을 인지하고, Accept-Language를 URL로 분리하는 대안을 설명할 수 있다
디버깅
섹션 제목: “디버깅”-
curl -I명령으로 캐시 관련 응답 헤더를 읽을 수 있다 -
x-cache,age,cf-cache-status헤더로 캐시 히트 여부를 확인할 수 있다 - CloudFront Invalidation을 AWS CLI로 실행할 수 있다
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”- Cache-Control: HTTP 캐시를 제어하는 핵심 헤더
- max-age: 캐시 신선도 유지 시간 (초 단위)
- s-maxage: 공유 캐시(CDN)에만 적용되는 max-age
- no-cache: 캐시 저장은 허용, 사용 전 서버 검증 필수
- no-store: 캐시 저장 자체를 금지
- public / private: 공유 캐시 허용 여부
- ETag: 리소스 버전 식별 해시값
- If-None-Match: ETag를 서버에 보내는 조건부 요청 헤더
- 304 Not Modified: 리소스 변경 없을 때 본문 없이 반환하는 응답 코드
- Last-Modified / If-Modified-Since: 시간 기반 조건부 요청
- Cache Busting: URL에 해시를 포함해 캐시를 우회하는 기법
- CDN Invalidation: CDN 캐시를 강제로 삭제하는 작업
- stale-while-revalidate: 만료 후에도 즉시 응답하며 백그라운드에서 갱신
- Cache Hit / Cache Miss: 캐시에서 응답이 나왔는지 여부
- Conditional Request: 서버에 조건부로 요청해 불필요한 본문 전송을 방지
- Vary: 캐시 키에 포함될 요청 헤더를 지정하는 응답 헤더 (예:
Vary: Accept-Encoding) - Cache Poisoning: 캐시에 악의적인 응답을 주입해 다수 사용자에게 전달하는 보안 공격
- Cache Stampede (Thundering Herd): 캐시 만료 시 다수 요청이 동시에 Origin을 히트하는 장애 패턴
- XFetch: 캐시 만료 전 확률적으로 선제 갱신해 스탬피드를 방지하는 알고리즘
- Combinatorial Explosion: Vary 헤더 다중 필드 조합으로 캐시 엔트리 수가 조합 수만큼 폭발하는 현상
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 HTTP caching — MDN Web Docs — HTTP 캐시의 모든 개념을 가장 정확하게 설명하는 공식 문서. Conditional Request 흐름이 그림과 함께 잘 정리되어 있다. (입문)
- 📖 A Guide to HTTP Cache Control Headers — DebugBear — no-store, no-cache, private, s-maxage, immutable, stale-while-revalidate 등 12개 디렉티브를 실전 예제와 함께 설명. DebugBear 캐시 검사 도구와 연계해 헤더 값을 직접 확인 가능. (입문~중급)
- 📖 Amazon CloudFront - Manage how long content stays in the cache — CloudFront에서 Origin Cache-Control 헤더를 어떻게 처리하는지, TTL 설정 방법까지 공식 문서 기준 설명. AWS 실무 필수. (중급)
- 📖 CloudFront cache problems and how to solve them — Advanced Web Machinery — CloudFront 캐시 미스 원인 분석, Cache Key 오류, Vary 헤더 문제 등 실무 CloudFront 캐시 문제 총정리. (중급)
- 📖 Cache-Control for Private APIs — the bug nobody sees — 인증 API에 Cache-Control을 빠뜨렸을 때 발생하는 보안 버그 시나리오. 실제 운영 중 발생할 수 있는 개인정보 노출 사례. (입문)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: 캐시 헤더 확인
섹션 제목: “실습 1: 캐시 헤더 확인”# MDN 공식 사이트의 캐시 헤더 확인curl -I https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching
# 예상 출력:# HTTP/2 200# cache-control: max-age=0, s-maxage=3600# etag: "abc123"# last-modified: Mon, 01 Jan 2025 00:00:00 GMT실습 2: 304 Not Modified 직접 발생시키기
섹션 제목: “실습 2: 304 Not Modified 직접 발생시키기”# 1단계: ETag 값 확인ETAG=$(curl -sI https://httpbin.org/cache/5 | grep -i etag | awk '{print $2}' | tr -d '\r')echo "ETag: $ETAG"
# 2단계: 5초 대기 (캐시 만료)sleep 6
# 3단계: If-None-Match로 조건부 요청curl -I -H "If-None-Match: $ETAG" https://httpbin.org/cache/5
# 예상 출력 (캐시가 아직 유효하면):# HTTP/2 304# (본문 없음)실습 3: CloudFront 캐시 상태 확인
섹션 제목: “실습 3: CloudFront 캐시 상태 확인”# CloudFront 배포를 통해 요청 시 캐시 상태 헤더 확인curl -I https://your-distribution.cloudfront.net/your-path
# 예상 출력:# HTTP/2 200# x-cache: Hit from cloudfront ← 캐시 히트# x-amz-cf-pop: ICN57-P1 ← 서울 리전 엣지 서버# age: 1523 ← 캐시 저장 후 1523초 경과# cache-control: public, max-age=3600실습 4: CloudFront Invalidation 실행
섹션 제목: “실습 4: CloudFront Invalidation 실행”# 특정 경로 무효화aws cloudfront create-invalidation \ --distribution-id YOUR_DISTRIBUTION_ID \ --paths "/api/products" "/api/categories/*"
# 무효화 상태 확인aws cloudfront get-invalidation \ --distribution-id YOUR_DISTRIBUTION_ID \ --id INVALIDATION_ID
# 예상 출력:# {# "Invalidation": {# "Status": "Completed",# "InvalidationBatch": {# "Paths": {# "Items": ["/api/products"]# }# }# }# }실습 5: Nest.js에서 캐시 헤더 적용 후 확인
섹션 제목: “실습 5: Nest.js에서 캐시 헤더 적용 후 확인”# Nest.js 앱 실행 중이라고 가정curl -I http://localhost:3000/products
# 예상 출력:# HTTP/1.1 200 OK# Cache-Control: public, max-age=300, stale-while-revalidate=60# ETag: "d4e5f6a7b8"# Content-Type: application/json실습 6: 캐시 포이즈닝 취약점 간이 테스트
섹션 제목: “실습 6: 캐시 포이즈닝 취약점 간이 테스트”# 1. 정상 요청의 응답 본문 확인curl -s https://your-site.com/page | grep -o 'src="[^"]*"' | head -5
# 2. X-Forwarded-Host를 변조하여 응답 차이 확인curl -s https://your-site.com/page \ -H "X-Forwarded-Host: attacker.com" | grep -o 'src="[^"]*"' | head -5
# 예상 출력 (안전한 경우):# src="https://your-site.com/app.js" ← 두 요청 모두 동일
# 예상 출력 (취약한 경우):# src="https://attacker.com/app.js" ← 변조된 호스트가 반영됨!# → Origin Request Policy에서 X-Forwarded-Host 제거 필요실습 7: CloudFront Cache Policy 확인
섹션 제목: “실습 7: CloudFront Cache Policy 확인”# 현재 배포에 적용된 Cache Policy 목록 확인aws cloudfront get-distribution-config --id YOUR_DIST_ID \ --query 'DistributionConfig.CacheBehaviors.Items[*].{Path:PathPattern,CachePolicy:CachePolicyId}'
# 관리형 Cache Policy 목록 (이름과 ID 매핑)aws cloudfront list-cache-policies --type managed \ --query 'CachePolicyList.Items[*].CachePolicy.{Name:CachePolicyConfig.Name,Id:Id}'
# 예상 출력:# [# { "Name": "CachingOptimized", "Id": "658327ea-f89d-4fab-a63d-7e88639e58f6" },# { "Name": "CachingDisabled", "Id": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" },# { "Name": "CachingOptimizedForUncompressedObjects", "Id": "b2884449-..." }# ]10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”HTTP 캐시는 Cache-Control 헤더로 응답의 저장 위치와 유효 시간을 지시하고, ETag로 불필요한 본문 전송을 막으며, Cache Busting과 CDN Invalidation으로 무효화를 제어하는 — 서버 부하와 응답 속도의 균형을 맞추는 인프라 레이어다.