버그는 이렇게 나타납니다. 어떤 연동이 여러분의 엔드포인트로 POST를 보냅니다. 엔드포인트는 정상이고 라우트도 있으며 손으로 확인하면 전부 문제없습니다. 그런데 로그에는 POST만 받는 라우트에 대한 GET이 찍히고 404 또는 405가 돌아옵니다.
원인은 거의 언제나 같습니다. 클라이언트와 엔드포인트 사이에 301 또는 302 리디렉션이 놓여 있고, 이 코드들은 클라이언트에게 메서드 유지를 의무화하지 않습니다.
확인은 십 초면 됩니다.
curl -sS -o /dev/null -L -d 'test=1' \
-w '리디렉션 후 메서드: %{method}\n' \
'https://httpbingo.org/redirect-to?url=/anything&status_code=301'
출력은 리디렉션 후 메서드: GET. 요청 본문도 함께 사라집니다.
같은 명령에서 301을 308로 바꾸면 POST가 나옵니다. 차이는 상태 코드뿐입니다.
다섯 개의 코드, 두 가지 성질
리디렉션은 서로 독립적인 두 축으로 나뉩니다. 영구인가 임시인가, 그리고 요청 메서드가 유지되는가.
| 코드 | 이름 | 영속성 | 메서드 유지 |
|---|---|---|---|
| 301 | Moved Permanently | 영구 | 안 됨 |
| 302 | Found | 임시 | 안 됨 |
| 303 | See Other | — | 안 됨, 항상 GET |
| 307 | Temporary Redirect | 임시 | 됨 |
| 308 | Permanent Redirect | 영구 | 됨 |
대칭에 주목하십시오. 307은 메서드 보장이 붙은 302이고, 308은 메서드 보장이 붙은 301입니다.
이 혼란은 어디서 왔나
형식적으로 명세는 301과 302에서 메서드를 바꾸라고 요구한 적이 없습니다. 그러나 초기 브라우저와 클라이언트가 바로 그렇게 동작했고, 이 불일치가 인식되었을 무렵에는 이미 웹 전체가 그렇게 돌아가고 있었습니다.
그래서 이 동작은 '허용'으로 명문화되었습니다. 현행 HTTP 시맨틱스 명세인 RFC 9110은 301과 302에서 클라이언트가 POST를 GET으로 바꾸는 것을 허용합니다. 허용하지만 요구하지는 않습니다 — 즉 동작이 클라이언트 재량에 남고, 따라서 믿을 수 없습니다.
명확한 선택지를 주기 위해 307과 308이 도입되었습니다. 이들에서는 메서드 변경이 금지되며, 클라이언트는 같은 메서드와 같은 본문으로 요청을 반복해야 합니다. 308은 별도의 RFC 7538에 정의되어 있고, MDN 문서에도 참조 설명이 있습니다.
실무적 결론: 301과 302는 요청이 확실히 GET인 곳에서만 안전합니다. POST가 올 수 있는 모든 곳에는 307이나 308이 필요합니다.
두 번째 함정: 영구 리디렉션은 캐시된다
이건 메서드 문제보다 더 자주 잊힙니다.
301과 308은 이전을 영구로 선언하므로, 브라우저는 이를 오랫동안 캐시할 권리가 있습니다 — 때로는 사용자가 캐시를 비울 때까지입니다. 그 이후로는 서버에 묻지조차 않습니다.
여기서 규칙이 나옵니다. 잘못 놓은 301은 되돌리기가 거의 불가능합니다. 설정은 고치겠지만, 이미 옛 응답을 받은 방문자는 계속 리디렉션됩니다. 302와 307에는 이런 문제가 없습니다. 정의상 임시이기 때문입니다.
실무 조언: 이전 설계가 자리 잡기 전까지는 임시 코드를 쓰십시오. 나중에 302를 301로 올리는 것은 쉽지만, 반대는 그렇지 않습니다.
무엇을 고를까
페이지가 영구 이전했고 GET만 받는다 → 301. 전형적인 경우: 옛 기사 주소, 주소 구조 변경.
같은 상황이지만 엔드포인트가 POST를 받는다 → 308. API, 웹훅, 폼이 여기 속합니다.
목적지가 임시로 바뀐다, GET만 → 302.
임시이면서 메서드가 유지되어야 한다 → 307.
POST 이후 사용자를 결과 페이지로 보낸다 → 303. 이것이 POST/Redirect/GET 패턴입니다. 페이지를 새로 고쳐도 폼이 재전송되지 않도록 의도적으로 요청을 GET으로 바꿉니다. 여기서 메서드 변경은 목적이지 결함이 아닙니다.
실제로 어디서 깨지나
API 앞단의 http→https 리디렉션. 가장 흔하고 가장 성가신 경우입니다. 클라이언트는 http://로 설정되어 있고, 서버는 https://를 가리키는 301을 반환하며, 메서드가 사라집니다. 증상은 POST 라우트에 찍히는 GET. 301을 308로 바꾸면 해결됩니다.
호스트 정규화. www에서 비-www로(또는 그 반대) 가는 리디렉션은 보통 301로 구성합니다. 페이지에는 문제없지만, 같은 호스트에 API가 함께 산다면 정확히 같은 문제가 적용됩니다.
폼 전송. 폼이 리디렉션하는 주소로 전송됩니다. 데이터가 핸들러에 도달하지 못했으므로 사용자에게는 '아무 일도 일어나지 않은' 것처럼 보입니다.
웹훅. 외부 서비스가 언젠가 여러분이 알려준 주소로 POST를 보냅니다. 그 주소가 301 뒤로 옮겨졌다면 웹훅은 조용히 작동을 멈추고, 발신 측 로그에는 리디렉션에 대한 성공 응답만 남습니다.
네 가지 모두 공통된 특징이 있습니다. 엔드포인트는 멀쩡한데 요청이 원래 형태로 도달하지 않는다는 것. 그래서 진단이 엉뚱한 방향으로 갑니다. 핸들러에서 버그를 찾지만, 원인은 웹 서버 설정에 있습니다.
단축 서비스가 302를 쓰는 이유
단축 링크는 거의 언제나 302를 반환하며, 이는 부주의가 아니라 의도된 선택입니다.
이유는 메서드가 아니라 영속성에 있습니다. 단축 링크의 목적지는 바뀔 수 있습니다. 301을 쓰면 브라우저가 그 이동을 영구히 캐시하도록 허용하는 셈이고, 목적지를 바꾼 뒤에도 일부 방문자는 계속 옛 주소로 떨어집니다. 여기서 메서드는 무관합니다 — 단축 링크는 GET 요청으로 따라갑니다.
구조 자체는 단축 URL은 어떻게 작동하는가에서 따로 다룹니다.
코드 선택이 바꾸지 않는 것
널리 퍼진 두 가지 오해.
링크 가치. 301과 302 중 무엇을 고르는지는 가치 전달에 영향을 주지 않습니다 — 구글이 2016년에 확인했습니다. 자세한 논의는 단축 링크는 링크 가치를 전달할까에 있습니다.
표준 URL 선정. 리디렉션과 rel="canonical"은 서로 다른 문제를 풉니다. 리디렉션은 주소를 유통에서 제거하고, canonical은 둘 다 접근 가능하게 두면서 주된 것을 지목합니다. 언제 무엇을 쓸지는 canonical 완전 가이드에 설명되어 있습니다.
짧은 체크리스트
POST를 받는 라우트에서는 302나 301이 아니라 307 또는 308을 쓴다.- http→https 리디렉션은 따로 확인한다. 일괄 규칙으로 설정되면서 API가 빠지기 쉽다.
- 설계가 자리 잡기 전까지는 임시 코드. 301과 308은 캐시되며 되돌리기 어렵다.
- 폼 처리 후에는 303을 쓴다 — 이는 올바른 동작이지 실수가 아니다.
- 사슬은 한 홉으로 줄인다. 홉이 하나 늘 때마다 지연과 위험이 함께 늘어난다.