FormData 전송 시 Content-Type 헤더를 설정하면 안 되는 이유

“왜 파일 업로드가 계속 실패하지?” - 많은 개발자들이 겪는 흔한 실수

🤔 문제 상황

파일 업로드 기능을 구현했는데 이유없이 실패하는 문제를 맞닥뜨렸다.

// 이렇게 했는데 왜 안 될까? 🤷‍♂️
const formData = new FormData()
formData.append('file', file)

axios.post('/upload', formData, {
  headers: {
    'Content-Type': 'multipart/form-data', // 이게 문제!
  },
})

서버에서는 400 Bad Request나 파일 파싱 오류가 발생하고, 몇 시간을 디버깅해도 원인을 찾기 어려웠다.

💡 해결책: 헤더를 아예 생략하세요

이것저것 찾아본 결과 내가 찾은 해결책은 정말 파격적이었다. formData를 보낼 때마다 당연히 헤더를 설정했는데 말이다.

// ✅ 올바른 방법
const formData = new FormData()
formData.append('file', file)
formData.append('description', 'My awesome file')

axios.post('/upload', formData)
// 끝! 이게 전부입니다.

🔍 왜 이런 일이 생길까?

Boundary의 비밀

multipart/form-data로 데이터를 전송할 때, 각 필드를 구분하기 위해 boundary라는 특별한 구분자가 필요하다.

아래와 같이 boundary가 없는 경우 서버는 어떤 문자열로 필드를 구분해야 하는지 모르게 된다.

const formData = new FormData()
formData.append('username', 'john_doe')
formData.append('email', 'john@example.com')
formData.append('bio', 'I love coding!')
Content-Disposition: form-data; name="username"
john_doe
Content-Disposition: form-data; name="email"
john@example.com
Content-Disposition: form-data; name="bio"
I love coding!

아래와 같이 boundary가 있는 경우 서버는 어떤 문자열로 필드를 구분해야 하는지 알 수 있다.

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg

[파일 바이너리 데이터]
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="description"

My awesome file
------WebKitFormBoundary7MA4YWxkTrZu0gW--

여기서 ------WebKitFormBoundary7MA4YWxkTrZu0gW가 바로 boundary다.

// ❌ 이렇게 하면...
headers: {
  'Content-Type': 'multipart/form-data'  // boundary가 없음!
}

하지만 내가 헤더를 수동으로 설정하면 boundary가 포함되지 않는다. 그래서 서버는 “어떤 문자열로 필드를 구분해야 하는지” 모르게 된다. 마치 책에서 페이지 번호가 없는 것과 같다.

🛠 올바른 동작 원리

자동 설정 과정

  1. FormData 감지: axios가 요청 본문이 FormData 객체임을 인식
  2. Boundary 생성: 고유한 랜덤 문자열 생성 (예: ----formdata-axios-0.27.2-1234567890)
  3. 헤더 설정: Content-Type: multipart/form-data; boundary=----formdata-axios-0.27.2-1234567890
  4. 데이터 구성: 생성된 boundary로 각 필드를 구분하여 전송

개발자 도구에서 확인해보기

const formData = new FormData()
formData.append('file', file)

axios.post('/upload', formData)

Network 탭에서 Request Headers를 보면:

Content-Type: multipart/form-data; boundary=----formdata-axios-0.27.2-16789012345

🌐 다른 HTTP 클라이언트들도 마찬가지

이는 axios만의 특별한 기능이 아니다. 모든 HTTP 클라이언트에서 동일하다.

Fetch API

// ✅ 올바른 방법
fetch('/upload', {
  method: 'POST',
  body: formData, // Content-Type 자동 설정
})

cURL

# ✅ 자동으로 boundary 설정
curl -X POST -F "file=@photo.jpg" http://example.com/upload

Node.js

// Node.js에서 form-data 라이브러리 사용 시
const FormData = require('form-data')
const form = new FormData()
form.append('file', fs.createReadStream('file.txt'))

axios.post('/upload', form, {
  headers: {
    ...form.getHeaders(), // 올바른 헤더 자동 생성
  },
})

왜 모든 클라이언트가 이렇게 동작하는가?

HTTP 표준 (RFC 7578)에서 정의:

  1. multipart/form-data는 반드시 boundary 파라미터가 필요
  2. Boundary는 고유하고 예측 불가능한 문자열이어야 함
  3. 각 구현체(브라우저, 라이브러리)가 자동으로 생성하도록 설계

🎯 핵심 정리

  1. FormData 사용 시 Content-Type 헤더를 생략하기
  2. HTTP 클라이언트가 자동으로 올바른 boundary와 함께 설정한다
  3. 이는 웹 표준(RFC 7578)을 따르는 모든 구현체의 공통 동작이다

Written by@Donghoon Song
사람들의 꿈을 이어주는 코멘토에서 일하고 있습니다.

InstagramGitHubTwitterLinkedIn