1996년 5월에 발행된 HTTP/1.0 명세 RFC 1945는 301과 302 항목에 똑같은 주석을 달았다. 브라우저가 POST 요청을 리다이렉트하면서 GET으로 바꾸는 것은 "erroneously", 즉 잘못된 동작이라고.
26년 뒤 현행 명세 RFC 9110은 같은 동작을 이렇게 적는다. 역사적인 이유로, 사용자 에이전트는 요청 메서드를 POST에서 GET으로 바꿀 수 있다고. 원문의 표현은 "for historical reasons ... MAY change the request method"다. 잘못됐다던 동작이 해도 되는 동작이 된 것이다.
금지가 허용이 됐다. 그 사이에 303과 307과 308이 차례로 덧대어졌고, 오늘 우리가 표로 외우는 2×2 격자가 남았다.
이 글은 그 격자가 설계도가 아니라 흉터라는 이야기다. 네 코드의 차이를 외우는 데서 그치지 않고, 왜 그렇게 갈렸는지를 알면 세 가지가 따라온다. 어떤 코드가 본문을 들고 가는지, 어떤 코드가 되돌리기 어려운 캐시를 남기는지, 그리고 리다이렉트를 따라가는 HTTP 클라이언트가 왜 지금도 자격증명을 흘리는지다.
Why: 왜 같은 일을 하는 코드가 4개인가
301·302·307·308은 서로 다른 네 가지 요구에서 나오지 않았다. 두 가지 요구(영구냐 임시냐)를 두고 명세와 구현이 26년간 합의에 실패한 결과다.
출발점에서 301과 302는 메서드를 보존하는 코드였다. 리다이렉트를 따라갈 때 POST는 POST로 남아야 했다. 그런데 초기 브라우저들은 갈렸다. 명세대로 POST를 유지하는 쪽과, GET으로 바꾸는 쪽. 후자에는 나름의 근거가 있었다. 리다이렉트 목적지가 다른 사이트일 수 있는데, 사용자가 입력한 데이터를 확인 없이 그쪽으로 다시 보내는 것은 위험했다. 관행은 결국 GET 변경 쪽으로 수렴했고, 명세가 뒤를 따라갔다.
한 가지 덧붙일 것이 있다. RFC 9110 §15.4의 역사 주석은 303이 HTTP/1.0에서 정의됐다고 서술하지만, RFC 1945 원문에는 303이 등장하지 않는다. §9.3이 나열하는 3xx는 300·301·302·304뿐이다. 303의 초출은 1997년 RFC 2068이다. 현행 명세조차 자기 역사를 정확히 기억하지 못한다는 사실이, 이 격자가 계획된 산물이 아니라는 방증이기도 하다.
What: 격자의 구조
네 코드는 영구냐 임시냐를 묻는 축과, 메서드를 보존하느냐 바꾸느냐를 묻는 축이 교차하는 2×2 격자를 이룬다. 대각선끼리 짝이 된다.
구분
메서드가 바뀔 수 있음
메서드 보존
임시
302
307
영구
301
308
이 격자가 우연이 아니라는 근거는 두 가지다.
첫째, 명세가 짝을 직접 지정한다. RFC 9110은 301 항목에 이런 주석을 달았다. 메서드가 바뀌는 동작을 원하지 않는다면 308을 대신 쓰면 된다고. 302 항목에도 307을 지목하는 같은 형태의 주석이 붙어 있다. 명세가 자기 코드를 설명하면서 "이게 싫으면 저걸 쓰라"고 안내하는 셈이다. 307과 308은 독립적인 기능이 아니라 명세가 스스로 내놓은 회피로다.
둘째, 대칭이 메서드 축에서만 성립하는 것이 아니다. RFC 9110 §15.1은 휴리스틱 캐시가 가능한 상태 코드를 명시적으로 나열하는데, 3xx 중에서는 300과 301과 308만 목록에 있다. 그 밖의 코드는 "all other status codes are not heuristically cacheable"로 정리된다. 영구 쌍인 301과 308은 캐시 지시가 없어도 캐시될 수 있고, 임시 쌍인 302와 307은 그렇지 않다. 같은 대각선이 캐시 수명에서도 갈린다.
두 축이 겹친다는 것은 코드를 고를 때 결정되는 것이 하나가 아니라는 뜻이다. 영구냐 임시냐를 고르는 순간 메서드 보존 여부와 캐시 지속성이 함께 정해진다. 301을 골랐다면 메서드 변경을 허용하면서 동시에 브라우저가 그 응답을 오래 들고 있어도 좋다고 선언한 것이다.
그런데 이 격자를 "네 칸 중 필요한 칸을 고른다"로 읽으면 절반만 이해한 것이다. 실제로 고르는 것은 네 개의 대등한 선택지가 아니다. 302와 301은 의도를 표현하지 못하는 코드이고, 307과 308은 그 표현 불가능을 우회하려고 나중에 추가된 코드다. 명세가 두 코드를 각각의 대안으로 지목하는 주석을 달아둔 이유가 여기 있다. 네 칸짜리 격자는 두 칸짜리 의미론이 고장 난 뒤에 생긴 형태다.
How: 리다이렉트는 실제로 어떻게 재전송되는가
브라우저가 리다이렉트를 따라갈 때 하는 일은 주소를 바꿔 다시 요청하는 것이 아니다. RFC 9110 §15.4는 원래 요청을 다섯 단계로 재구성하도록 규정한다. 매일 리다이렉트를 다루면서도 이 알고리즘을 읽어본 개발자는 많지 않다.
재전송 다섯 단계
1단계, 타깃 URI 교체. Location 헤더 값을 원래 요청의 타깃 URI 기준으로 상대 해석한 뒤 교체한다. Location에 절대 URI만 올 수 있다고 생각하기 쉽지만, 상대 참조가 오면 원 요청 주소를 기준으로 풀린다.
2단계, 구현이 자동 생성한 헤더 제거. 여기가 길다. 연결 특화 헤더, 프록시 설정에 딸린 Proxy-Authorization, 오리진 특화 헤더인 Host, 캐시가 붙인 If-None-Match나 If-Modified-Since 같은 검증 헤더, 그리고 리소스 특화 헤더로 분류되는 Referer·Origin·Authorization·Cookie가 모두 대상이다. 요청의 목적지가 바뀌었으니 목적지에 묶여 있던 정보는 더 이상 유효하지 않다는 논리다.
3단계, 호출자가 붙인 헤더 처리. 구현이 아니라 애플리케이션 코드가 직접 붙인 헤더는 사정이 다르다. 명세는 "보안상 영향이 있는 경우 제거를 고려하라"고 적고, 그 예로 Authorization과 Cookie를 든다. 2단계가 제거를 지시하는 것과 달리 3단계는 고려를 권한다. 같은 헤더인데 어디서 왔느냐에 따라 강제력이 다르다. 이 격차는 뒤에서 다시 다룬다.
4단계, 메서드 변경. 리다이렉트를 보낸 상태 코드의 의미론에 따라 메서드를 바꾼다. 302를 받았다면 POST가 GET이 되고, 307을 받았다면 그대로 POST다.
5단계, 콘텐츠 헤더 제거. 메서드가 GET이나 HEAD로 바뀌었다면 Content-Encoding, Content-Language, Content-Location, Content-Type, Content-Length, Last-Modified 등을 제거한다. 이유는 단순하다. 메서드가 GET이 되는 순간 본문이 사라지므로, 본문을 설명하던 헤더는 존재하지 않는 것을 설명하는 거짓말이 된다.
코드별 실제 동작
302는 명세와 현실이 가장 크게 어긋나는 자리다. RFC 9110은 메서드 변경을 해도 된다는 수준으로 적지만, 실제로는 거의 모든 구현이 POST를 GET으로 바꾼다. RFC 2616은 1999년에 이미 이 상황을 기록해뒀다. 대부분의 구현이 302를 사실상 303처럼 다루며, 원래 메서드가 무엇이었든 Location이 가리키는 곳으로 GET을 보낸다고. 즉 302는 이름만 남고 동작은 303에 흡수된 상태다.
307은 재량을 없앤 코드다. RFC 9110은 "the user agent MUST NOT change the request method"라고 못박는다. 메서드가 유지되므로 본문도 따라간다.
303은 오해받는 코드다. POST 결과를 별도 리소스로 분리해, 그 리소스를 독립적으로 식별하고 북마크하고 캐시할 수 있게 하는 것이 목적이다. 여기서 캐시 대상은 리다이렉트가 가리키는 목적지이지 303 응답 자체가 아니다. 303은 §15.1의 휴리스틱 캐시 목록에 없다. 규정 강도는 그 사이 약해졌다. RFC 2616은 303 응답을 캐시해서는 안 된다고 금지했으나, 현행 명세는 휴리스틱 캐시 대상이 아니라는 수준으로 완화했다. 여기서도 명세는 강한 금지에서 조건부 허용 쪽으로 물러섰다.
308은 아직도 신참 취급을 받는다. RFC 9110 §15.4.9의 주석은 2022년 시점에도 308이 형제 코드들보다 훨씬 어리며(2014년 6월), 그래서 모든 환경에서 인식되지는 않을 수 있다고 경고한다. 표준이 된 지 여러 해가 지났는데도 명세가 배포 상태를 걱정해주는 코드인 셈이다. 원문은 "much younger ... might not be recognized everywhere"로 적혀 있다.
수정안이 태어날 때부터 반쯤 사용 불가였다는 것
이 이야기에서 가장 기묘한 대목은 1999년 RFC 2616에 있다. 이 문서는 302의 모호함을 없애려고 303과 307을 정비해놓고, 같은 문서 안에서 그 코드를 쓰지 말라고 안내한다.
303 항목에는 이렇게 적혀 있다. HTTP/1.1 이전의 클라이언트 상당수가 303을 이해하지 못하므로, 그런 클라이언트와의 호환이 걱정된다면 302를 대신 써도 된다고. 307 항목에도 이전 클라이언트들이 307을 이해하지 못한다는 같은 취지의 문장이 붙어 있다.
모호함을 없애려고 만든 코드를 두고, 아직 클라이언트가 못 알아들으면 모호한 쪽을 쓰라고 권한 것이다. 새 코드는 태어난 시점에 이미 배포 문제를 안고 있었고, 그래서 실무는 계속 302를 썼다. 307의 영구 버전인 308이 15년이나 늦게 도착한 배경에도 같은 사정이 있다.
본문이 따라가는 리다이렉트
307과 308이 메서드를 보존한다는 것은 본문도 함께 간다는 뜻이다. 리다이렉트 목적지를 공격자가 통제할 수 있는 상황이라면, 302로 응답할 때는 GET 전환 과정에서 본문이 사라지지만 307로 응답할 때는 원래 POST 본문이 그대로 전달된다. 같은 열린 리다이렉트라도 응답 코드에 따라 노출되는 정보량이 달라진다는 뜻이다. 다만 이 서술은 메서드 보존 규정에서 도출한 것으로, 명세가 직접 다루는 시나리오는 아니다.
Security: 격자가 만드는 보안 결과
리다이렉트의 보안 문제는 목적지 검증에 그치지 않는다. 어떤 헤더가 따라가는지, 어떤 응답이 캐시에 남는지가 상태 코드와 구현 재량에 따라 갈린다.
자격증명이 따라가는 자리
앞서 본 재전송 알고리즘의 2단계와 3단계 사이에 명세적 격차가 있다. 구현이 자동으로 생성한 Authorization과 Cookie는 제거 대상으로 지시되지만, 애플리케이션 코드가 직접 붙인 같은 헤더는 "보안상 영향이 있는 경우 제거를 고려하라"는 권고에 머문다. 같은 이름의 헤더가 출처에 따라 다른 강제력을 받는 것이다.
게다가 명세는 대상 헤더를 열거하면서 "including (but not limited to)"라는 단서를 붙인다. 목록이 아니라 예시라는 뜻이다. 무엇을 벗겨야 하는지 최종 판단은 구현에 넘어간다.
이 판단이 실제로 어떻게 갈렸는지는 공개된 취약점 기록에 남아 있다. 네 건이 서로 다른 방식으로 같은 지점에서 어긋났다.
대상
어긋난 지점
curl 7.82.0 이하
같은 호스트 판정에 스킴과 포트를 넣지 않음
Perl LWP::UserAgent 6.83 미만
Host와 Cookie만 제거, 호출자 Authorization은 전달
Microsoft Kiota
Authorization만 제거, Cookie와 커스텀 헤더는 전달
Elixir Tesla 1.4.0~1.18.2
소문자 필터가 Authorization을 매칭하지 못함
이 중 LWP::UserAgent 사례()가 명세 단계에 가장 정확히 대응한다. 이 구현은 3xx 응답에서 Host와 Cookie를 제거했다. 2단계는 수행했으나 호출자가 붙인 Authorization을 다루는 3단계를 수행하지 않았다. 명세가 지시가 아니라 고려로 적어둔 바로 그 항목이다.
Kiota 사례()는 예시 목록을 완결된 목록으로 읽었을 때의 결과를 보여준다. Authorization은 제거했지만 Cookie와 Proxy-Authorization과 커스텀 인증 헤더는 그대로 보냈다. 명세가 예시일 뿐이라고 적어둔 단서가 비용을 만든 자리다.
나머지 두 건은 판정 자체가 어긋난 경우다. curl()은 자격증명을 넘기지 않으려는 검사가 이미 있었으나 호스트명만 비교해 스킴과 포트 변경을 같은 호스트로 취급했고, Tesla()는 제거 목록을 소문자로 비교해 대부분의 라이브러리가 쓰는 표기인 Authorization이 필터를 통과했다. 방어 코드와 목록이 모두 있었는데도 판정 기준이 규격과 어긋나 통과한 것이다.
탐지 관점에서 이 동작은 요청을 두 번 발생시키므로 흔적이 남는다. 리다이렉트를 추종한 요청은 원 요청과 최종 요청의 호스트가 다르며, 자격증명이 함께 실렸다면 그 조합이 기록된다. 호스트 경계를 넘는 요청에 인증 헤더가 붙는 조합은 정상 흐름에서 흔하지 않아 구분된다.
되돌릴 수 없는 리다이렉트
301과 308이 휴리스틱 캐시 대상이라는 §15.1의 규정은 실무에서 되돌리기 어려운 사고로 이어진다. 캐시 제어 헤더를 명시하지 않은 채 잘못된 301을 내보내면, 그 응답을 받은 클라이언트는 서버를 고친 뒤에도 한동안 새 응답을 요청하지 않는다. 서버 로그에는 요청 자체가 들어오지 않으므로 문제가 드러나는 데도 시간이 걸린다.
302와 307에는 이 성질이 없다. 영구와 임시 중 무엇을 고르느냐가 의미론의 문제일 뿐 아니라, 잘못했을 때 되돌릴 수 있느냐의 문제이기도 하다는 뜻이다.
국내 서비스에서도 로그인 POST 뒤에 302를 두는 구성이 관행처럼 쓰이는데, 이 구성은 브라우저가 명세의 원래 의도와 다르게 동작해준다는 전제 위에 서 있다. 의도가 GET 전환이라면 303이, 메서드 유지라면 307이 그 의도를 명시적으로 표현하는 코드라는 점이 이 관행에 적용된다.
Summary
네 개의 코드는 두 개의 의미론이 고장 난 흔적이다. 1996년에 "잘못된 동작"으로 기록된 브라우저 관행이 2022년에는 "해도 되는 동작"이 됐고, 그 사이에 303·307·308이 차례로 덧대어졌다. 307과 308은 새 능력이 아니라 명세가 직접 안내하는 우회로다.
코드 선택은 메서드만 정하지 않는다. 본문이 따라가는지, 응답이 캐시에 남아 되돌리기 어려워지는지가 함께 결정된다. 영구와 임시의 선택은 의미론의 문제이자 복구 가능성의 문제다.
명세가 판단을 넘긴 자리에서 사고가 반복된다. 리다이렉트 시 어떤 헤더를 벗길지를 명세는 예시와 권고로만 정했고, curl과 Perl LWP와 Kiota와 Tesla가 각각 다른 방식으로 그 판단에 실패했다. 네 건 중 세 건이 2026년에 공개됐다.