308 은 정말 되돌릴 수 없을까 — 명세와 실제 헤더가 다를 때
HTTP 307 vs 308, heuristic caching, Cache-Control no-store
난이도: ★★★☆☆
연관 노트: 막을 거면 차리기 전에 막아라
핵심 요약
- 308 은 명세상 캐시 헤더가 없으면 브라우저가 알아서 캐시해도 되는 응답이다(휴리스틱 캐시). 크롬은 사실상 영구히 기억한다. 그래서 308 을 한 번 겪은 사용자는 서버에서 리다이렉트를 지워도 요청이 서버에 오지 않고 브라우저 안에서 계속 튕긴다. 튕기는 사람과 안 튕기는 사람은 “규칙이 살아 있을 때 방문했나, 그 뒤 캐시를 지웠나” 로 갈린다.
- 하지만 응답에
Cache-Control: no-store가 붙어 있으면 308 도 307 처럼 매번 서버에 묻는다. 명시적 헤더가 휴리스틱보다 우선한다. 짧은max-age를 줘도 그 시간만큼만 기억한다. - Next.js 의
getServerSideProps응답에는 Next 가 상태코드와 무관하게no-store를 붙인다. 그래서 gSSP 의permanent: true는 캐시 문제가 없다. 위험한 건 헤더를 안 붙이는next.configredirects · CDN · 웹 서버 규칙 쪽이다. - 그래도 307/308 을 고르는 기준은 캐시가 아니라 의미다. 308 은 “이 URL 은 영원히 저쪽” 이라 검색엔진이 색인을 옮긴다. 다시 열 수 있는 페이지면 307 이다.
왜 헷갈렸나
- “308 = 무조건 캐시”로 외웠다. 명세의 “휴리스틱 캐시 가능” 을 “강제 캐싱” 으로 기억했다. 그래서 gSSP 의 308 도 캐시될 거라고 1번 논리를 그대로 가져왔다. 조건(캐시 헤더 유무)이 다르다는 걸 놓쳤다.
no-store가 307 이라서 붙은 줄 알았다. curl 로 본 응답이 307 이었으니 “307 은 캐시하지 말라는 헤더가 들어 있구나” 로 읽었다. 실제로는 gSSP 라서 붙은 것이다. 같은 gSSP 에서 308 을 내도 똑같이 붙는다(아래 실측).- “서버가 상태코드를 보고 캐시 헤더를 정한다”고 생각했다. 그런 자동 연결은 없다. 상태코드는 의미(임시/영구)와 휴리스틱 기본값을 정하고, 캐시 헤더는 그 응답을 만든 쪽(프레임워크·CDN·웹 서버 설정)이 따로 정한다.
- “gSSP 면 308 도 괜찮다” 와 “308 이 옳다” 를 섞을 뻔했다. 캐시만 보면 gSSP 의 308 은 문제가 없다. 하지만 그 안전은 “gSSP 에서 나가고, 아무도 Cache-Control 을 덮어쓰지 않았다” 는 코드에 안 보이는 조건에 기댄다.
no-store는 gSSP 의 기본값이라res.setHeader로 바뀔 수 있고, 리다이렉트를 config·미들웨어·CDN 으로 옮기면 사라진다. 307 은 어디서 나가든 안전하다. “문제가 없었다” 와 “옳은 선택이었다” 는 다른 질문이다. - 이 오해는 사실 글쓴이(AI)도 먼저 저질렀다. 코드 주석에 “308 은 브라우저가 캐시해서 되돌릴 수 없다” 라고 썼다가, 응답 헤더를 한 줄 찍어보고 이 스택에서는 과장이었다고 정정했다.
메커니즘
비유: 이사 안내문
📮 307 — 문에 붙인 "잠시 3층으로 옮겼어요" 쪽지
올 때마다 쪽지를 읽는다. 쪽지를 떼면 다음 방문부터 원래 방으로 들어간다.
📒 308 — "영구 이전했습니다" 안내를 받고 내 주소록을 고쳐 적음
다음부터는 원래 주소로 가지도 않는다. 주소록을 보고 바로 새 주소로 간다.
원래 방이 다시 열려도 나는 모른다 — 주소록을 지우기 전까지.
🚫 308 + no-store — "영구 이전" 이라고 써 있지만 "이 안내는 적어두지 마세요"
주소록에 안 적으니 매번 원래 주소로 와서 안내를 다시 읽는다 → 307 과 같은 행동.
휴리스틱 캐시란
캐시가 “이 응답을 얼마나 오래 써도 되나” 를 정하는 우선순위:
1. 명시적 헤더가 있나?
├─ Cache-Control: no-store → 저장 자체를 안 함
├─ Cache-Control: no-cache → 저장은 하되, 쓸 때마다 서버에 재검증
├─ Cache-Control: max-age=N → N 초 동안 그냥 씀
└─ Expires: <날짜> → 그 날짜까지 그냥 씀
│
│ 아무것도 없으면
▼
2. 휴리스틱(heuristic) = 캐시가 스스로 "추정" 한다
├─ 명세가 "기본적으로 캐시 가능" 이라고 한 상태코드만 해당
│ 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 ...
│ (302, 307 은 목록에 없음 → 헤더 없으면 캐시 안 함)
└─ 얼마나 오래? 명세는 정하지 않음 — 브라우저 재량
· 일반 리소스: Last-Modified 로부터 지난 시간의 10% 같은 식
· 301/308: 크롬·파이어폭스 모두 사실상 무기한
휴리스틱 = “서버가 말을 안 해줬으니 캐시가 알아서 어림잡는다”. 308 이 위험한 건 상태코드 자체 때문이 아니라, 헤더를 안 준 308 이 이 어림잡기에서 “영원히” 로 떨어지기 때문이다.
no-store vs no-cache
| 저장 | 다음 요청 때 | |
|---|---|---|
no-store | 안 함 | 무조건 서버로 새 요청 |
no-cache | 함 | 쓰기 전에 서버에 “아직 유효해?” 재검증 (ETag/Last-Modified) |
max-age=0, must-revalidate | 함 | 즉시 만료로 간주 → 재검증 |
🧊 no-store = "냉장고에 넣지 마"
→ 저장 안 함. 필요하면 무조건 서버에 새로 요청
🧊 no-cache = "넣어도 되는데, 먹기 전에 매번 엄마한테 먹어도 되는지 물어봐"
→ 저장함. 쓰기 전 서버에 "아직 유효해?" → 304 Not Modified 면 저장본 사용
이름과 달리 no-cache 는 “캐시하지 마” 가 아니라 “확인 없이 쓰지 마” 다. 리다이렉트를 캐시에 절대 안 남기려면 no-store 가 정답이다.
실측: 같은 gSSP 에서 307 과 308
$ curl -sI http://localhost:3000/closed/ # permanent: false
HTTP/1.1 307 Temporary Redirect
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
location: /landing/
$ curl -sI http://localhost:3000/tmp-308/ # permanent: true
HTTP/1.1 308 Permanent Redirect
cache-control: private, no-cache, no-store, max-age=0, must-revalidate ← 똑같다
location: /
같은 앱의 next.config.js redirects(permanent: true) 는:
$ curl -sI http://localhost:3107/old-path/ # next.config redirects
HTTP/1.1 308 Permanent Redirect
location: /
← cache-control 이 없다
cache-control 은 상태코드가 아니라 gSSP 라서 붙었다. 헤더는 상태코드가 정하는 게 아니라 그 응답을 만든 쪽이 붙인다. gSSP 는 “요청마다 새로 계산하는 페이지” 라 Next 가 결과가 매번 다를 수 있다고 보고 200·307·308 가리지 않고 캐시 금지 헤더를 기본값으로 박는다. config redirects 는 페이지 코드를 안 타는 라우팅 규칙이라 그런 기본값이 없다.
기본값이니 덮어쓸 수 있다. gSSP 안에서
res.setHeader("Cache-Control", "s-maxage=60")처럼 직접 쓰면 Next 는 기본값을 붙이지 않는다(SSR 결과를 CDN 에 캐시할 때 흔히 쓰는 방법).
그래서 위험은 “어디서 거느냐” 에 있다
캐시 헤더 308 을 지운 뒤
───────── ─────────────────────────
gSSP redirect no-store (Next) 다음 요청부터 바로 풀림 ✅
next.config redirects 없음 (기본값) 방문자 브라우저에 남음 ⚠️
CDN / Nginx 규칙 설정 나름 헤더 안 줬으면 남음 ⚠️
307 vs 308: 캐시 말고 뭐가 다른가
| 307 | 308 | (참고) 302 / 301 | |
|---|---|---|---|
| 의미 | 임시 이동 | 영구 이동 | 임시 / 영구 |
| 헤더 없을 때 캐시 | 안 함 | 휴리스틱 → 사실상 무기한 | 302 안 함 / 301 무기한 |
| 메서드 보존 | ✅ POST 는 POST 로 | ✅ | ❌ 역사적으로 GET 으로 바뀌기도 함 |
| 검색엔진 | 원래 URL 색인 유지 | 색인·신호를 목적지로 이전 | 302 유지 / 301 이전 |
즉 “동작은 같고 캐시만 다르다” 도 반만 맞다. 브라우저 이동 동작(메서드 보존, Location 따라감)은 같지만, 의미가 다르고 그 의미 때문에 검색엔진과 휴리스틱 캐시가 다르게 대한다.
해결 패턴
// ✅ 다시 열 수도 있는 차단 → 307
export const getServerSideProps = async () => ({
redirect: { destination: "/landing/", permanent: false },
});
// ⚠️ next.config 에서 영구 리다이렉트를 걸 거라면, 정말 영구인지 먼저 묻는다
module.exports = {
async redirects() {
return [
// permanent: true → 308, 캐시 헤더 없음 → 방문자 브라우저에 남는다
{ source: "/old-promo", destination: "/", permanent: false },
];
},
};
이미 헤더 없는 308 을 내보냈고 되돌려야 한다면: 서버에서 할 수 있는 건 없다. 요청이 안 오기 때문이다. 목적지 쪽에서 원래 URL 로 링크를 다시 걸어두는 정도가 최선이고, 결국 사용자가 캐시를 지워야 풀린다. 그래서 308 은 내보내기 전에 결정하는 것이다.
다음에 이 상황을 만나면
영구 리다이렉트를 걸려고 할 때 → 상태코드보다 먼저 “이 URL 을 다시 쓸 일이 절대 없나?” 를 묻는다.
- 되살릴 가능성이 조금이라도 있나 → 307
- 리다이렉트를 어디서 거나 (gSSP / config / CDN / 웹 서버) → 거기서 캐시 헤더가 붙나?
-
curl -sI로 실제 응답 헤더를 찍어봤나 — 명세로 추론하지 말고 확인 - 검색 색인을 목적지로 옮기는 게 의도인가 (도메인 이전, URL 구조 변경이면 308 이 맞다)
- 폼 제출(POST) 경로라면 301/302 가 아니라 307/308 인가
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- 막을 거면 차리기 전에 막아라 — redirect 는 브라우저가 Location 을 읽고 재요청하는 구조. 그래서 브라우저가 “다음부터 묻지 않고 바로 간다” 를 선택할 수 있다
- HTTP 캐시의 freshness vs validation — “그냥 써도 되는 기간(max-age)” 과 “써도 되는지 물어보기(ETag)” 의 구분.
no-cache와no-store의 차이가 여기서 나온다
→ 확장 개념 (여기서 더 나아가면)
- RFC 9111 §4.2.2 Heuristic Freshness — 캐시가 어림잡는 규칙과 “heuristically cacheable” 상태코드 목록
- CDN 캐시와 브라우저 캐시의 분리 —
s-maxage,private처럼 공유 캐시와 개인 캐시를 따로 제어하는 지시어. 리다이렉트가 CDN 엣지에 남는 경우도 있다 - HSTS — “이 도메인은 앞으로 무조건 https” 를 브라우저가 기억하는, 308 보다 더 강하게 되돌리기 어려운 캐시. 같은 “브라우저가 기억해서 서버가 못 되돌린다” 계열
↔ 같은 원리가 적용되는 곳
- 단축 URL 뒤에 붙인 파라미터는 어디로 갔나? — 명세로 추론하지 않고 실제 응답을 찍어서 검증한 같은 태도
- Service Worker 캐시 — 서버 배포와 무관하게 브라우저에 남아 옛 응답을 계속 주는 구조. “배포했는데 왜 안 바뀌지?” 의 또 다른 원인
- CSS/JS 파일명 해시(캐시 버스팅) — 한번 기억된 캐시는 서버가 못 지우니, 이름을 바꿔서 새 요청을 만들게 하는 해법. 308 을 되돌리는 유일한 방법이 URL 을 바꾸는 것과 같은 원리
참고
- RFC 9110 §15.4.9 — 308 Permanent Redirect — “heuristically cacheable” 명시
- RFC 9111 §4.2.2 — Calculating Heuristic Freshness
- MDN — Cache-Control —
no-storevsno-cache - Next.js — redirects (next.config) —
permanent: true= 308