CDN을 앞단에 붙였는데도 오리진 트래픽이 줄지 않는다면, 십중팔구 캐시 전략이 잘못 설계된 것입니다. CDN은 “일단 앞에 두면 알아서 캐싱해주는 마법”이 아니라, 무엇을 얼마나 오래, 어떤 키로 캐시할지 명시적으로 설계해야 제 성능을 냅니다.
이 글에서는 실무에서 반복적으로 문제가 되는 세 가지 축, 즉 TTL(캐시 유효 기간), 캐시키(어떤 요청을 같은 것으로 볼지), 퍼지(강제 무효화)를 중심으로 설계 원칙과 실제 설정 예시를 정리합니다. 특정 벤더에 종속되지 않도록 표준 HTTP 헤더를 기준으로 설명하고, 필요한 부분에서 CloudFront·Fastly·Nginx 예시를 덧붙입니다.
TTL 설계: max-age와 s-maxage를 분리하라
가장 흔한 실수는 브라우저 캐시와 CDN(공유 캐시) 캐시를 같은 값으로 묶는 것입니다. Cache-Control에서 max-age는 브라우저용, s-maxage는 공유 캐시(CDN)용입니다. 둘을 분리하면 “브라우저는 짧게, CDN은 길게” 같은 전략이 가능합니다.
예를 들어 자주 바뀌지 않는 정적 에셋은 CDN에 오래 두되, 브라우저에는 짧게 두어 배포 후 반영을 빠르게 할 수 있습니다.
# 정적 에셋(해시 파일명 기반) - 사실상 영구 캐시
Cache-Control: public, max-age=31536000, immutable
# HTML(자주 바뀜) - 브라우저는 캐시 금지, CDN만 짧게
Cache-Control: public, max-age=0, s-maxage=60, must-revalidate
# API 응답(개인화 없음) - 브라우저 10초, CDN 5분
Cache-Control: public, max-age=10, s-maxage=300
immutable은 파일명에 콘텐츠 해시가 들어간 경우에만 안전합니다(app.9f3a1c.js). 파일 내용이 바뀌면 파일명이 바뀌므로 재검증 자체가 필요 없습니다. 반대로 index.html처럼 파일명이 고정된 리소스에 immutable을 걸면 배포해도 갱신되지 않는 사고가 납니다.
stale-while-revalidate로 지연 없는 갱신
TTL이 만료되는 순간 다음 요청이 오리진까지 왕복하면서 지연이 튑니다. stale-while-revalidate를 쓰면 만료된 캐시를 즉시 반환하면서 백그라운드에서 갱신하므로, 사용자는 항상 캐시 속도로 응답을 받습니다.
Cache-Control: public, s-maxage=60, stale-while-revalidate=600
위 설정은 “60초 동안은 신선, 이후 600초까지는 낡은 캐시를 주면서 뒤에서 조용히 갱신”을 의미합니다. 오리진 장애 시 낡은 응답이라도 서빙하려면 stale-if-error를 함께 둡니다.
Cache-Control: public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400
- 장점: 만료 경계에서 발생하는 레이턴시 스파이크 제거, 오리진 부하 평탄화
- 주의: 최대 24시간 낡은 데이터가 노출될 수 있으므로, 정확성이 중요한 응답에는 값을 짧게
캐시키 설계: 무엇이 요청을 “다르게” 만드는가
캐시키는 CDN이 “이 두 요청이 같은 응답을 가진다”고 판단하는 기준입니다. 기본 캐시키는 보통 호스트 + 경로이며, 쿼리스트링과 헤더를 포함시킬지는 선택입니다. 여기서 두 가지 방향의 사고가 있습니다.
첫째, 캐시키에 불필요한 요소를 넣으면 캐시가 파편화됩니다. 추적용 쿼리(utm_source 등)를 키에 포함하면 같은 콘텐츠가 수십 벌로 캐시되어 히트율이 폭락합니다. 둘째, 꼭 필요한 요소를 빼면 오염됩니다. 언어별 응답이 다른데 Accept-Language를 무시하면 엉뚱한 언어가 캐시됩니다.
# CloudFront 캐시 정책(예시): 추적 쿼리 제거, 필요한 것만 포함
QueryStringsConfig:
QueryStringBehavior: whitelist
QueryStrings: [page, sort, category] # utm_* 등은 키에서 제외
HeadersConfig:
HeaderBehavior: whitelist
Headers: [Accept-Language, Accept-Encoding]
CookiesConfig:
CookieBehavior: none # 개인화 없는 경로는 쿠키 전부 무시
개인화가 섞인 응답이라면 오리진이 Vary 헤더로 명시해야 합니다. 예를 들어 압축 방식별로 응답이 다르면 Vary: Accept-Encoding이 필수입니다. 단, Vary: Cookie는 사실상 캐시를 무력화하니 개인화 조각은 CDN 캐시 대상에서 아예 분리하는 편이 낫습니다.
퍼지(Purge)와 무효화: soft vs hard
배포나 데이터 변경 시 캐시를 강제로 비워야 할 때가 있습니다. 퍼지에는 크게 두 방식이 있습니다.
- Hard purge: 캐시 즉시 삭제. 다음 요청은 무조건 오리진으로 가므로, 대량 퍼지 직후 오리진에 썬더링 허드(thundering herd)가 발생할 수 있음
- Soft purge: 캐시를 “stale”로 표시만.
stale-while-revalidate와 결합하면 사용자에게는 낡은 응답을 주면서 백그라운드 갱신 → 오리진 충격 최소화
Fastly의 surrogate key(태그 기반 퍼지)를 쓰면 특정 콘텐츠 그룹만 정밀하게 무효화할 수 있습니다.
# 오리진 응답에 태그 부착
Surrogate-Key: product-1234 category-shoes
# 상품 1234만 soft purge (Fastly API)
curl -X POST "https://api.fastly.com/service/$SID/purge/product-1234" \
-H "Fastly-Key: $TOKEN" \
-H "Fastly-Soft-Purge: 1"
CloudFront는 태그 퍼지가 없으므로 경로 패턴 무효화를 씁니다. 전체(/*) 무효화는 비용과 지연이 크니, 배포는 앞서 말한 해시 파일명 전략으로 무효화 자체가 필요 없게 만드는 것이 정석입니다.
aws cloudfront create-invalidation \
--distribution-id E123ABC \
--paths "/index.html" "/api/config.json"
캐시 계층과 히트율 모니터링
CDN은 보통 엣지(edge) – 실드/오리진 실드(shield) – 오리진의 다층 구조입니다. 엣지가 미스여도 실드에서 맞으면 오리진은 보호됩니다. 실드를 켜면 오리진 요청 수가 엣지 POP 수만큼 곱해지는 문제를 줄일 수 있습니다.
운영에서는 응답에 캐시 상태를 노출시켜 디버깅합니다. 표준화된 Cache-Status 헤더나 벤더 헤더(X-Cache)를 확인하세요.
# 응답 헤더로 히트/미스 확인
curl -sI https://cdn.example.com/app.9f3a1c.js | grep -iE 'cache|age'
# 예상 출력:
# Cache-Status: cdn; hit; ttl=8640000
# Age: 12034
# X-Cache: Hit from cloudfront
핵심 지표는 캐시 히트율(byte hit ratio 포함)과 오리진 오프로드율입니다. 히트율이 낮다면 캐시키 파편화(쿼리·쿠키), TTL 과소 설정, Vary 남용을 순서대로 의심하세요.
안티패턴 체크리스트
실무에서 반복적으로 마주치는 함정을 미리 점검하면 사고를 크게 줄일 수 있습니다.
Set-Cookie가 붙은 응답은 많은 CDN이 기본적으로 캐시하지 않음 → 정적 응답에 세션 쿠키가 새는지 확인Cache-Control: private또는no-store가 실수로 정적 에셋에 붙으면 CDN이 통과만 함- 인증이 필요한 응답을
public으로 캐시해 다른 사용자에게 노출되는 보안 사고 - 404·500 같은 에러도 짧게라도 캐시하면 오리진 폭주를 방지(
s-maxage=10수준)
# Nginx 오리진: 에러도 짧게 캐시해 오리진 보호
map $status $cache_ttl {
200 "public, s-maxage=300, stale-while-revalidate=600";
404 "public, s-maxage=10";
default "no-store";
}
add_header Cache-Control $cache_ttl always;
마무리
CDN 캐시 전략의 본질은 결국 세 가지 질문으로 압축됩니다. 얼마나 오래 신선한가(TTL), 무엇을 같은 요청으로 볼 것인가(캐시키), 어떻게 안전하게 무효화할 것인가(퍼지)입니다. 해시 파일명으로 무효화를 원천 봉쇄하고, s-maxage와 stale-while-revalidate로 오리진을 평탄하게 보호하며, 캐시키에서 추적 쿼리와 쿠키를 걷어내는 것만으로도 대부분의 히트율 문제는 해결됩니다. 설정을 바꿨다면 반드시 Cache-Status 헤더로 실제 히트 여부를 눈으로 확인하고, 대시보드의 오프로드율 변화를 며칠 단위로 추적하는 습관을 들이길 권합니다.