TechFeedTechFeed
Frontend

캐시 컨트롤, s-maxage, 스테일 리밸리데이트, CDN | 배포 직후 옛 페이지가 남는 이유는?

배포 직후 옛 HTML이 남으면 브라우저 max-age부터 본다. 캐시 컨트롤, s-maxage, 스테일 리밸리데이트, public과 private, no-cache와 no-store, Vary, 해시 파일, 버셀 CDN을 프론트엔드, Next.js, 개발자, API, 클라우드 기준으로 같은 표에 둔다. 넥스트 fetch 재검증 글과 쿠키 글과 자리를 섞지 않는다.

by

배포 버튼을 눌렀는데 옛 글이 남습니다. 캐시 컨트롤 한 줄을 브라우저와 CDN에 나눠 쓰지 않은 경우가 많습니다. 로그인 HTML에 public을 붙이면 남의 이름이 뜹니다. no-cache는 저장 금지가 아닙니다. 쓰기 전에 물어보라는 뜻입니다. 해시 있는 JS는 1년을 둬도 됩니다. HTML은 브라우저 나이를 0으로 두고, 공유 최대 나이만 CDN에 줍니다. 저는 버셀에서 응답 헤더를 연 뒤에야 배포 직후 옛 페이지가 남는 자리를 고쳤습니다.


넥스트 fetch 재검증은 프로덕션 체크리스트에 두고, 여기는 응답 헤더만 봅니다. 버셀 기본값은 public, max-age=0, must-revalidate입니다. 숫자는 2026년 8월 MDN 캐시 컨트롤과 버셀 캐시 문서 기준입니다.


배포했는데 옛 HTML이 남는 자리

브라우저 max-age가 남아 있으면 새 배포를 안 받아 옵니다. CDN은 배포 때 비워져도, 폰은 옛 헤더를 지킵니다.


12사이트 넥스트를 버셀에 올리며 반복한 실수는 이렇습니다. 글 목록 HTML에 max-age=3600을 붙였습니다. 엣지는 한 시간 동안 함수를 안 탑니다. 문제는 배포 직후였습니다. 제가 시크릿 창에서 보면 새 글이 있고, 출근길 사파리에서는 한 시간 전 목록이 남았습니다. 버셀 대시보드는 새 배포였습니다. 폰이 헤더를 지킨 겁니다.


버셀 문서는 방문자가 같은 화면을 보는 서버 렌더에 max-age=0, s-maxage=86400을 출발점으로 적습니다. CDN은 하루를 들고, 브라우저는 매번 물어봅니다. 배포가 나가면 엣지 칸은 비워집니다. 브라우저 칸은 헤더가 정한 초만큼 남습니다. 그래서 HTML에 긴 max-age를 붙이면, 배포와 무관하게 옛 마크업이 남습니다.


어디누가 보나배포하면한 줄로
브라우저max-age, private안 비움. 초가 끝나야 물어봄HTML은 0초 또는 1~2분
CDNs-maxage, 공유 캐시버셀은 배포 때 비움같은 화면이면 길게 둬도 됨
런타임 캐시넥스트 fetch 강제 캐시태그 무효화와 별개헤더 글과 자리를 섞지 않음
이미지 캐시넥스트 이미지 변환본변환 결과가 남음주소 가드는 SSRF 글

카나리로 트래픽을 나누는 자리는 카나리 장애 글에 있습니다. 여기는 헤더 초가 배포보다 길면, 새 바이너리가 있어도 옛 HTML이 먼저라는 점만 남깁니다.


응답에 캐시 컨트롤이 어떻게 찍히는지
curl -sI https://example.com/ | grep -iE 'cache-control|age|x-vercel-cache|cf-cache'

개발자 도구가 속인다 | 크롬 네트워크 창을 연 채 새로고침하면 Pragma: no-cache가 붙습니다. 버셀은 그때 백그라운드가 아니라 동기 재검증을 합니다. x-vercel-cacheREVALIDATED로 보입니다. 창을 닫고 시크릿으로 한 번 더 봅니다.


no-cache는 캐시를 끈다는 뜻이 아니다

저장은 됩니다. 쓰기 전에 원본에 물어봅니다. 끄려면 no-store입니다.


MDN은 no-cache를 이렇게 적습니다. 저장된 응답을 쓰기 전에 원본과 재검증해야 한다. 디스크에 안 남기라는 뜻이 아닙니다. no-store가 그 뜻입니다. 비밀번호 재설정, 결제 영수증, 관리자 JSON은 no-store입니다. 공개 글 HTML에 no-cache를 붙이면, CDN과 브라우저가 복사본을 들고 매 요청마다 조건부 GET을 보냅니다. ETag가 같으면 304가 옵니다.


지시저장쓰기 전어디에 쓰나
no-store안 함해당 없음개인 정보, 실시간 숫자
no-cache반드시 재검증자주 바뀌는 공개 페이지
must-revalidate신선할 때는 그대로, 상하면 물어봄버셀 기본값에 포함
immutable신선 기간 동안 안 물어봄해시 있는 JS, CSS, 폰트

버셀 기본값 public, max-age=0, must-revalidate는 CDN과 브라우저 둘 다 신선 기간이 0입니다. 매 요청이 원본을 봅니다. 함수 비용이 바로 늘어납니다. 공개 글이면 기본값을 그대로 두지 말고, 공유 최대 나이를 따로 줍니다. 헤더 이름은 MDN 캐시 컨트롤이 정본입니다.


no-store와 no-cache, must-revalidate, immutable 네 지시의 차이
no-cache는 저장 뒤 재검증이다. 저장을 끄려면 no-store를 쓴다

브라우저 나이와 CDN 나이는 같은 칸이 아니다

max-age는 브라우저, s-maxage는 공유 캐시입니다. 버셀은 s-maxage를 먹고 클라이언트에는 안 넘깁니다.


한 줄에 두 숫자를 같이 적습니다. 앞은 폰, 뒤는 엣지입니다. 버셀은 서버 렌더 같은 화면에 max-age=0, s-maxage=86400을 권합니다. 반쯤 정적 글은 브라우저를 60~120초로 열어 엣지 요청을 줄입니다. 그 120초 안에는 배포가 나가도 폰이 옛 HTML을 보여 줍니다. 저는 글 상세는 브라우저 0초, 마케팅 랜딩만 120초를 줍니다.


내용버셀이 적은 헤더언제
서버 렌더, 방문자 동일max-age=0, s-maxage=86400목록, 공개 글, 문서
반쯤 정적max-age=120, s-maxage=86400마케팅, 제품 소개. 2분 지연을 감수
개인 화면private, max-age=0쿠키, 세션, 권한
해시 정적 파일max-age=31536000, immutable넥스트가 빌드 해시 파일에 자동

스테일 와일 리밸리데이트는 신선 시간이 끝난 뒤에도 옛 응답을 주고, 뒤에서 원본을 다시 받습니다. 버셀 예시는 s-maxage=1, stale-while-revalidate=59입니다. 1초 뒤 요청은 옛 값을 받고, 함수는 비동기로 돕니다. 버셀 프록시는 이 지시와 s-maxage를 소비한 뒤 브라우저로 넘기지 않습니다. 배포 직후 폰이 옛 HTML을 붙잡는 일을 줄이려는 동작입니다. 정본은 버셀 캐시 컨트롤 문서입니다.


헤더가 세 종류로 갈리면 우선순위가 있습니다. Vercel-CDN-Cache-Control이 버셀만, CDN-Cache-Control이 중간 CDN, Cache-Control이 마지막입니다. 함수가 돌려준 헤더가 next.config.jsvercel.json보다 앞섭니다. 클라우드플레어를 앞에 두면 중간 헤더를 따로 적습니다.


넥스트 라우트에서 세 헤더를 나누는 예
export async function GET() { return new Response('ok', { status: 200, headers: { 'Cache-Control': 'max-age=0', 'CDN-Cache-Control': 'max-age=60', 'Vercel-CDN-Cache-Control': 'max-age=3600', }, }) }

로그인 HTML을 public으로 두면

CDN이 한 사람 화면을 다른 사람에게 줍니다. 개인 응답은 private 또는 no-store입니다.


버셀은 쿠키, 세션, 권한으로 갈리는 응답에 private, max-age=0을 적습니다. private은 공유 캐시에 넣지 말라는 뜻입니다. 브라우저 칸만 허용합니다. 대시보드 HTML, 파트너십 문의 뒤 완료 화면, 결제 직후 페이지가 여기입니다. 공개 글과 같은 레이아웃을 쓴다고 같은 헤더를 복사하면 안 됩니다.


제가 초기에 실수한 자리는 미들웨어에서 로그인 여부를 보고 HTML을 갈라 놓고, 헤더는 공개 글과 같이 둔 경우입니다. 첫 방문자가 로그인한 채로 그 경로를 열면, 다음 사람은 그 이름을 볼 수 있습니다. Vary: Cookie를 붙여 막을 수는 있습니다. 세션마다 칸이 생겨 CDN이 사실상 꺼집니다. 경로는 /account처럼 나누고, 그 경로만 private을 고정합니다.


쿠키 SameSite는 사파리 로그인 글에 있습니다. CSP와 HSTS는 보안 헤더 체크에 있습니다. 여기는 캐시 칸에 개인 HTML이 들어가면, 헤더 검사보다 먼저 화면이 샌다는 점만 봅니다. 시크릿 키 유출은 환경변수 체크입니다.


공개 HTML과 로그인 HTML에 붙이는 캐시 컨트롤을 나눈 표
같은 레이아웃이어도 로그인 경로는 private이다. public은 방문자 모두가 같은 화면일 때만

Vary를 넓히면 키가 폭발한다

Cookie를 Vary에 넣으면 세션마다 칸이 생깁니다. 로그인과 공개를 경로로 나눕니다.


CDN은 URL만 보지 않습니다. Vary에 적힌 요청 헤더 값이 다르면 다른 칸입니다. Accept-Encoding은 압축 때문에 거의 기본입니다. Accept-Language를 넣으면 언어마다 칸이 늘어납니다. 한국어만 쓰는 사이트면 언어 헤더로 키를 나누지 않습니다. 한국어 HTML을 고정하고, 영문이 필요하면 경로를 나눕니다.


버셀 문서는 방문자마다 다른 응답에 s-maxage를 쓰지 말라고 적습니다. 쓰려면 Vary를 맞춰야 합니다. 맞추더라도 쿠키 값은 사실상 무한입니다. 공개 목록은 Vary를 최소로 두고, 개인 화면은 캐시를 포기하는 편이 안전합니다. 모바일과 데스크톱 마크업을 헤더로 가르면 같은 사고가 납니다. 반응형 한 장이 캐시에도 맞습니다.


한국 접속은 네이버 인앱, 카카오톡 인앱, 사파리가 섞입니다. 인앱은 자체 웹뷰 캐시를 따로 둡니다. 헤더를 맞춰도 인앱이 더 오래 들고 있는 달이 있습니다. 배포 직후 제보가 카카오톡 미리보기면, 먼저 외부 사파리와 크롬으로 가릅니다. 인앱만 남으면 헤더가 아니라 웹뷰 칸입니다.


퍼지보다 파일명 해시를 먼저

해시 있는 JS와 CSS는 1년 immutable입니다. HTML만 짧게 두면 퍼지를 매일 안 눌러도 됩니다.


넥스트 빌드는 main.a1b2c3.js처럼 내용 해시를 붙입니다. 파일이 바뀌면 주소가 바뀝니다. 옛 주소는 그대로 1년을 살아도 됩니다. 새 HTML이 새 주소를 가리키면 끝입니다. 퍼지가 필요한 자리는 주소가 안 바뀌는 파일입니다. 로고 PNG, og-default.png, 해시 없는 CSS가 여기입니다. 이름을 바꾸거나 쿼리를 붙입니다.


버셀은 배포마다 CDN 칸을 비웁니다. 클라우드플레어를 앞에 두면 그 칸은 따로입니다. 페이지 규칙으로 HTML만 짧게 두고, 정적 경로는 해시 정책을 유지합니다. 전체 퍼지는 마지막입니다. 퍼지 한 번에 전 세계 히트가 사라지면, 그 시각 함수와 원본이 같이 맞습니다. 글 하나 고친 날에는 그 경로만 지웁니다.


스테일 이프 에러는 원본이 500이거나 DNS가 죽을 때 옛 응답을 줍니다. 버셀 예시는 7일 신선, 이후 하루 더 옛 값을 허용합니다. 장애 때 빈 화면보다 옛 글이 낫습니다. 결제 확인처럼 틀린 옛 값이 위험한 경로는 이 지시를 빼니다. 캐시 층 안내는 버셀 캐싱 개요에 있습니다.


공개 글과 개인 경로를 헤더로 나누는 예
// 공개 글 HTML. 브라우저는 매번, CDN은 하루 Cache-Control: public, max-age=0, s-maxage=86400 // 로그인 뒤 화면. CDN에 넣지 않음 Cache-Control: private, no-store // 빌드 해시 정적 파일. 넥스트 기본 Cache-Control: public, max-age=31536000, immutable
해시 파일은 1년, HTML은 짧게, 개인 경로는 저장 금지인 순서
주소가 바뀌는 파일은 퍼지 없이 오래 둔다. 주소가 같은 HTML만 짧게 잡는다

버셀에 올린 1인 팀이 먼저 볼 헤더

공개 글은 브라우저 0초, CDN 하루입니다. 계정 경로는 private, no-store입니다. 배포 뒤에는 시크릿 창과 평소 사파리를 같이 엽니다.


저는 새 글을 올린 뒤 curl -sI로 캐시 컨트롤과 x-vercel-cache를 남깁니다. HIT이면 엣지, MISS면 원본, STALE이면 뒤에서 다시 받는 중입니다. 개발자 도구를 연 채 재면 숫자가 달라집니다. 창을 닫고 봅니다. 네이버와 카카오 인앱 제보가 오면 같은 URL을 외부 브라우저로 먼저 재현합니다.


부가세와 함수 비용은 헤더가 아니라 호출 횟수입니다. 공개 글을 매번 원본에서 받으면 엣지 요청과 서버 시간이 같이 올라갑니다. 공개 목록에 공유 최대 나이를 하루 주면, 같은 글은 함수를 안 탑니다. 개인 API는 반대로 캐시를 끄고, 응답에 토큰을 넣지 않습니다. 관측은 센트리 글에 에러율을 두고, 여기는 헤더만 점검합니다. 배포 당일 제보가 오면 헤더부터 보고, 본문 오타는 그다음입니다.


참고 자료


헤더 숫자는 호스트가 수시로 바꿉니다. 위 표는 2026년 8월 공개 페이지 기준 점검용이며, 최종 근거는 각 문서와 실제 응답 헤더입니다.


자주 묻는 질문

배포했는데 옛 글이 보이면 어디부터 보나?

시크릿 창과 평소 브라우저를 같이 엽니다. 시크릿만 새 글이면 브라우저 max-age입니다. 둘 다 옛 글이면 CDN이거나 앞단 클라우드플레어입니다. curl -sI로 캐시 컨트롤과 x-vercel-cache, cf-cache-status를 봅니다. HTML에 긴 브라우저 나이가 있으면 그 줄을 0으로 내립니다.


no-cache를 붙이면 캐시가 꺼지나?

꺼지지 않습니다. 복사본을 들고 쓰기 전에 원본에 물어봅니다. 끄려면 no-store입니다. 결제 영수증과 비밀번호 메일은 저장 금지입니다. 공개 글에 no-store를 붙이면 엣지가 매 요청마다 함수를 탑니다. 공개는 공유 최대 나이를 주고, 개인만 저장을 끕니다.


s-maxage만 넣으면 브라우저도 하루를 드나?

버셀은 s-maxage를 프록시에서 소비하고 브라우저로 넘기지 않습니다. 클라이언트에는 max-age만 남거나 기본값에 가깝습니다. 브라우저까지 하루를 주고 싶으면 max-age를 따로 적습니다. 배포 직후 옛 페이지를 줄이려면 브라우저는 0초가 맞습니다.


로그인 페이지에 공유 최대 나이를 줘도 되나?

방문자마다 화면이 다르면 안 됩니다. private, no-store가 기본입니다. Vary: Cookie로 칸을 나누면 세션 수만큼 키가 생깁니다. 경로를 /account로 나누는 편이 낫습니다. 공개 글 레이아웃을 복사해도 헤더는 복사하지 않습니다.


스테일 와일 리밸리데이트는 꼭 넣나?

필수는 아닙니다. 원본이 느린 공개 글에 넣으면 옛 값을 주는 동안 뒤에서 갱신합니다. 버셀은 이 지시를 브라우저로 넘기지 않습니다. 결제처럼 틀린 옛 값이 위험한 경로는 빼니다. 넣기 전에 신선 초와 스테일 초를 표로 적어 둡니다.


넥스트 fetch 재검증이랑 같은 설정인가?

다릅니다. fetch 재검증과 ISR은 함수 안 데이터 칸입니다. 캐시 컨트롤은 브라우저와 CDN이 읽는 응답 헤더입니다. 프로덕션 체크리스트에 데이터 칸이 있고, 여기는 헤더만 봅니다. 둘 다 맞춰야 배포 직후 화면이 맞습니다.


캐시 컨트롤s-maxage스테일 리밸리데이트CDNCache-Control프론트엔드Next.js버셀개발자API클라우드HTTP

함께 보면 좋은 문제 해결

EXPLORE / Frontend

이어서 읽어보기

전체 토픽 둘러보기