본문 바로가기
빌드노트 검색

빌드노트

리다이렉트 뒤 POST가 GET으로 바뀌었다: 303과 307의 차이

리다이렉트 뒤 POST 본문이 사라졌다면 첫 응답과 목적지 요청을 함께 본다. 실제 303 뒤 GET·0바이트, 307 뒤 POST·15바이트 기록을 바탕으로 Network 확인 순서와 저장 후 결과 조회(PRG)·같은 POST의 임시 주소 이동을 구분한다.

저장 API에 POST로 JSON을 보냈는데 리다이렉트 목적지에는 GET과 빈 본문이 도착한다면, 첫 응답의 상태 코드부터 확인한다. 303을 자동으로 따라가는 fetch는 POST를 GET으로 바꾸고 본문을 제거한다. 저장을 끝내고 결과를 조회하려는 것인지, 아직 처리하지 않은 POST를 다른 주소로 보내려는 것인지에 따라 응답 코드를 골라야 한다.

저장 기능이 없는 로컬 서버에서 이 차이를 비교했다. /303과 /307은 모두 Location: /target을 반환한다. 같은 Node.js 내장 fetch로 {"name":"demo"}라는 15바이트 문자열을 보내고 redirect: 'follow'로 자동 추적했다. 서버가 읽은 요청 메서드와 본문 바이트 수는 다음과 같았다.

/303 POST bodyBytes=15
/target GET bodyBytes=0
/307 POST bodyBytes=15
/target POST bodyBytes=15

최초 POST에는 둘 다 15바이트가 도착했지만, 목적지에서는 303 뒤에만 본문이 없었다. 두 흐름의 최종 응답은 모두 200이었다. /target에 직접 POST하면 15바이트를 받았고, 307 뒤에는 길이뿐 아니라 본문 문자열도 입력과 같았다. 이 비교에서는 JSON 파싱 전에 후속 요청 자체가 달라진 것이다. 실행 환경은 Windows, Node.js 22.23.2, Undici 6.28.0이었다. Node의 fetch 문서 (새 탭에서 열림)는 내장 구현이 Undici 기반임을 설명한다.

최종 200보다 먼저 볼 요청이 있다

자신의 테스트 환경에서는 개발자 도구의 Network 기록을 켜고 문제가 생기는 요청 흐름을 확인한다. 폼 제출로 페이지가 바뀐다면 기록 보존 옵션인 Preserve log도 켠다. 다음 세 곳을 이어서 본다.

  1. 최초 요청의 Request Method가 POST이고 Payload에 보낼 JSON이 있는지 확인한다.
  2. 그 요청의 응답 Status Code와 Response Headers의 Location을 읽는다. 마지막 요청의 200만 확인하지 않는다.
  3. Location을 해석한 목적지 요청에서 Request Method와 Payload를 비교한다. 중간 이동이 더 있으면 각 응답과 다음 요청을 같은 방식으로 확인한다.

최초 POST에는 본문이 있고 303 뒤 목적지가 GET이라면, 본문이 목적지의 JSON 파서에 도달하지 않은 이유를 설명할 수 있다. 반대로 목적지에 POST와 원래 본문이 도착했다면 이번 원인과 다르다. 목적지의 Content-Type 처리와 본문 읽기 코드를 확인한다. 목적지 요청 자체가 없다면 클라이언트의 리다이렉트 설정과 연결 오류를 먼저 본다.

같은 Node 클라이언트에서 redirect: 'manual'로 별도 실행했을 때는 각각 303·307과 Location: /target을 받았고, 목적지 요청은 생기지 않았다. 자동 추적을 끄면 첫 응답과 후속 요청을 구별할 수 있다. 다만 브라우저 fetch의 manual 응답은 스크립트에 상태·헤더가 가려질 수 있으므로, Node에서 읽은 값을 브라우저에서도 그대로 읽을 수 있다고 가정하지 않는다. 브라우저에서는 Network의 흐름을 확인한다.

리다이렉트는 서버가 본문을 다른 서버로 전달하는 동작이 아니다. 서버가 이동 응답을 보내면 클라이언트가 다음 요청을 만든다. Fetch 표준의 HTTP 리다이렉트 절차 (새 탭에서 열림)는 303을 받았고 메서드가 GET이나 HEAD가 아니면 GET으로 바꾸며 본문을 없애도록 정한다. 위 관찰은 그중 POST에 해당한다.

저장을 마쳤다면 303, 같은 POST의 임시 이동이라면 307

이미 저장을 끝내고 결과 페이지를 보여 주려는 경우에는 303이 의도에 맞는다. RFC 9110의 303 설명 (새 탭에서 열림)은 Location의 다른 리소스를 조회해 원래 요청의 결과를 확인하는 흐름을 정의한다. POST /orders에서 저장한 뒤 /orders/42를 GET으로 조회하게 하는 식이다. 이것이 POST → Redirect → GET, 즉 PRG다. 조회 요청에 원래 저장용 본문이 없는 것은 정상이다.

Node.js HTTP 핸들러라면 저장 성공 뒤 다음 응답을 보낸다. res는 현재 요청의 응답 객체이며, /orders/42는 실제 저장 결과의 조회 주소로 바꾼다. 아래 코드는 저장 로직을 포함하지 않은 응답 부분이다.

res.writeHead(303, { Location: '/orders/42' }).end();

브라우저 폼 제출 뒤 결과 페이지로 이동하는 PRG는 새로고침이 조회를 반복하게 한다. 중복 저장을 모두 막아 주지는 않는다. fetch로 API를 호출했다면 자동 추적은 응답을 받는 과정이고, 화면 이동은 프런트엔드에서 따로 처리해야 한다.

첫 주소에서는 저장하지 않고 다른 주소에서 같은 POST를 처리해야 하는 경우에는 임시 이동을 뜻하는 307이 맞는지 검토한다. RFC 9110의 307 설명 (새 탭에서 열림)은 자동으로 따라갈 경우 메서드를 바꾸지 못하도록 규정한다. 아래는 앞의 303과는 다른 처리 경로에서 사용할 응답 예다. /v2/orders는 같은 POST와 본문 형식을 받아 처리하는 목적지여야 한다.

res.writeHead(307, { Location: '/v2/orders' }).end();

두 코드의 writeHead와 end 사용법 (새 탭에서 열림)은 같다. 바뀌는 것은 목적지 요청의 의미다. 이미 저장하는 경로를 무조건 307로 바꾸면 목적지에도 POST가 전달되어 두 번째 저장이 생길 수 있다. 먼저 어느 경로가 저장을 맡는지 정한다. 302로 바꾸는 것만으로도 보존을 요구할 수 없다. RFC 9110의 302 설명 (새 탭에서 열림)은 후속 요청에서 POST를 GET으로 바꾸는 것을 허용한다.

이번 실행은 같은 출처의 loopback HTTP와 다시 보낼 수 있는 문자열 본문만 확인했다. 307이 자동 추적이나 재전송 성공을 보장하는 것은 아니다. Fetch 표준은 다시 보낼 본문 원본이 없는 경우 오류로 끝내는 조건도 둔다. 브라우저 실행, 스트리밍 업로드, 다른 출처의 인증·CORS, 302 추적, DB 저장과 새로고침 동작은 측정하지 않았다. 자신의 코드에서도 테스트 환경의 메서드·본문 길이와 실제 저장 위치를 함께 확인해야 한다.

← 전체 글 보기