CloudFront 캐시는 CDN 콘솔에서 정해지지 않는다
CDN 콘솔의 TTL 과 코드가 내려보내는 Cache-Control 은 다른 물건이다. 무엇이 무엇을 이기는지 정리했다.
- Date
- Tags
- #cloudfront#cdn#cache-control#nextjs#react
Input
Cache-Control 이란
응답에 딸려 오는 한 줄짜리 쪽지다. 서버가 "이 파일, 누가 얼마나 들고 있어도 된다"를 적어 보낸다. 브라우저와 CDN 은 이 쪽지를 읽고 따른다.
Cache-Control: public, max-age=3600
앞칸이 누가, 뒷칸이 얼마나다.
알아야 할 지시어 넷
| 지시어 | 뜻 | 누가 따르나 |
|---|---|---|
max-age=N | N초 보관 | 모두 (브라우저 + CDN) |
s-maxage=N | N초 보관 | CDN 만. 브라우저는 무시 |
immutable | 만료 전엔 묻지도 마라 | 브라우저 |
stale-while-revalidate=N | 만료 후 N초는 옛 걸 주면서 뒤에서 갱신 | 둘 다 |
둘이 같이 있으면 CDN 은 s-maxage, 브라우저는 max-age 를 본다.
CDN 은 하루, 브라우저는 1분 같은 분리가 한 줄로 된다.
Cache-Control: public, s-maxage=86400, max-age=60
immutable 은 "새로고침해도 서버에 안 물어봄"이다. 강력한 만큼 되돌릴 수 없다.
파일명에 해시가 박힌 파일에만 쓴다. 내용이 바뀌면 이름이 바뀌니 안전하다.
CloudFront 의 TTL 이란
엣지가 사본을 들고 있는 시간. 콘솔에서 세 값을 정한다.
| 값 | 역할 |
|---|---|
| DefaultTTL | 오리진이 Cache-Control 을 안 보냈을 때 쓰는 기본값 |
| MinTTL | 오리진이 보낸 값의 하한 |
| MaxTTL | 오리진이 보낸 값의 상한 |
즉 TTL 은 값이 아니라 범위다.
오리진이 Cache-Control 을 보냈나?
│
├─ 아니오 → DefaultTTL
│
└─ 예 → s-maxage(없으면 max-age) 를 MinTTL ~ MaxTTL 사이로 자른 값
내가 본 배포는 Min·Default 가 0 이었다. 판단을 전부 오리진에 넘긴 상태다.
그래서 오리진이 max-age=0 을 흘리면 콘솔을 아무리 뒤져도 엣지 캐시는 0 이었다.
Problem
TTL 과 max-age 는 뭐가 다른가
같은 "캐시 시간" 같지만 작동하는 자리가 다르다.
오리진 (Next · nginx)
│
│ Cache-Control: public, s-maxage=86400, max-age=60
▼
CloudFront 엣지
│
│ TTL 이 작동하는 유일한 지점.
│ 헤더는 손대지 않고 그대로 아래로 전달한다.
▼
브라우저
TTL 을 아예 모른다. max-age=60 만 보고 판단한다.
- 범위. TTL 은 엣지 한 곳.
Cache-Control은 엣지 + 브라우저 전부. - 브라우저는 TTL 을 모른다. 콘솔에서 TTL 을 1년으로 올려도 브라우저 캐시는 1초도 안 늘어난다.
- 회수. 엣지는 invalidation 으로 지운다. 브라우저 캐시는 지울 방법이 없다.
| CloudFront TTL | max-age | s-maxage | |
|---|---|---|---|
| 정하는 곳 | CDN 콘솔 | 코드 | 코드 |
| 영향 범위 | 엣지만 | 브라우저 + CDN | CDN 만 |
| 나중에 취소 | 가능 | 불가능 | 가능 |
결론. 엣지 히트율은 CDN 설정이 아니라 애플리케이션 코드가 결정한다. 콘솔의 TTL 은 오리진이 아무 말도 안 했을 때를 위한 안전망이지 조종간이 아니다.
Output
CloudFront 란 무엇인가
AWS 의 CDN. 우리 서버(오리진) 앞에 전 세계 수백 곳의 복사본 창고(엣지) 를 세워둔다. 사용자는 서버가 아니라 자기한테 가장 가까운 창고에서 파일을 받는다.
사용자
│
▼
가까운 엣지
│
├─ HIT → 엣지가 바로 응답. 오리진은 가지도 않는다.
│
└─ MISS → 오리진에서 받아온다 → 엣지에 저장 → 응답
(다음 사람부터는 HIT)
한 줄 요약: 멀리 있는 서버에 매번 가지 않게 하는 장치. 그래서 CDN 튜닝의 목표는 하나다 — MISS 를 HIT 으로 바꾸기.
Next.js 는 next.config.js 에서
_next/static/** 은 Next 가 알아서 immutable 을 붙인다. 해시 파일명이니 맞다.
문제는 public/ 이다. 기본값이 max-age=0 이라 어디에도 안 걸린다.
async headers() {
const asset = [{ key: 'Cache-Control', value: 'public, max-age=3600, stale-while-revalidate=86400' }];
return [
{ source: '/assets/:path*', headers: asset },
{ source: '/favicon.ico', headers: asset },
];
}
robots.txt·sitemap.xml 은 app/ 규칙 파일 대신 라우트 핸들러로 둔다.
규칙 파일로 두면 Next 가 1년 immutable 을 붙이는데, 크롤러 캐시는 invalidation 으로도 못 지운다.
React(Vite) 는 nginx 가 정한다
Vite 산출물에는 자동 헤더가 없다. 정적 파일 서버가 전부 정한다.
까다로운 건 /assets/ 아래에 성격이 정반대인 두 종류가 섞인다는 점이다.
- 빌드 산출물 →
assets/바로 아래 평평하게, 파일명에 해시 public/assets/**→ 하위 디렉터리로 그대로 복사, 파일명 고정
경로 prefix 로는 못 가른다. 정규식으로 "해시 있는 평평한 파일"만 골라야 한다.
# 정규식 location 은 prefix location 보다 먼저 평가된다
location ~ "^/assets/[^/]+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$" {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 파일명 고정이라 immutable 금지
location /assets/ {
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400";
}
# 진입 문서가 엣지에 붙잡히면 배포 후 흰 화면이 된다
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
index.html 이 왜 no-cache 인가. 옛 문서가 가리키는 해시 자산은 새 빌드에 없다 → 404 → 흰 화면.
결국 이 표 하나
| 대상 | Cache-Control |
|---|---|
| 해시 파일명 | public, max-age=31536000, immutable |
| 고정 파일명 자산 | public, max-age=3600, stale-while-revalidate=86400 |
| robots.txt / sitemap.xml | public, max-age=300 |
| SPA 진입 문서 | no-cache, must-revalidate |
| SSR HTML · 개인화 응답 | no-store 계열 |
밟은 함정 셋
전부 설정은 맞는데 동작만 틀린 종류였다. 눈으로는 안 보인다.
| 함정 | 무슨 일이 | 왜 |
|---|---|---|
| 헤더가 두 줄로 나감 | 설정이 조용히 무효 | Next 가 자기 헤더를 덧붙임. RFC 9111 상 엄격한 쪽이 이김 |
nginx ^~ /assets/ | 정규식 블록이 안 돌음 | ^~ 는 prefix 일치 시 정규식 평가를 건너뜀 |
location 안 add_header | 상위 헤더가 전부 사라짐 | nginx 의 add_header 는 상속을 끊음 |
그래서 헤더를 눈이 아니라 테스트로 확인하게 만들었다.
특히 Cache-Control 이 몇 줄인지 세는 검사. 한 줄이 아니면 실패.
남은 것
배포 파이프라인에 invalidation 단계가 없으면 고정 파일명 자산의 TTL 을 길게 못 가져간다.
그게 들어가면 s-maxage 로 엣지만 길게 잡는 걸 다시 볼 수 있다.
형식은 PKM — 기록으로 나를 디버깅하기 에서 정한 Input / Problem / Output 을 따랐다.