사람들이 정확히 한 번씩 저지르는 실수가 있다. 사이트를 HTTPS로 옮기면서 http://에서 https://로 가는 리다이렉트를 설정한다. 영구적인 이전이니까 영구를 뜻하는 코드, 301을 고른다. 잘 작동한다. 몇 달 뒤 이걸 되돌려야 한다 — 인증서가 깨졌거나, 디버깅 중이거나, 잠깐 평범한 HTTP로 돌아가고 싶거나. 서버 설정을 바꾼다. 그런데 리다이렉트는 그대로 일어난다. 전에 방문했던 사용자들에게, 당신이 그들의 브라우저에 손을 뻗어 멈출 방법도 없이.
버그가 아니다. 301이 약속한 그대로 하는 것이다. 문제는, “301 = 영구, 302 = 임시”가 외우기 너무 쉬운 사실이라 그 밑을 들여다보는 사람이 거의 없다는 점이다. 그 밑에 있는 진실은 이렇다. 301 대 302는 하나의 결정이 아니다. 두 개의 결정이 숫자 하나로 융합돼 있고, 망가지는 부분은 대개 당신이 생각하지 않았던 쪽에서 온다.
당신이 원한 영구성, 그리고 당신이 얻은 영구성
SEO 때문에 301을 고를 때 머릿속의 영구성은 검색엔진이다. 페이지가 완전히 옮겨갔으니 랭킹을 넘겨주고 색인을 갱신하라고 구글에 알리는 것. 좋다. 그 부분은 작동한다.
하지만 “영구”는 크롤러에게만 말하지 않는다. 브라우저에게도 말하고, 브라우저는 곧이곧대로 받아들인다. 301은 기본적으로 캐시 가능하다 — Cache-Control도, Expires도, 아무것도 필요 없다. Chrome을 비롯한 브라우저는 이를 저장하고, 돌아온 방문자에게는 당신의 서버에 다시 묻지 않고 리다이렉트를 수행한다. 리다이렉트는 당신의 통제를 떠났다. 이제 남의 디스크 위에서 살고, 당신의 설정 변경은 거기에 보이지 않는다.
비대칭이 잔인하다. 당신이 원한 영구성은 구글 색인 속에 있고, 그건 영향을 줄 수도, 회복시킬 수도 있다. 당신이 얻은 영구성은 과거 방문자 모두의 브라우저 캐시 속에 있고, 그건 전혀 손댈 수 없다. 반면 302는 매번 당신에게 다시 확인하는 것 — 마음을 바꿀 수 있게 해주는 바로 그 의미에서 임시다. 이 영역 전체에서 가장 안전한 습관 하나: 이전이 영원할지 아직 확신하지 못하는 동안에는 302가 결정권을 당신 손에 남겨둔다. 확신이 서고 “영원”이 정말 영원임을 받아들일 준비가 되면 그때 301로 승격하라.
숫자 속에 숨은 또 하나의 결정
캐싱은 유명한 함정이다. 더 미묘한 쪽은 HTTP 메서드에 무슨 일이 일어나는가이고, 여기서 리다이렉트는 조용히 데이터를 삼킨다.
사용자가 폼을 제출한다고 하자 — 본문이 있는 POST를, 리다이렉트로 응답하는 URL로. 브라우저는 리다이렉트된 요청에 어떤 메서드를 쓸까? 당연히 POST를 그대로 쓸 것 같다. 301과 302에서는, 종종 그러지 않는다. 조용히 GET으로 바꾸고 본문을 버린다. 폼 제출은 증발하고, 사용자는 아무것도 보내지 않은 채 어떤 페이지에 도착한다. 302를 따라가는 API 클라이언트도 같은 식으로 페이로드를 잃는다.
브라우저가 버그를 내는 게 아니다. 스펙에 굳어버린 역사다. HTTP/1.0(RFC 1945)에서 302는 “Moved Temporarily”였고, 텍스트는 클라이언트가 메서드를 바꾸지 말라고 했다 — 그런데 모든 브라우저가 POST를 GET으로 바꿨고, 웹의 충분히 많은 부분이 그 동작에 의존하게 돼 되돌릴 수 없었다. HTTP/1.1은 이 난장판을 302를 고치는 게 아니라 그 주변에 명확한 코드 둘을 더하는 방식으로 정리하려 했다: 항상 “이 다른 것을 GET하라”를 뜻하는 303 See Other, 그리고 메서드와 본문을 보존함을 보장하는 307 Temporary Redirect. 302 자체는 “Found”로 이름이 바뀌고 일부러 모호하게 남겨졌다. 2022년의 현행 HTTP 스펙 RFC 9110은 그 모호함을 버젓이 박아둔다: 301과 302에 대해, 사용자 에이전트가 “역사적 이유로(for historical reasons)” POST를 GET으로 바꿔도 된다(MAY)고 적는다. MAY. 스펙이 당신의 브라우저가 뭘 할지 모른다고 말하고 있는 것이다.
몇 년 뒤 308 Permanent Redirect(RFC 7538, 2015)가 마지막 모서리를 채웠다. 이제 깔끔한 격자가 생긴다: 301과 308은 영구, 302와 307은 임시, 307/308 쌍은 메서드를 보존하고, 301/302 쌍은 그러지 않을 수도 있다. 코드 넷, 독립된 두 축 — 캐시 가능한가, 메서드를 보존하는가 — 이 마침내 민간전승 같은 한 쌍에서 별개의 선택으로 분리됐다.
이게 당신에게 실제로 뜻하는 것
이게 중요한 이유는 상태 코드에 대한 현학이 아니다. 두 고전 코드가 당신 대신 두 개의 결정을 내리는데, 당신은 아마 그중 하나만 생각하고 있었기 때문이다.
301을 입력할 때 당신은 “내 쪽에서 캐시를 무를 수 없고, 어쩌면 영원히, 내가 결코 보지 못할 클라이언트에서”까지 함께 신청하는 것이다. 폼 핸들러 앞에 302를 둘 때 당신은 “POST 본문이 조용히 사라질 수 있음”까지 함께 신청하는 것이다. 둘 다 이름에는 없다. “영구”와 “임시”는 SEO 절반만 설명하고, 캐시-되돌리기 절반과 메서드 절반에 대해서는 완전히 침묵한다.
그래서 내가 실제로 줄 규칙은 이렇다. 무언가가 유동적인 동안에는 302를 기본으로 하라. 되돌릴 수 있음은 생각보다 값지고 비용은 거의 없으니까. 301은 이전이 정말로 영원하고 브라우저에서 그걸 되찾을 수 없음과 화해했을 때만 꺼내라. 그리고 리다이렉트가 POST 앞에 앉는 순간 — 로그인, 폼, API — 고전 쌍을 아예 버리고, POST를 GET으로 바꾸지 않겠다고 문서로 약속하는 307이나 308을 써라. 모든 걸 망가뜨리는 리다이렉트는, 하는 일이 아니라 별명을 보고 고른 그 리다이렉트다.