308 은 정말 되돌릴 수 없을까 — 명세와 실제 헤더가 다를 때

HTTP 307 vs 308, heuristic caching, Cache-Control no-store

HTTPCacheRedirectNext.js

난이도: ★★★☆☆
연관 노트: 막을 거면 차리기 전에 막아라


핵심 요약

  1. 308 은 명세상 캐시 헤더가 없으면 브라우저가 알아서 캐시해도 되는 응답이다(휴리스틱 캐시). 크롬은 사실상 영구히 기억한다. 그래서 308 을 한 번 겪은 사용자는 서버에서 리다이렉트를 지워도 요청이 서버에 오지 않고 브라우저 안에서 계속 튕긴다. 튕기는 사람과 안 튕기는 사람은 “규칙이 살아 있을 때 방문했나, 그 뒤 캐시를 지웠나” 로 갈린다.
  2. 하지만 응답에 Cache-Control: no-store 가 붙어 있으면 308 도 307 처럼 매번 서버에 묻는다. 명시적 헤더가 휴리스틱보다 우선한다. 짧은 max-age 를 줘도 그 시간만큼만 기억한다.
  3. Next.js 의 getServerSideProps 응답에는 Next 가 상태코드와 무관하게 no-store 를 붙인다. 그래서 gSSP 의 permanent: true 는 캐시 문제가 없다. 위험한 건 헤더를 안 붙이는 next.config redirects · CDN · 웹 서버 규칙 쪽이다.
  4. 그래도 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: 캐시 말고 뭐가 다른가

307308(참고) 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 을 바꾸는 것과 같은 원리

참고