HTTP 캐싱과 CDN

장석원·2026년 9월 27일

이번주 공부의 목표는 Cache-Control 지시자부터 Next의 fetch 캐시, ISR까지 캐시 계층을 하나씩 분리해서 "누가 무엇을 얼마나 오래 저장하는지" 알아가는것이 목표

여기서는 하루 21만 건이던 Supabase REST 직접 호출을 왜 /api/catalog/* + CDN 캐시로 옮기면 방문자 수와 DB 부하가 분리되는지, 숫자로 표현할 수 있음

**ISR: 이거 next 에서 사용하는 전체 사이트를 다시 빌드 하지않고도 SSG 된 페이지의 특정 주기에따라 백그라운드에서 렌더링 하여 업데이트 하는거

Cache-Control 해부

이주전 장애 조치 중 "정적 데이터를 /api/catalog/* + CDN 5분 캐시로 모아 방문자 수와 DB 부하를 분리" 한 결정.
그 전에는 Framer 코드 컴포넌트가 브라우저에서 anon key로 Supabase REST를 직접 호출해서 캐시가 0겹이였음

max-age, s-maxage, stale-while-revalidate, private, no-store은 각각 누구에게 말을 거는 지시자인가??

  • max-age=N: 모든 캐시(브라우저 + CDN)에게 N초 동안은 원 서버에 묻지 않고 그대로 써도 된다.
  • s-maxage=N: shared cache만 (CDN), CDN은 max-age 대신 이 값을 따른다. 브라우저는 무시한다.
  • stale-while-revalidate=N: 캐시(주로 CDN, 브라우저도 지원), 만료 후 N초 동안은 옛 응답을 바로 주고, 뒤에서 새로 받아온다.
  • private: shared cache에게 금지, 브라우저만 저장할 수 있다.
  • public: shared cache에게 허용, 원래는 저장하면 안 되는 경우라도 CDN이 저장해도 된다.
  • no-cache: 모든 캐시, 저장은 해도 되지만, 쓰기전마다 원 서버에 재검증 해야한다.
  • no-store: 모든 캐시, 어디에도 저장하면 안 된다.

max-age vs s-maxage

  • max-age는 브라우저와 CDN이 둘다 읽는다.
  • s-maxage는 shared cache만 읽는다. 둘다 있으면 CDN은 s-maxage를 우선하고, 브라우저는 s-maxage를 무시하고 max-age만 본다.

둘을 나눈 이유는 무효화할 수 있느냐가 다르기 때문이다.
CDN 캐시는 우리가 purge하거나 revalidate 할 수 있다.
사용자 브라우저에 들어간 캐시는 서버가 지울 방법이 사실상 없다.
그래서 CDN에는 길게, 브라우저에는 짧게 라는 조합이 필요하고, 그걸 표현하려면 값이 두개여야한다.

해당 부분은 더 공부 필요할듯 무슨말인지 모르겠음... 추후 수정예정

private vs public

  • private: 이 응답은 특정 사용자용이니 shared cache는 저장하지 마라. 브라우저는 저장해도 된다. 로그인 사용자 정보나 장바구니 같은 응답에 쓴다.
  • public : shared cache에 저장해도 된다고 명시적으로 허락

stale-while-revalidate

"새 데이터를 받아오는 동안 이미 만료된 옛 응답을 보여주는것"
s-maxage=300, stale-while-revalidate=600일 때 흐름

  • 0~300초: fresh 상태, 캐시가 바로 응답한다.
  • 300 ~ 900초: stale 상태지만, 옛 응답을 즉시 돌려주고 동시에 백그라운드 원 서버에 새로 요청해 캐시를 갱신한다.
  • 900초 이후 너무 오래됐으니 원 서버 응답을 기다린다. 이때 느리다.

사용자에게 항상 빠르게 느껴지는 이유는: 갱신비용을 사용자 요청경로에서 백그라운드로 옮겼기 때문.

대가는 신선도. 갱신을 촉발한 사용자 한명은 옛 데이터를 받는다. 900초 이후 첫 요청은 느림

no-store vs no-cache

  • no-store: 저장 자체를 금지. 매번 전체 응답을 새로 받는다
  • no-cache: 저장은 하되, 쓰기전에 매번 원 서버에 아직 유효한지 물어봄. 이거 조건부 요청이다.
    안 바꼈으면 서버가 304 Not Modified를 본문없이 돌려주고, 캐시 씀

no-cache는 왕복은 하지만 본문 비용을 아낀다. no-store은 둘다 안아낌

/api/catalog/* 은 어덯게 할지 최적화 방안생각

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=600
  • s-maxage=300 5분 캐시 결정. 핵심은 DB에 들어오는 요청이 방문자 수와 상관없이 URL당, CDN 엣지당 최대 5분에 1번으로 묶인다는 점. 이것 덕분에 방문자 수와 DB 부하의 분리

  • max-age=0: 브라우저 캐시는 당장 무효화 할 수 없기에, 가격이나 지원금이 변경되었을때 특정사용자만 옛 가격을 보는 일을 막으려고 브라우저는 매번 CDN에 묻게 한다.

  • stale-while-revalidate=600: 사용자가 원 서버를 기다리는 일 을 제거한다.
    최악의 경우 옛 데이터가 보이는 시간은 대략 s-maxage + swr = 15분. 최대 15분 늦게 반영돼도 괜찮은가? 를 생각해야 했고, 괜찮다. 만약 변경을 더 빨리 반영해야 한다면. swr을 줄이거나 변경 시점에 CDN purge 나 on-demand revalidate를 쓰면 된다.

  • stale-if-error=86400. 이것은 원 서버나 DB가 에러를 내도 CDN이 옛 응답으로 버터게 해줌. 장애 조치를 위해 필요하다. 다만 CDN마다 지원 여부가 달라 CDN문서 확인필요

Next.js App Router Route Handler에서 사용법

return Response.json(data, {
  headers: { 'Cache-control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' },
});

CDN 캐시와 브라우저 캐시가 어긋날때.

  • 브라우저가 더 길 때. 예시로 max-age=3600, s-maxage=300 이게되면 가격을 변경하고 CDN을 purge해도, 이미 방문했던 사용자는 최대 1시간동안 브라우저에 남은 옛 가격을 보게됨
  • CDN만 purge 되지 않았을 때.
  • etc) Age헤더는 CDN이 응답하면서 "이 응답이 CDN에서 몇 초 묵었는지" 를 Age에 적는다.
    브라우저는 max-age - Age 만큼만 fresh를 본다. 브라우저 캐시 시간은 브라우저에 도착한 시점이 아닌 원 서버가 응답을 만든 시점부터 계산된다.

진짜 우리의 문제점이였던 것....

Framer -> Supabase REST 직접 호출에는 왜 캐시를 걸 곳이 없었는가.....
1. 응답 헤더를 쓰는 주체가 Supabase 이기 때문에. Cache-Control은 응답 헤더라서 원 서버가 정한다.
원 서버가 Supabase 였으니 우리 쪽에는 헤더를 붙일 코드가 한 줄도 없었다.
2. 중간에 우리가 소유한 계층이 없었다. 브라우저에서 곧바로 xx.supabase.co/rest/v1/...으로 갔기 때문에 CDN을 끼워 넣을 자리가 없었음
3. 요청에 Authorization: Bearer <anon key> 가 붙는다. 중간에 shared cache가 있더라도 public이나 s-maxage없이 저장할 수 없다.
4. 브라우저 캐시조차 명시적인 유효기간이 없으면 거의 쓰이지 안흔ㄴ다.

결과...적

결과적으로 방문자 1명의 페이지 로드 = 쿼리 N개가 그대로 Postgres까지 도달했다.
캐시가 0겹이니 방문자 수와 DB 부하가 1:1 이였고... 터지게 되었다.

/api/catalog/* 를 두면서 응답 헤더의 주인을 변경했고. 그러므로 s-maxage를 쓸 수 있게 된것이다.

0개의 댓글