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

빌드노트

일반 fetch는 되는데 keepalive POST만 TypeError로 실패할 때

같은 POST가 keepalive일 때만 큰 본문에서 실패하면 UTF-8 바이트 수와 진행 중 전송을 확인한다. 실제 Chrome에서 65,536·65,537바이트의 서버 수신 차이와 HTTP 413 응답을 비교해, 서버 업로드 제한과 브라우저 예산을 구별한다.

같은 POST가 일반 fetch()에서는 성공하는데 keepalive: true를 넣으면 큰 본문만 TypeError로 실패한다면, 서버의 업로드 제한보다 먼저 브라우저의 keepalive 본문 예산을 확인한다. Fetch에는 현재 보낼 본문과 같은 fetch group의 진행 중 keepalive 본문을 합쳐 64KiB, 즉 65,536바이트를 넘으면 거절하는 조건이 있다. 서버 설정을 올려도 이 조건은 바뀌지 않는다. 실제로 보내는 문자열의 UTF-8 바이트 수와 함께 진행 중인 전송이 있는지 살펴야 한다.

keepalive는 페이지가 내려가도 요청을 계속할 수 있게 하는 옵션이다. 큰 업로드를 허용하는 옵션은 아니다. MDN의 Request.keepalive 설명 (새 탭에서 열림)과 RequestInit의 본문 크기 제한 (새 탭에서 열림)에 이 용도와 제한이 각각 설명돼 있다.

글자 수 대신 최종 본문의 바이트 수를 잰다

문제가 나는 POST를 만드는 코드에서 body에 전달할 최종 문자열을 확인한다. JSON이라면 JSON.stringify(payload)의 결과를 잰다. 객체 안의 메시지만 재면 키·따옴표·구분자가 빠진다. 아래는 문자 수로 검사했을 때 놓치기 쉬운 한글 합성 입력이다. 브라우저 Console에서 실행하면 두 길이를 바로 비교할 수 있다.

const body = "가".repeat(21845) + "aa";
console.log(body.length, new TextEncoder().encode(body).byteLength);

이번 Chrome에서 출력은 21847 65537이었다. String.length는 UTF-16 코드 단위 수이고, 여기서 한글 가는 UTF-8로 3바이트다. 문자열이 65,536보다 짧아 보여도 실제 본문은 예산을 넘는다. TextEncoder.encode 문서 (새 탭에서 열림)는 결과가 UTF-8 바이트를 담은 Uint8Array임을 명시한다. 이 계산은 문자열 본문을 대상으로 한다.

keepalive만 바꾸자 서버 수신 여부도 달라졌다

Windows 11, Chrome 156.0.8078.47의 headless 브라우저와 Python 3.12.10의 loopback 서버로 비교했다. 페이지와 수신 경로는 같은 origin에 뒀다. 각 경계 조건마다 새 브라우저 컨텍스트를 만들고, 요청과 응답 본문을 하나씩 끝까지 처리한 뒤 닫았다. 다른 keepalive 요청의 영향을 빼고 본문 크기만 비교하기 위해서다.

ASCII 입력은 "a".repeat(65535)에 a를 하나씩 더했고, 한글 입력은 "가".repeat(21845)에 ASCII a를 하나씩 더했다. 두 종류 모두 65,535·65,536·65,537바이트로 맞췄다. 결과는 같았다.

  • 65,535와 65,536바이트: keepalive를 켜거나 꺼도 HTTP 200이었다. 서버는 각각 해당 길이의 본문을 받았다.
  • 65,537바이트: keepalive를 켜면 TypeError: Failed to fetch였고 서버에는 이 POST의 헤더 수신 기록도 없었다. 끄면 HTTP 200이었고 서버가 65,537바이트를 받았다.
  • 131,072바이트 ASCII 본문: keepalive를 끈 대조에서 HTTP 200과 같은 길이의 수신을 확인했다. 이 서버가 64KiB에서 업로드를 막고 있던 것은 아니었다.

위 한글 body로 차이를 확인하려면, 자신이 제어하는 로컬 테스트 페이지와 같은 origin에 POST를 받는 경로가 필요하다. 다음 예제의 /collect는 UTF-8 텍스트를 받고, 수신 길이를 {"receivedBytes": 65537} 형태의 JSON으로 돌려주는 테스트 경로다. 해당 경로를 준비한 경우 앞의 body를 유지하고 다음 코드를 실행한다. 자기 API에 적용할 때는 경로와 Content-Type을 실제 계약에 맞추고, 수신 길이 필드가 없다면 서버 수신 로그를 대조한다.

async function check(keepalive) {
  try {
    const response = await fetch("/collect", {
      method: "POST",
      headers: { "Content-Type": "text/plain;charset=UTF-8" },
      body,
      keepalive
    });
    console.log("HTTP", response.status, response.ok);
    console.log("server bytes", (await response.json()).receivedBytes);
  } catch (error) {
    console.log(error.name, error.message);
  }
}

await check(true);
await check(false);

앞서 설명한 서버에서 이 코드도 별도로 실행했다. 출력은 다음과 같았고, 수신 기록에는 두 번째 POST만 남았다.

TypeError Failed to fetch
HTTP 200 true
server bytes 65537

실패한 큰 본문으로 new Request(..., { method: "POST", body, keepalive: true })를 만드는 것 자체는 성공했다. 뒤의 fetch()가 반환한 Promise가 거절됐다. GET에 body를 넣어서 Request 생성 단계에서 실패하는 경우와는 다른 조건이다.

서버가 크기를 거절하는 경우도 따로 비교했다. 합성 서버의 다른 경로에 8KiB 제한을 두고, keepalive를 켠 채 16KiB를 보냈다. 이때 서버는 본문을 받은 뒤 413을 반환했고, 클라이언트는 response.status === 413, response.ok === false인 Response를 받았다. HTTP 오류 상태 자체는 fetch의 Promise를 거절시키지 않는다. MDN의 fetch 설명 (새 탭에서 열림)에 따라 상태와 ok를 직접 확인해야 한다.

따라서 catch에 들어왔다는 이유만으로 서버 업로드 제한을 높이지 않는다. 원래 fetch가 실패했는지, 자신의 코드가 !response.ok를 보고 오류를 던졌는지부터 나눈다. 이번처럼 같은 본문이 keepalive를 끄면 도착하고, 켰을 때만 경계에서 실패하는 비교가 필요하다. TypeError라는 이름과 서버 로그 부재만으로는 CORS·CSP·연결 실패와 구별할 수 없다.

64KiB 이하로 나눠도 동시에 보내면 부족할 수 있다

Fetch 표준의 HTTP-network-or-cache fetch 절차 (새 탭에서 열림)는 본문 길이가 알려져 있고 keepalive가 true일 때, 요청의 client에 연결된 fetch group에서 keepalive가 true이며 done flag가 아직 설정되지 않은 요청들을 찾는다. 그 본문 길이의 합계에 새 본문의 길이를 더해 64KiB보다 크면 network error를 반환한다. 같은 서버 주소로 보낸 요청끼리만 합산하는 조건은 아니다.

예를 들어 기존 40,000바이트가 그 조건에 해당하는 동안 새 30,000바이트를 더하면 합계가 예산을 넘는다. 이것은 표준 조건으로 계산한 예이며, 이번 실험에서 동시 전송을 측정한 결과는 아니다. 실제로 실행한 추가 대조는 같은 컨텍스트에서 40,000바이트를 두 번, 앞 요청의 응답 본문까지 읽은 뒤 차례로 보내는 경우였다. 둘 다 200과 40,000바이트 수신에 성공했다. 페이지에서 지금까지 보낸 양을 계속 누적하는 한도와는 구별된다.

작은 본문도 간헐적으로 실패한다면 같은 페이지에서 시작한 다른 keepalive 전송의 크기와 완료 시점을 함께 기록한다. 본문을 여러 조각으로 나눠 Promise.all()로 한꺼번에 보내는 방식은 이 합산 조건의 해결책이 되지 않는다.

큰 데이터는 페이지가 살아 있을 때 보낸다

관찰한 조건에 해당한다면 큰 원문은 페이지 사용 중 일반 fetch로 전송하고 응답을 확인한다. 종료 무렵의 keepalive에는 작은 식별자나 요약만 남기도록 계약을 바꾼다. 계속 keepalive가 필요하다면 최종 직렬화 결과를 작게 제한하고, 다른 진행 중 전송도 고려해 여유를 둔다. 이번에 65,536바이트 하나가 성공했다고 모든 호출에 그 크기를 채워 넣을 수 있다는 뜻은 아니다.

sendBeacon()으로 이름만 바꿔도 이 예산이 사라지지는 않는다. Beacon 편집 초안의 처리 모델 (새 탭에서 열림)은 keepalive를 사용하고 같은 진행 중 할당량 제한을 공유하며, 큐에 넣을 수 없으면 false를 반환하도록 설명한다. fetchLater()는 전송을 미뤄 예약하는 별도 API다. MDN의 fetchLater 설명 (새 탭에서 열림)은 별도 할당량 오류와 FetchLaterResult 반환을 설명한다. 이 API들의 수치나 반환값을 일반 keepalive fetch의 결과와 섞어 판단하지 않는다.

이번 실행은 페이지를 유지한 상태의 문자열 POST와 직접 연결한 loopback 서버에 한정된다. Firefox·Safari, 다른 origin, 프록시, multipart·스트림 본문, 진행 중 요청의 동시 합산, sendBeacon·fetchLater 호출은 실행하지 않았다. 페이지 종료나 브라우저 강제 종료 후의 전달도 검증하지 않았으므로, keepalive 성공을 종료 후 전달 보장으로 해석할 수는 없다.

← 전체 글 보기