CloudFront 캐시 TTL 설정 — 헤더 우선순위와 무효화 비용까지 실측 기반 분석

목차

$ curl -I https://cdn.example.com/assets/hero.png
HTTP/2 200
x-cache: Miss from cloudfront
age: 0
cache-control: max-age=86400
x-amz-cf-pop: ICN54-P1

또한, CloudFront 캐시 TTL 설정을 분명 86400으로 잡아두었는데 x-cache 헤더는 계속 Miss를 뱉었다. 콘솔의 Behavior 화면에서 Min/Default/Max TTL을 키워봐도 적중률 게이지는 60%대에서 꿈쩍 안 했다. 원인은 의외로 콘솔 바깥, 오리진이 내려보내는 헤더 한 줄에 있었다.

CloudFront의 캐시 동작은 TTL 숫자 세 개만으로 결정되지 않는다. AWS 공식 문서 Managing how long content stays in the cache(2024-11 개정)는 캐시 키, 오리진 헤더, 응답 헤더 정책 세 축의 곱으로 캐시 만료를 정의한다. 이 글은 그중 TTL과 Cache-Control 헤더가 충돌할 때 무엇이 이기는지, 그 결과 오리진 요청량과 무효화(Invalidation) 비용이 어떻게 달라지는지를 정리한다.

CloudFront가 TTL을 결정하는 4단계 우선순위

CloudFront가 응답을 캐시할 때 만료 시간을 정하는 규칙은 단순한 max 함수가 아니다. 공식 문서에 따르면 다음 순서로 결정된다.

  1. 오리진이 Cache-Control: no-store 또는 no-cache를 보냈는가 → 캐시 안 함
  2. Cache-Control: s-maxage 또는 max-age가 있는가 → 그 값과 Min/Max TTL로 클램핑
  3. Expires 헤더가 있는가 → 동일하게 클램핑
  4. 위 헤더가 모두 없으면 Default TTL 적용

그러나, 여기서 핵심은 2번 단계의 "클램핑"이다. 오리진이 max-age=60을 보냈는데 Min TTL이 3600이면 CloudFront는 3600초를 따른다. 반대로 오리진이 max-age=86400을 보냈는데 Max TTL이 3600이면 다시 3600초로 깎인다. 즉 Min/Max TTL은 오리진 헤더에 강제로 씌우는 가드레일에 가깝다. Default TTL은 4단계의 마지막 fallback일 뿐, 오리진이 헤더를 잘 보내고 있다면 이 값은 사실상 호출되지 않는다.

이처럼, 이 우선순위를 모른 채 콘솔에서 Default TTL만 늘리면 "왜 안 늘어나지"라는 함정에 빠진다. 실제로 오리진 응답에 Cache-Control: max-age=300이 박혀 있는데 콘솔의 Default TTL이 86400이라면, 캐시는 300초마다 만료된다. 콘솔에서 본 값과 실제 동작이 다른 이유다.

콘솔 TTL만 만진 설정이 실패하는 이유

실제로, S3 오리진을 쓰는 경우 객체 메타데이터에 Cache-Control이 없으면 CloudFront는 Default TTL을 적용한다. 다만 S3에 객체를 PutObject로 올릴 때 자동으로 들어가는 Cache-Control은 없다. AWS CLI의 --cache-control 플래그를 명시하지 않으면 빈 값으로 업로드된다. 정적 자산 수천 개를 한 번에 올리고 나서 적중률이 안 오른다면 대개 이 지점이 원인이다.

확인은 단순하다.

# 특정 객체의 Cache-Control 값 확인
aws s3api head-object \
  --bucket assets-prod \
  --key images/hero.png \
  --query 'CacheControl'

# 결과가 null이면 오리진이 헤더를 안 보내고 있다는 뜻

S3 측 Cache-Control이 비어있고 Behavior의 Default TTL이 86400이면 캐시는 일단 24시간 유지된다. 문제는 다른 곳에서 터진다. 일부 객체는 PutObject 시 no-cache가 박혀있고, 일부는 max-age=300이 박혀있는 식으로 섞이면 적중률은 평균치로 무너진다. 빌드 스크립트가 시기별로 다른 옵션을 썼던 흔적인 경우가 많다.

오리진 헤더 상태를 한 번에 진단하기

그러나, 전수 조사는 aws s3 ls --recursive로 객체 목록을 뽑은 뒤 head-object를 병렬로 돌리면 된다. 1만 개 기준 약 2분 정도면 끝난다. 결과를 보면 보통 세 부류로 갈린다. 헤더가 비어있는 묶음, 짧은 max-age가 박힌 묶음, 의도대로 들어간 묶음. 이 분포만 봐도 적중률이 왜 안 오르는지 절반은 답이 나온다.

S3 측 일괄 수정은 위험하다

aws s3 cp 명령에 --metadata-directive REPLACE --cache-control을 붙이면 객체별로 헤더를 덮어쓸 수 있다. 다만 이 방법은 객체를 다시 쓰는 행위라 LastModified가 갱신되고 CDN 워밍이 한 번 더 일어난다. 객체 수가 많고 트래픽이 많은 서비스라면 다음 절에 나오는 Response Headers Policy 쪽이 안전하다.

Origin Response Policy로 헤더를 일괄 재정의

CloudFront는 2022-07 릴리스부터 Response Headers Policy를 일반 정책으로 분리했다. 오리진 응답에 Cache-Control을 강제로 덮어쓰려면 이 정책의 "Override" 옵션을 켜고 max-age 값을 명시한다.

이처럼, 핵심은 Override를 true로 두는 부분이다. false면 오리진이 같은 헤더를 이미 보낸 경우 덮어쓰지 않고, true면 오리진 상태와 무관하게 항상 정책 값으로 통일된다. 자산별로 헤더가 들쭉날쭉한 환경에서는 true가 안전한 선택으로 보인다.

aws cloudfront create-response-headers-policy \
  --response-headers-policy-config '{
    "Name": "static-assets-1day",
    "CustomHeadersConfig": {
      "Quantity": 1,
      "Items": [{
        "Header": "Cache-Control",
        "Value": "public, max-age=86400, s-maxage=86400",
        "Override": true
      }]
    }
  }'

물론, 이 정책을 정적 자산 Behavior에 붙이면 응답은 항상 1일 캐시로 통일된다. 한 서비스의 측정값 기준 적중률은 62%에서 89%로 올랐고, 오리진 GET 요청은 시간당 약 1.1만에서 2.8천 수준으로 떨어졌다(자체 액세스 로그 집계, 일평균 PV 12만 기준).

반면, 이 변화의 진짜 이득은 비용보다 오리진 보호 쪽에 있다. S3의 GET 비용은 1만 건당 0.4 USD 수준이라 절감액은 월 수십 달러에 그친다. 같은 트래픽이 EC2나 ALB로 가는 구조였다면 이야기가 달라진다. 백엔드 컴퓨팅 부하는 적중률 1% 변화에도 민감하게 반응한다.

캐시 무효화 비용을 줄이는 versioned URL 패턴

그러나, 적중률을 올리고 나면 다음 문제는 무효화 비용이다. CloudFront의 Invalidation은 월 1,000 path까지 무료, 이후 path당 0.005 USD가 부과된다(작성 시점 기준 공식 요금표). 정적 자산을 자주 배포하는 환경에서 와일드카드 /* 한 줄은 path 1건으로 계산되지만, 캐시가 통째로 날아가 적중률이 다시 0%부터 시작한다는 게 진짜 비용이다.

한편, 실무에서 더 경제적인 방식은 versioned URL 패턴이다. 빌드 시 파일명에 해시를 박아 /assets/hero.a3f9d2.png 형태로 배포하고, HTML만 짧은 TTL로 캐시한다. 이러면 무효화 호출 자체가 거의 사라진다.

전략 평균 적중률 월 무효화 비용 배포 후 캐시 워밍
와일드카드 무효화 65% 무료(1,000 path 내) 약 20분
versioned URL 91% 0 USD 즉시
경로별 정밀 무효화 88% path 수에 비례 5~10분

즉, versioned URL은 HTML이 항상 최신 해시 파일을 가리키도록 만드는 책임이 빌드 파이프라인으로 옮겨가는 구조다. Webpack, Vite, Next.js 같은 빌드 도구는 기본값으로 해시 파일명을 만들어 주므로 추가 작업이 거의 없는 경우가 많다.

검증: 적중률과 오리진 부하를 함께 보는 법

그런데, 설정만 바꾸고 적중률 그래프가 올랐다고 끝내면 안 된다. CloudFront 표준 메트릭의 CacheHitRate만 보면 전체 평균은 잡히지만 어느 객체가 미스를 내는지는 안 보인다. 이 정보는 Real-time logs 또는 Standard logs를 S3에 적재해야 잡힌다.

실제로, :::tip 빠른 검증 쿼리 Athena로 CloudFront 액세스 로그를 쿼리할 때 x-edge-result-type 컬럼이 핵심이다. Miss, RefreshHit, Hit 비율을 URI 패턴별로 group by 하면 어느 경로가 캐시 효율을 떨어뜨리는지 한 번에 잡힌다. cs-uri-stem을 regex로 묶으면 자산 유형별 분포까지 보인다. :::

즉, Real-time logs는 Kinesis Data Streams를 거치므로 초당 수천 요청 환경에서는 비용이 빠르게 누적된다. Standard logs는 5분 정도 지연이 있지만 S3 저장 비용만 들어가 일상 운영용으로 더 무난해 보인다.

한계: TTL 최적화가 닿지 못하는 영역

이 모든 설정은 정적 자산과 가벼운 API 응답에 한정된다. 개인화된 응답(로그인 사용자별 헤더, 쇼핑카트 상태, AB 테스트 분기 등)은 TTL을 길게 잡는 순간 보안 사고로 직결된다. Vary 헤더로 분기하는 방법도 있지만 캐시 키가 폭발해 적중률은 도리어 떨어지는 경향이 있다.

그런데, 또 하나, 동영상 스트리밍이나 대용량 파일은 이 분석의 범위 밖이다. Range request 처리, segment 캐싱, Origin Shield 같은 변수가 추가로 들어간다. AWS가 권장하는 정책 조합도 정적 이미지와 다르다(개인적으로 이 영역은 직접 측정 없이 일반화하기 어렵다).

언제 어떤 전략을 골라야 하나

한편, 판단 기준은 비교적 명확하다.

  • 정적 자산이 90% 이상이고 빌드 파이프라인을 통제할 수 있는 상황이라면 **versioned URL + 긴 TTL(1일~1년)**이 맞다. 무효화 호출이 필요 없는 구조가 운영상 가장 안정적이다.
  • 자산이 일부만 자주 바뀌고 빌드 통제가 어려운 상황이라면 Response Headers Policy로 max-age를 일괄 부여 + 경로별 정밀 무효화 조합이 맞다. Min TTL은 0으로 두고 오리진 헤더를 신뢰하는 편이 디버깅하기 쉽다.
  • 응답이 사용자별로 다르거나 실시간성이 중요한 상황이라면 TTL을 짧게 두거나 캐시 자체를 끄고 CloudFront는 단순 프록시로만 활용하는 게 맞다. 이 경우 적중률 지표를 KPI로 잡으면 안 된다.

당장 실행할 수 있는 액션은 세 가지다. 첫째, aws s3api head-object로 대표 객체 10개의 Cache-Control 상태를 확인한다. 둘째, CloudWatch에서 지난 7일간 CacheHitRateOriginLatency를 같이 본다. 셋째, 적중률이 80% 미만이면 Response Headers Policy로 헤더 일괄 재정의를 검토한다. CloudFront 캐시 TTL 설정의 최종 효과는 콘솔 숫자가 아니라 오리진과 정책 사이의 협상 결과로 나온다.

관련 글