그래프로

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=NN초 보관모두 (브라우저 + CDN)
s-maxage=NN초 보관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 만 보고 판단한다.
  1. 범위. TTL 은 엣지 한 곳. Cache-Control 은 엣지 + 브라우저 전부.
  2. 브라우저는 TTL 을 모른다. 콘솔에서 TTL 을 1년으로 올려도 브라우저 캐시는 1초도 안 늘어난다.
  3. 회수. 엣지는 invalidation 으로 지운다. 브라우저 캐시는 지울 방법이 없다.
CloudFront TTLmax-ages-maxage
정하는 곳CDN 콘솔코드코드
영향 범위엣지만브라우저 + CDNCDN 만
나중에 취소가능불가능가능

결론. 엣지 히트율은 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.xmlpublic, 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 을 따랐다.