빌드노트
FormData를 보냈는데 서버가 multipart 경계를 찾지 못한다
FormData 전송에 수동 Content-Type을 넣으면 boundary가 누락되거나 본문과 달라질 수 있다. 실제 브라우저의 네 요청에서 헤더·경계 바이트·파싱 결과를 대조하고, 브라우저가 multipart 헤더를 만들도록 수정하는 방법을 설명한다.
fetch의 본문에 FormData를 넣었는데 파일과 일반 필드를 모두 읽지 못한다면, 직접 추가한 Content-Type: multipart/form-data부터 확인한다. 이 헤더에는 각 파트를 나누는 boundary 값도 필요하다. FormData를 본문으로 보낼 때는 Content-Type을 브라우저가 만들도록 맡긴다. 미디어 타입 이름만 직접 적으면 본문에 경계선이 있어도 헤더에는 그 값이 빠질 수 있다.
기존 업로드 함수에서 수동 Content-Type 설정을 제거한다. 아래는 합성 텍스트 파일과 필드 하나를 보내는 형태다. 브라우저에서 실행하며, /upload는 실행 중인 같은 출처의 로컬 테스트 업로드 경로로 바꾼다. 서버가 받는 필드명이 다르면 title과 file도 맞춰야 한다.
const data = new FormData();
data.append('title', 'boundary-demo');
data.append('file', new Blob(['boundary probe\n'], {
type: 'text/plain'
}), 'probe.txt');
const response = await fetch('/upload', {
method: 'POST',
body: data
});
console.log(response.status);
공통 요청 함수가 기본 헤더를 합친다면 그 경로도 확인한다. 호출부에서 한 줄을 지워도 공통 함수가 다시 multipart/form-data나 application/json을 넣으면 해결되지 않는다. 필요한 다른 헤더는 유지하되, 이 FormData 요청의 최종 헤더에서 수동 Content-Type을 제외한다.
MDN의 FormData 안내 (새 탭에서 열림)는 fetch나 XMLHttpRequest로 FormData를 보낼 때 Content-Type을 직접 설정하지 말라고 설명한다. Fetch 표준의 본문 변환 규칙 (새 탭에서 열림)은 FormData로부터 multipart 본문과 boundary가 붙은 타입을 생성한다. 다만 Request 생성 규칙 (새 탭에서 열림)은 이미 Content-Type이 있으면 그 타입을 헤더에 추가하지 않는다. 직접 쓴 헤더에 브라우저가 빠진 boundary를 보충해 주리라고 기대하면 안 된다.
본문 첫 줄은 같아 보여도 헤더와의 짝이 달랐다
운영 장애 기록 대신 새 브라우저 프로필과 loopback 서버로 비교했다. Windows에서 Chrome 156.0.8078.47을 사용했고, 서버는 Node.js 22.23.2의 내장 Response.formData()로 수신한 헤더와 본문 바이트를 파싱했다. 위와 같은 필드와 Blob을 넣은 동일한 FormData 객체를 각 조건에서 다시 전송했다.
수동 헤더를 넣은 요청은 다음과 같았다. 다른 HTTP 헤더와 첫 경계선 뒤의 파트 내용은 생략했다.
Content-Type: multipart/form-data
------WebKitFormBoundaryLHBPpr9fNXzO0Ne2
서버에는 303바이트가 도착했다. 본문 안에는 title 필드, probe.txt 파일의 내용과 파트 구분선이 모두 있었지만, 헤더에는 그 구분선의 값을 알려 주는 boundary=가 없었다. 파서는 TypeError: Failed to parse body as FormData.를 냈다. 파일 내용을 보내지 못한 실패와는 구별해야 한다.
다음 요청에서는 수동 Content-Type만 생략했다. 이번에 서버가 받은 헤더와 본문 첫 줄은 다음과 같았다.
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary45fZ7JOWxkGHxm96
------WebKitFormBoundary45fZ7JOWxkGHxm96
RFC 7578 §4.1 (새 탭에서 열림)은 Content-Type의 boundary 매개변수와 본문 파트 구분선의 관계를 정한다. 위 요청에서는 헤더 값 ----WebKitFormBoundary45fZ7JOWxkGHxm96 앞에 --를 붙인 것이 본문의 경계선이었다. 서버가 받은 첫 줄 끝의 바이트는 0d 0a, 즉 CRLF였고, 마지막 경계선에는 뒤에도 --가 붙었다. 헤더와 본문을 비교할 때 이 구분용 하이픈까지 boundary 값의 일부로 오인하지 않는다.
정상 요청도 본문은 303바이트였지만 파싱 결과는 달랐다. 일반 필드 title은 boundary-demo였고, file은 이름 probe.txt, 타입 text/plain, 크기 15바이트로 읽혔다. 파일 내용도 줄바꿈을 포함한 boundary probe\n와 같았다. 이 비교에서 문제를 가른 것은 전송량이 아니라 헤더에 선언한 경계와 실제 본문 경계의 일치 여부였다.
boundary를 아무 값으로 채워도 해결되지 않았다
추가로 헤더에 boundary=declared-but-not-encoded를 직접 넣어 보냈다. 본문 첫 경계선은 ------WebKitFormBoundaryaf0zCEcZgzTKqpAi였다. 헤더가 가리키는 구분선과 달랐고, 같은 파싱 오류가 났다. FormData의 경계 문자열을 헤더 값에 맞춰 바꾼 결과는 아니었다.
수동 헤더를 다시 생략한 네 번째 요청은 성공했다. 이때 생성된 boundary도 앞의 정상 요청과 달랐다. 로그에서 성공한 문자열을 복사하거나 고정 boundary를 정답처럼 넣지 않는다. 이번의 303바이트와 WebKitFormBoundary 모양도 이 브라우저·입력에서 관찰한 값이다. 다른 브라우저나 입력의 고정 규칙으로 쓰지 않는다.
실험 서버는 파싱 예외를 잡아 400을 반환하도록 만들었고, 성공 시에는 200을 반환했다. 네 요청의 상태는 차례로 400, 200, 400, 200이었다. 실제 서버가 같은 상태 코드나 오류 문구를 반환해야 한다는 뜻은 아니다.
수정 뒤에는 실제로 나간 요청을 확인한다
로컬 테스트 화면의 개발자 도구에서 Network를 열고 업로드를 한 번 실행한다. 실패한 POST 요청의 Request Headers에서 Content-Type 전체를 읽는다. multipart/form-data만 있다면 우선 직접 설정한 헤더를 없앤다. 수정 후에는 multipart/form-data; boundary=…가 전송되는지 확인한다. 소스 코드에서 설정을 지운 사실과 최종 요청에서 빠진 사실은 다를 수 있다.
boundary가 이미 있다면, 합성 요청을 받는 테스트 서버에서 파싱 전 원문을 확보해 본문 첫 구분선과 비교한다. 헤더 값이 따옴표로 둘러싸여 있으면 따옴표를 제외한 값 앞에 --를 붙여 대조한다. 개발자 도구의 Payload가 필드 목록만 보여 준다면 그것만으로 경계 바이트까지 일치한다고 판단하지 않는다. 이번 실험도 서버가 받은 원문 바이트를 따로 읽어 확인했다.
헤더와 경계가 일치하는데 특정 필드만 없다면 FormData의 필드명과 서버가 기대하는 이름을 확인한다. 경계 파싱은 성공했지만 크기 제한이나 서버의 파일 처리 단계에서 거절됐다면 그 오류를 따로 좁힌다. 같은 FormData에서 수동 헤더 제거 전후를 비교해도 결과가 바뀌지 않았다면, 이 글의 원인으로 확정할 수 없다.
확인한 범위는 같은 출처의 loopback HTTP 요청과 작은 합성 Blob이다. 실제 API, 프록시를 통과하는 업로드, 다른 서버 프레임워크와 브라우저는 실행하지 않았다. 서버별 오류 문구까지 일반화할 수는 없지만, 이 실험에서는 본문이 도착한 요청도 헤더의 boundary 누락·불일치 때문에 파싱에 실패하는 것을 확인했다.