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

빌드노트

브라우저 fetch의 manual 응답은 왜 302 대신 status 0일까

브라우저의 redirect: manual 응답에서 상태 0과 빈 Location이 보이는 이유를 opaqueredirect로 설명한다. 실제 302 서버 로그와 브라우저 manual·follow·no-cors, Node fetch를 비교하고 자동 추적과 JSON 목적지 계약을 고르는 기준을 제시한다.

브라우저에서 fetch(url, { redirect: "manual" })로 요청한 뒤 Location을 읽으려는데, 상태는 0이고 헤더는 null일 수 있다. 이때 먼저 response.type을 확인한다. opaqueredirect라면 브라우저가 리다이렉트 응답의 상태·헤더·본문을 가린 것이다. manual은 자바스크립트가 원래 302와 목적지를 읽도록 열어 주는 옵션이 아니다. MDN의 Response.type 설명 (새 탭에서 열림)

최종 응답만 필요하면 자동으로 따라가는 follow를 사용한다. 다음 주소를 읽고 애플리케이션이 먼저 판단해야 한다면, 서버가 주소를 JSON 같은 일반 응답 데이터로 돌려주는 계약이 필요하다. 숨겨진 Location을 읽는 코드를 반복해서 고쳐도 그 계약을 대신할 수는 없다.

같은 302를 manual과 follow로 읽었다

별도 로컬 서버에 GET /start는 302와 Location: /done을 보내고, GET /done은 200과 JSON을 보내도록 만들었다. 요청의 case 쿼리는 두 실행을 서버 로그에서 구별하기 위해 목적지에도 그대로 붙였다. 두 경로는 페이지와 같은 출처다. Windows 11의 HeadlessChrome 153.0.0.0에서 새 탭을 열어 비교했으며 실제 인증이나 쿠키는 사용하지 않았다.

기존 코드에서도 브라우저 콘솔에서 같은 필드를 확인할 수 있다. 아래의 /start는 자신이 관리하는 로컬 GET 리다이렉트 경로로 바꾼다. 그 서버와 같은 출처의 HTTP 페이지를 먼저 열어야 한다. 예제의 credentials: "omit"은 인증 없는 비교를 위한 값이다.

for (const redirect of ["manual", "follow"]) {
  const r = await fetch(`/start?case=browser-${redirect}`, {
    redirect, credentials: "omit", cache: "no-store"
  });
  console.log(JSON.stringify({
    redirect, type: r.type, status: r.status, url: r.url,
    Location: r.headers.get("Location"), redirected: r.redirected,
    bodyIsNull: r.body === null
  }));
}

실행에서 읽은 필드는 다음과 같았다. 호스트와 포트는 그때 사용한 임시 서버 주소다.

manual
  type=opaqueredirect, status=0, Location=null
  url=http://127.0.0.1:14550/start?case=browser-manual
  redirected=false, bodyIsNull=true

follow
  type=basic, status=200, Location=null
  url=http://127.0.0.1:14550/done?case=browser-follow
  redirected=true, bodyIsNull=false

브라우저에서 0을 읽었어도 서버는 실제 302를 보냈다. 서버 기록에서 메서드·경로·상태·Location만 추리면 다음 순서다.

GET /start?case=browser-manual → 302 Location=/done?case=browser-manual
GET /start?case=browser-follow → 302 Location=/done?case=browser-follow
GET /done?case=browser-follow  → 200

manual의 목적지 GET은 없었다. 그 응답의 url도 /done이 아니라 요청한 /start였다. response.url을 숨겨진 목적지의 대용으로 쓰면 안 된다. 또 여기서 redirected: false는 서버가 302를 보내지 않았다는 뜻이 아니다. 이 요청에서 후속 주소를 따라가지 않았다는 결과다. Fetch 규격은 수동 리다이렉트 응답의 URL 목록을 지우지 않으며, redirected는 URL 목록에 주소가 둘 이상 있을 때 true로 정한다. 필터 응답의 정의 (새 탭에서 열림), Response의 URL·redirected 규칙 (새 탭에서 열림)

반대로 302를 거치지 않는 /done을 manual로 직접 요청하자 type: "basic", status: 200이었다. manual을 넣은 모든 응답이 0이 되는 것은 아니다. 자신의 결과가 다르다면 Network나 서버 로그에서 첫 응답이 실제 리다이렉트였는지부터 확인한다.

Location 노출 헤더를 추가해도 풀리지 않는다

opaqueredirect는 브라우저가 제공하는 제한된 응답이다. 규격상 상태는 0, 헤더 목록은 비어 있고 본문은 null이다. 따라서 r.status === 302 같은 분기로는 이 응답을 처리할 수 없고, 원래 리다이렉트의 정확한 상태 코드나 본문도 읽을 수 없다. 이번 서버는 302에 Access-Control-Expose-Headers: Location까지 붙였지만, 브라우저의 헤더 목록은 여전히 빈 배열이었다. 같은 출처에서도 나온 결과이므로 일반적인 CORS 헤더 노출 문제로 고치려 해서는 안 된다. Fetch의 manual 처리 (새 탭에서 열림)

이 실험의 fetch Promise는 정상 이행해 Response를 반환했다. status: 0만 보고 연결 실패라고 단정할 수 없는 이유다. 실제 네트워크 오류로 fetch가 실패할 때는 Promise가 거부된다. MDN의 error·opaqueredirect 구분 (새 탭에서 열림)

mode: "no-cors"의 opaque와도 구분한다. 별도 포트의 로컬 서버가 리다이렉트 없이 200을 보낸 경우, 교차 출처 no-cors 요청의 브라우저 결과는 type: "opaque", 상태 0, 빈 URL이었다. 헤더와 본문을 읽지 못한다는 점은 비슷하지만 발생 조건이 다르다. no-cors를 더하는 것은 manual 응답을 읽기 위한 해결책이 아니다. MDN의 opaque 설명 (새 탭에서 열림)

최종 결과만 필요하면 follow로 받는다

서버가 지정한 다음 주소에서 데이터를 받아오면 되는 요구라면 기존 redirect: "manual"을 "follow"로 바꾸거나 옵션을 생략한다. follow가 기본값이다. 앞의 비교에서도 이 모드만 바꿨을 때 /done의 JSON을 읽을 수 있었다. Request.redirect의 기본값 (새 탭에서 열림)

이때 받는 상태 200과 본문은 최종 응답의 것이다. Location: null도 처음 302의 헤더를 숨긴 결과가 아니라, 이번 최종 200 응답에 Location이 없기 때문이었다. 따라간 최종 URL은 response.url에서 확인할 수 있다. 다만 이 값은 이미 요청을 보낸 뒤의 결과이므로, 다음 요청을 보내기 전에 목적지를 선택하려는 용도로는 늦다. Response.url (새 탭에서 열림), redirected로 사후 차단하지 말라는 안내 (새 탭에서 열림)

fetch가 따라간다고 현재 페이지가 이동하는 것도 아니다. 이번 실행에서도 응답 URL은 /done으로 바뀌었지만 탭의 페이지 URL은 계속 /lab이었다. 데이터를 가져올지, 페이지 자체를 탐색할지는 별도로 결정해야 한다.

다음 주소를 먼저 판단하려면 데이터로 반환한다

클라이언트가 주소를 확인한 뒤 다음 요청을 결정해야 한다면 서버의 응답을 바꾼다. 실험에서는 GET /next가 3xx 대신 200, Content-Type: application/json과 다음 본문을 보내게 했다. next는 이 서버가 정한 같은 출처의 고정 조회 경로다.

{"next":"/done?case=contract"}

이제 브라우저는 숨겨진 헤더 대신 읽을 수 있는 데이터를 얻는다. 다음 코드는 앞의 로컬 페이지 콘솔에서 그 계약을 확인하는 예다. next를 받은 뒤 필요한 애플리케이션 판단을 넣을 자리가 두 요청 사이에 생긴다.

const r = await fetch("/next", {
  redirect: "error", credentials: "omit"
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const { next } = await r.json();
const result = await fetch(next, {
  redirect: "error", credentials: "omit"
});
if (!result.ok) throw new Error(`HTTP ${result.status}`);
console.log(await result.json());

실제 브라우저에서 /next와 /done?case=contract는 각각 200이었고, 마지막 본문은 {"arrivedAt":"/done?case=contract"}였다. redirect: "error"는 이 데이터 API가 뜻밖에 리다이렉트하면 요청을 실패시키려는 선택이다. 이 옵션으로 302의 내용을 읽게 되는 것은 아니다. 서버가 기존 302를 계속 보내면 JSON 계약을 구현한 것이 아니므로 서버 응답부터 확인한다. 리다이렉트 거부 옵션 (새 탭에서 열림)

Node에서 읽혔던 코드를 브라우저에 그대로 옮기지 않는다

같은 로컬 서버에 Node.js 22.23.2의 내장 fetch로 manual 요청을 보내자 결과는 type: "basic", 상태 302였고 Location과 본문도 읽을 수 있었다. 내장 Undici 버전은 6.28.0이었다. 해당 버전의 공식 문서는 manual 모드에서 필터링된 opaqueredirect 대신 실제 응답을 반환한다고 설명한다. 이것은 서버 런타임의 대조 결과이며 브라우저 실험을 대신하지 않는다. Undici 6.28.0의 Manual Redirect 설명 (새 탭에서 열림)

이번 관찰은 한 Chromium 계열의 격리된 탭에서, 인증 없는 로컬 GET과 한 번의 같은 출처 302를 대상으로 했다. opaque 비교에만 다른 로컬 포트를 썼다. Firefox·Safari, 서비스 워커가 개입하는 흐름, 실제 로그인·쿠키·외부 사이트 리다이렉트는 실행하지 않았다. 그 환경의 동작까지 이 결과로 검증했다고 보지는 않는다.

← 전체 글 보기