이 글은 누구를 위한 것인가
- Cache-Control을 붙였는데도 사용자마다 다른 화면이 나오는 프론트엔드·백엔드
nextjs-app-router-caching-revalidate-patterns와 함께 HTTP 레이어까지 맞추고 싶은 팀- API와 정적 자산의 캐시 전략을 표로 합의하려는 테크 리드
들어가며
웹 성능에서 캐시는 가장 싸고 강한 최적화다. 동시에 가장 잘못 이해되는 레이어다. 브라우저만 캐시하는 줄 알았는데 CDN이 끼었고, max-age=0인데도 304가 나오고, private과 s-maxage의 차이가 헷갈린다.
이 글은 RFC 전체가 아니라 역할 분리와 팀이 같은 그림을 그리기 위한 결정표다.
1. 네 종류의 "캐시 주체"
| 주체 | 전형적 위치 | 강점 | 주의 |
|---|---|---|---|
| 브라우저(private) | 사용자 기기 | 개인화·오프라인 근처 | 탭·시크릿마다 다름 |
| 공유 캐시(CDN/proxy) | POP, 리버스 프록시 | 지연·원본 부하 감소 | 개인 데이터 혼입 시 사고 |
| 원본 서버 | 앱 서버 | 권한·비즈니스 로직 | CPU·DB 부담 |
| 엣지 로직 | edge-functions-middleware-patterns 참고 | 지역 라우팅·A/B | 캐시와 결합 시 무효화 복잡 |
한 응답이 이 네 겹을 순서대로 통과할 수 있다. 디버깅은 Age, CF-Cache-Status, x-cache 등 응답 헤더로 어느 층에서 왔는지 추적하는 것이 빠르다.
2. Cache-Control 핵심만
public: 공유 캐시가 저장 가능(개인정보 응답에는 금지).private: 브라우저만; 팀 대시보드·개인화 API에 적합.max-age: 신선도 기간(초). 만료 후에는 재검증으로 이어짐.s-maxage: 공유 캐시 한정 max-age.private과 조합 주의.no-store: 아무 데도 저장하지 않음. 금융·건강 등.must-revalidate: 신선도 만료 후 오래된 것 그대로 주면 안 됨(가능하면).stale-while-revalidate: 만료 직후 짧은 기간 오래된 것 먼저 주고 백그라운드 갱신. UX에 유리.
**immutable**은 파일명에 해시가 붙은 버전드 정적 자산에 쓴다. 롱 max-age와 함께 쓰면 브라우저가 불필요한 재검증을 줄인다.
3. 무엇을 어디에 두는가 (결정 힌트)
| 리소스 유형 | 브라우저 | CDN | 코멘트 |
|---|---|---|---|
| 번들 JS/CSS (해시 파일명) | 긴 max-age + immutable | 동일 | 배포마다 URL이 바뀌므로 안전 |
| HTML 문서 | 짧게 또는 no-cache | 상황별 | SPA 인덱스는 오래 캐시하면 안 됨 |
| JSON API(공개) | 짧거나 private | s-maxage | 개인화 없을 때만 |
| JSON API(개인) | private, no-store 등 | 보통 비활성 | 공유 캐시 금지 |
| 이미지·폰트 | 길게 | 길게 | CDN 친화 |
4. 재검증: ETag와 Last-Modified
max-age가 지나도 바로 네트워크로 새 파일을 받는 게 아니라, 조건부 요청(If-None-Match / If-Modified-Since)으로 304를 받을 수 있다. API는 ETag를 안정적으로 만들기 어려울 수 있어, 팀 내에서 해시 vs 버전 컬럼 정책을 정한다.
5. Next.js·풀스택과의 정렬
App Router의 revalidate, fetch 캐시 옵션은 프레임워크 레이어다. 그 위에 CDN이 HTTP 헤더를 어떻게 해석하는지가 얹힌다. 프로덕션에서는 원본이 보내는 Cache-Control과 엣지에서 덮어쓰기 규칙을 한 번에 점검한다.
core-web-vitals-seo-performance-guide에서 다룬 LCP 자원도 캐시 히트율이 곧 반복 방문 체감 속도다.
6. 무효화 전략
- 버전 URL(권장): 에셋은 파일명 해시로 롤링.
- 태그·퍼지: API 응답이 변할 때 CDN 퍼지 — 비용·전파 지연이 있다.
- 짧은 TTL + SWR: 정적 퍼지 대신 짧은 신선도 + 백그라운드 갱신.
7. Vary·Cookie·개인화: 캐시를 망가뜨리는 조합
응답이 Set-Cookie로 세션을 심거나, Accept-Language에 따라 HTML이 달라지면, 공유 캐시는 같은 URL에 다른 바디를 저장해 다른 사용자에게 섞일 위험이 있다. 그래서 많은 사이트가 HTML에 private, no-store 또는 캐시 비활성을 건다.
Vary: Accept-Encoding은 일반적이고, 잘못된 Vary 조합은 캐시 적중률을 0에 가깝게 만든다. 디버깅할 때는 응답 헤더의 **Vary**와 요청 헤더를 나란히 본다. CDN 벤더 문서에 어떤 헤더를 캐시 키에 포함하는지(정규화 규칙 포함)가 정리되어 있는 경우가 많다.
8. Age 헤더: “얼마나 오래됐나”
Age는 프록시·CDN에 머문 시간(초)을 나타낸다. max-age와 함께 보면 남은 신선도를 추정할 수 있다. Age가 max-age에 근접하면 곧 재검증이 일어날 타이밍이다.
원본이 Cache-Control을 잘못 보냈는데 CDN이 상한을 덮어쓰기하는 설정이 있으면, 개발자 도구에서 보이는 값과 실제 엣지 동작이 달라 혼란이 생긴다. “정책은 인프라 코드에, 문서에도” 남긴다.
9. GraphQL, GET, 그리고 HTTP 캐시
GraphQL을 GET 쿼리로 보내는 구성은 캐시 친화적일 수 있지만, 쿼리 문자열 폭발로 캐시 키가 비대해지고 URL 길이 제한에 걸릴 수 있다. POST 기반이면 기본 HTTP 캐시에서 이점이 적다는 전제가 있으므로, **APQ( persisted queries)**나 서버 측 캐시 레이어를 따로 논의한다.
10. Service Worker와 HTTP 캐시
브라우저의 HTTP 캐시와 Cache API(서비스 워커)는 별개다. PWA에서 오프라인 에셋을 다룰 때 두 층이 충돌하면 “왜 새 배포가 안 보이지?” 이슈가 난다. 배포 시에는 캐시 버스팅·스킵웨이팅 전략을 SW 코드에 명시한다(pwa-2026-practical-guide 맥락과 연결).
11. 디버깅 체크리스트 (현장용)
- 같은 URL, 시크릿 vs 일반 탭 —
Set-Cookie유무 차이 curl -I와 브라우저 — 클라이언트가 보내는Cache-Control요청 지시자 차이- CDN 대시보드 — Rule이
Cache-Control을 덮었는지 - 퍼지 직후에도 오래된 바디 — POP 전파 지연·로컬 SW
- API는 200인데 데이터만 옛것 — 앱 레이어 캐시(React Query 등)와 혼동하지 않기
12. 멀티 테넌시·B2B: 테넌트별 캐시 키
SaaS에서 경로만 같고 테넌트 헤더로 분기하면, 엣지에서 잘못된 캐시 키가 테넌트 간 누출을 일으킬 수 있다. 반드시:
- 인증된 개인 데이터는 공유 캐시에 넣지 않거나
- Edge에서 인증 검증 후 캐시 키에 테넌트 ID를 포함하는 명시적 규칙을 둔다
“공개 마케팅 페이지만 CDN”처럼 데이터 등급으로 레이어를 나누면 사고 표면이 줄어든다.
13. stale-while-revalidate 운영 시 주의점
SWR은 사용자에게 즉각적인 화면을 주지만, 최신이 아닌 데이터를 잠깐 보여 줄 수 있다. 주가·재고·자리 예약처럼 강한 신선도가 필요한 데이터에는 맞지 않을 수 있다. 반대로 뉴스·블로그 본문·카탈로그 정적 설명처럼 몇 초의 지연이 허용되는 리소스에는 UX 이점이 크다.
CDN마다 stale-while-revalidate 해석이 조금씩 다를 수 있어, 해당 벤더 문서를 확인하고 스테이징에서 헤더 전체를 캡처해 검증한다.
14. no-cache와 no-store를 혼동하지 않기
많은 개발자가 **no-cache를 “캐시하면 안 된다”**로 오해한다. 일반적으로 no-cache는 저장은 가능하나 사용 전 재검증을 강하게 요구하는 쪽에 가깝다(브라우저·프록시 해석 차이는 존재). 저장 자체를 금지하는 데 가까운 의도라면 no-store를 검토한다.
팀 위키에 우리가 쓰는 값과 의도한 동작을 표로 적어 두면, 온콜 중 헤더 한 줄 수정으로 장애를 푸는 시간이 줄어든다.
15. 원본 보호: stale-if-error·헬스 체크
일부 인프라는 원본이 5xx일 때 오래된 캐시라도 제공하는 확장(Cache Control 확장 또는 CDN 기능)을 지원한다. 운영 편의는 크지만, 치명적으로 틀린 데이터(예: 잘못된 가격)가 캐시돼 있으면 복구가 어렵다. 반복 배포·데이터 정정 전략과 함께 캐시 TTL·퍼지 연동을 설계한다.
16. 로컬 개발과 프로덕션의 괴리
개발자 머신에서는 브라우저 확장, 기업 프록시, 로컬 HTTPS 인증서 때문에 Cache-Control이 프로덕션과 다르게 보일 수 있다. 문제 재현 시 시크릿 창, 확장 비활성화, **curl -v**로 분기한다. Next.js next dev는 프로덕션과 헤더·압축·스트리밍 동작이 다를 수 있으므로, 캐시 이슈는 가능하면 next build && next start 또는 스테이징에서 재확인한다.
맺음말
캐시는 "설정"이 아니라 제품 요구사항이다. 개인 데이터는 private·no-store로 막고, 공개 문서는 s-maxage로 싸고, 자산은 해시와 immutable로 끝낸다. 팀에 결정표 한 장만 있어도 인시던트가 줄어든다.