HTTP 캐시 전략 실전: Cache-Control, CDN Edge, stale-while-revalidate로 응답 시간을 줄이는 법
캐시는 서버 앞에 놓는 성능 예산이다
웹 서비스가 느려질 때 가장 먼저 떠올리는 해결책은 서버 증설입니다. 하지만 모든 요청을 오리진까지 보내는 구조라면 서버를 늘려도 같은 종류의 비용이 반복됩니다. 정적 자산, 공개 API, 변경 빈도가 낮은 문서, 상품 목록 일부는 매번 데이터베이스와 애플리케이션 서버를 거치지 않아도 됩니다. HTTP 캐시는 이런 요청을 사용자 가까운 곳에서 끝내는 장치입니다.
문제는 캐시를 단순히 "오래 저장하면 빠르다"로 이해할 때 생깁니다. 잘못된 Cache-Control은 오래된 가격을 보여주거나, 배포한 JavaScript가 사용자 브라우저에 남거나, 개인화 응답이 CDN에 저장되는 사고를 만듭니다. 캐시는 성능 기능이면서 동시에 데이터 일관성 계약입니다. 어떤 응답이 누구에게 얼마나 오래 재사용되어도 되는지 명확히 정해야 합니다.
이 글에서는 브라우저 캐시와 CDN Edge 캐시를 함께 설계하는 기준을 정리합니다. max-age, s-maxage, stale-while-revalidate, immutable, private, no-store를 언제 쓰는지, 배포 파일과 API 응답의 전략을 어떻게 분리하는지, 운영에서 어떤 헤더를 확인해야 하는지 다룹니다.

1. 브라우저 캐시와 CDN 캐시는 다르게 생각한다
브라우저 캐시는 한 사용자의 기기에 저장됩니다. 같은 사용자가 다시 방문할 때 네트워크 요청 자체를 줄일 수 있습니다. 반면 CDN 캐시는 여러 사용자가 공유하는 Edge 서버에 저장됩니다. 같은 URL에 대한 요청을 오리진 대신 CDN이 처리하므로 서버 부하와 지연 시간이 함께 줄어듭니다.
이 둘은 같은 Cache-Control 헤더를 보지만 목적이 다릅니다. 브라우저에는 짧게 저장하고 CDN에는 길게 저장하고 싶은 경우가 많습니다. 이때 s-maxage를 사용합니다. max-age는 브라우저와 공유 캐시가 모두 볼 수 있지만, s-maxage는 CDN 같은 shared cache가 우선합니다. 예를 들어 공개 글 목록은 브라우저에는 60초, CDN에는 10분 저장하도록 설계할 수 있습니다.
개인화 응답은 반드시 조심해야 합니다. 로그인 사용자 이름, 장바구니, 권한 정보가 들어간 응답은 public CDN 캐시에 저장되면 안 됩니다. 이런 응답은 private 또는 no-store를 사용합니다. private은 브라우저 같은 개인 캐시에는 저장할 수 있지만 공유 캐시는 저장하지 말라는 의미입니다. no-store는 어떤 캐시에도 저장하지 말라는 강한 지시입니다.
2. 정적 자산은 파일명 해시와 immutable이 핵심이다
JavaScript, CSS, 이미지처럼 빌드 산출물 파일명에 콘텐츠 해시가 포함되는 자산은 길게 캐시할 수 있습니다. main.a1b2c3.js처럼 파일 내용이 바뀌면 URL도 바뀌기 때문입니다. 이런 파일에는 Cache-Control: public, max-age=31536000, immutable을 줄 수 있습니다. 브라우저는 1년 동안 재검증하지 않아도 되고, 배포가 바뀌면 새 URL을 받습니다.
반대로 index.html이나 앱 셸 문서는 길게 캐시하면 위험합니다. HTML이 오래 남으면 새 JavaScript 파일 경로를 알 수 없고, 사용자는 이전 버전 앱을 계속 보게 됩니다. HTML은 짧은 max-age 또는 no-cache가 안전합니다. no-cache는 저장하지 말라는 뜻이 아니라, 재사용 전 서버에 재검증하라는 의미입니다. 이름이 헷갈리기 때문에 운영 문서에 정확히 적어두는 편이 좋습니다.
정적 자산 전략은 배포 파이프라인과 묶여야 합니다. 파일명 해시가 없는 assets/app.js에 1년 캐시를 주면 롤백이나 핫픽스가 어려워집니다. 반대로 해시 파일인데 매번 no-cache를 주면 CDN과 브라우저 캐시의 장점을 버리게 됩니다. 빌드 결과물의 URL 안정성과 캐시 정책은 항상 함께 봐야 합니다.
3. stale-while-revalidate는 느린 원본을 숨긴다
stale-while-revalidate는 캐시가 만료된 응답을 잠시 더 제공하면서 백그라운드에서 새 응답을 가져오게 하는 전략입니다. 사용자는 빠른 응답을 받고, CDN은 뒤에서 오리진을 갱신합니다. 뉴스 목록, 블로그 홈, 문서 페이지처럼 몇 초에서 몇 분 정도의 지연이 허용되는 콘텐츠에 적합합니다.
예를 들어 Cache-Control: public, s-maxage=300, stale-while-revalidate=3600이라면 CDN은 5분 동안 신선한 응답으로 처리합니다. 5분이 지난 뒤 1시간 동안은 오래된 응답을 즉시 제공할 수 있고, 동시에 새 응답을 가져와 캐시를 갱신합니다. 트래픽이 몰릴 때 오리진으로 동시에 많은 재검증 요청이 들어가는 것도 줄일 수 있습니다.
주의할 점은 데이터 성격입니다. 재고 수량, 결제 상태, 보안 설정처럼 즉시성이 중요한 값에는 맞지 않습니다. 캐시가 사용자 경험을 빠르게 만들 수 있지만, 오래된 정보가 비즈니스 문제를 만들 수 있다면 쓰지 않는 것이 맞습니다. stale 전략은 "조금 오래되어도 괜찮은가"라는 제품 질문에 답한 뒤 적용해야 합니다.
4. 운영 체크리스트
- HTML과 해시 정적 자산의 Cache-Control이 분리되어 있는가
- 개인화 응답에 public 캐시가 붙지 않는가
- CDN에서 s-maxage와 stale-while-revalidate를 실제로 해석하는가
- Vary 헤더가 Accept-Encoding, Authorization, Cookie 같은 분기 조건을 반영하는가
- 배포 후 새 HTML이 이전 JavaScript 파일을 참조하지 않는가
- purge 없이도 새 콘텐츠가 합리적인 시간 안에 보이는가
- 캐시 적중률, 오리진 요청 수, p95 latency를 함께 보고 있는가
5. 캐시 무효화는 배포 전략과 연결한다
캐시를 설계할 때 가장 자주 빠지는 질문은 "언제 지울 것인가"입니다. max-age를 정하는 것만큼 purge 전략도 중요합니다. 콘텐츠가 수정됐는데 CDN이 오래된 응답을 계속 제공하면 작성자는 배포가 실패했다고 느끼고, 사용자는 서로 다른 버전의 페이지를 보게 됩니다. 그래서 캐시 무효화는 운영 도구와 배포 파이프라인 안에 들어가야 합니다.
가장 안전한 방법은 URL을 바꾸는 것입니다. 정적 자산처럼 파일명에 해시를 넣으면 이전 캐시를 지울 필요가 없습니다. 새 HTML이 새 URL을 참조하면 사용자는 자연스럽게 새 파일을 받습니다. 하지만 블로그 글, 상품 상세, API 목록처럼 URL을 유지해야 하는 콘텐츠는 purge가 필요할 수 있습니다. 이때 전체 캐시를 지우는 방식은 간단하지만 비용이 큽니다. 특정 글 URL, 관련 목록 URL, sitemap처럼 영향 범위를 좁히는 것이 좋습니다.
CDN purge는 즉시 반영된다고 가정하면 안 됩니다. provider마다 전파 시간이 있고, 여러 계층의 캐시가 있을 수 있습니다. 브라우저 캐시, CDN 캐시, reverse proxy 캐시, 애플리케이션 내부 캐시가 서로 다른 TTL을 갖고 있으면 원인을 찾기 어렵습니다. 운영 문서에는 각 계층의 TTL과 purge 방법을 표로 정리해 두는 것이 좋습니다.
콘텐츠 관리 도구가 있다면 저장 버튼 이후 어떤 캐시가 지워지는지 명확히 보여줘야 합니다. 작성자가 글을 수정했는데 홈 목록은 갱신되고 상세 페이지는 갱신되지 않는 식의 부분 반영은 신뢰를 떨어뜨립니다. 수정 대상과 파생 페이지를 함께 추적하는 의존성 모델이 있으면 purge 누락을 줄일 수 있습니다.
6. Vary 헤더는 캐시 키의 일부다
같은 URL이라도 요청 헤더에 따라 응답이 달라질 수 있습니다. 대표적으로 Accept-Encoding에 따라 gzip, br, zstd 응답이 달라집니다. 언어 설정을 사용하는 사이트라면 Accept-Language에 따라 한국어와 영어 응답이 달라질 수 있습니다. 로그인 상태나 AB 테스트 버킷에 따라 응답이 바뀌는 경우도 있습니다. 이런 차이를 캐시가 구분하지 못하면 잘못된 응답이 다른 사용자에게 전달됩니다.
Vary 헤더는 캐시가 어떤 요청 헤더를 기준으로 응답을 구분해야 하는지 알려줍니다. Vary: Accept-Encoding은 압축 응답에서 거의 필수입니다. 다국어 정적 페이지라면 Vary: Accept-Language를 고려할 수 있지만, 언어별 URL을 분리하는 편이 캐시 효율과 SEO 측면에서 더 명확한 경우가 많습니다. Cookie나 Authorization을 Vary에 넣으면 캐시 키가 폭발할 수 있으므로 정말 필요한지 검토해야 합니다.
개인화와 캐시는 특히 조심해야 합니다. "로그인한 사용자에게만 버튼 하나를 더 보여주는 공개 페이지"를 전체 HTML 캐시로 처리하면 위험합니다. 이럴 때는 공개 HTML은 캐시하고, 개인화 영역은 클라이언트에서 별도 API로 가져오거나 edge에서 명확히 분리하는 구조가 낫습니다. 캐시 가능한 영역과 개인화 영역을 섞지 않는 것이 사고를 줄입니다.
운영 테스트에서는 curl로 응답 헤더를 직접 확인해야 합니다. Cache-Control만 보는 것이 아니라 Age, Vary, CF-Cache-Status 또는 X-Cache 같은 CDN 상태 헤더를 함께 봅니다. 기대한 대로 HIT가 나는지, 인증 요청이 MISS 또는 BYPASS 되는지, stale 상태가 어떻게 표시되는지 확인해야 합니다.
7. 실제 점검은 헤더와 사용자 흐름을 함께 본다
캐시 정책은 코드 리뷰만으로 검증하기 어렵습니다. 배포 후 실제 URL에 대해 첫 요청과 두 번째 요청의 헤더를 비교해야 합니다. 첫 요청은 MISS, 두 번째 요청은 HIT가 되는지, Age가 증가하는지, purge 이후 다시 MISS가 되는지 확인합니다. 브라우저 개발자 도구에서도 disk cache, memory cache, CDN HIT가 어떻게 보이는지 확인해야 합니다.
사용자 흐름 테스트도 필요합니다. 글을 수정한 뒤 홈 목록, 글 상세, sitemap, Open Graph 미리보기가 모두 새 값을 반영하는지 봅니다. 캐시가 계층별로 다르면 상세 페이지는 바뀌었는데 목록 카드 요약은 예전 내용인 상태가 생길 수 있습니다. 이런 불일치는 SEO 심사와 사용자 신뢰 모두에 좋지 않습니다.
API 캐시에서는 인증 전후를 함께 테스트합니다. 익명 사용자 응답이 로그인 사용자에게 보이거나, 반대로 개인화 응답이 익명 캐시에 들어가면 심각한 사고입니다. 테스트 계정 두 개로 같은 URL을 호출하고 응답 body와 캐시 상태 헤더를 비교하는 자동 점검을 두면 실수를 빨리 찾을 수 있습니다.
마지막으로 캐시 정책은 운영자가 이해할 수 있어야 합니다. 장애 중에는 "이 값이 왜 아직도 예전인가"라는 질문이 자주 나옵니다. 각 URL 패턴별 TTL, purge 방법, 예상 전파 시간, 우회 파라미터 사용 여부를 문서화하면 대응 시간이 줄어듭니다.
8. 캐시 정책은 작은 표로 고정한다
팀에서 캐시 사고가 반복된다면 URL 패턴별 정책표를 만드는 것이 효과적입니다. 예를 들어 /_next/static/* 은 1년 immutable, /posts/* 는 CDN 10분과 stale 1시간, /api/me 는 no-store처럼 명확히 적습니다. 이 표는 문서에만 두지 말고 테스트 기준으로도 사용합니다.
새 라우트가 추가될 때 정책표에 없는 URL이면 리뷰에서 막아야 합니다. 캐시 정책은 기본값에 맡길수록 위험합니다. 특히 프레임워크, CDN, reverse proxy가 각각 기본 헤더를 넣을 수 있으므로 최종 응답 기준으로 확인해야 합니다. 운영 가능한 캐시는 의도한 헤더가 실제 배포 환경에서도 그대로 나가는 상태입니다.
결론: 캐시는 속도가 아니라 신뢰 가능한 재사용이다
좋은 캐시 전략은 무조건 오래 저장하지 않습니다. 오래 저장해도 되는 것, 짧게 저장해야 하는 것, 저장하면 안 되는 것을 구분합니다. HTTP 캐시는 서버 비용을 줄이는 강력한 도구지만, 잘못 쓰면 사용자가 오래된 상태를 보게 하는 장애 원인이 됩니다.
운영 가능한 캐시는 헤더 한 줄이 아니라 배포 방식, URL 설계, CDN 동작, 데이터 일관성 기준이 함께 맞아야 완성됩니다. 응답이 재사용되어도 안전한 시간과 범위를 명확히 정하는 것이 캐시 설계의 시작입니다.