파일 업로드를 처리할 때마다 복잡한 문자열 조합이나 라이브러리에 의존했던 경험이 있을까. 대부분의 개발자들은 FormData 객체가 존재한다는 것은 알지만, 정확히 어떤 상황에서 어떻게 써야 하는지 모른 채 구글링한 코드를 그냥 복사해 붙인다. 이번에는 FormData API가 정확히 뭔지, 일반 객체 전송과 뭐가 다른지, 실제로 파일을 어떻게 처리하는지 완벽하게 정리해서 소개하겠다.

 

FormData API가 필요한 이유

웹 애플리케이션에서 서버로 데이터를 보낼 때, JSON 형식을 쓰는 게 가장 간단하다. 그런데 파일이 포함되면 달라진다. JSON에는 파일 바이너리 데이터를 직접 담을 수 없기 때문이다. 이 문제를 해결하기 위해 HTTP는 multipart/form-data 인코딩 방식을 제공한다. FormData API는 이 복잡한 인코딩 형식을 JavaScript에서 자동으로 처리해주는 도구다.

FormData 객체는 키-값 쌍으로 폼 필드와 파일을 저장했다가, fetch나 XMLHttpRequest로 요청을 보낼 때 브라우저가 자동으로 multipart/form-data 형식으로 변환해서 전송한다. 직접 헤더를 조작하거나 바운더리 문자열을 만들 필요가 없다.

 

FormData 기본 사용법

 

1. FormData 객체 생성 방법

FormData는 두 가지 방식으로 만들 수 있다. 빈 객체를 생성한 뒤 데이터를 추가하거나, 기존 HTML form 요소에서 직접 생성하는 것이다.

// 방법 1: 빈 FormData 생성 후 수동으로 데이터 추가
const formData = new FormData();
formData.append('name', 'John Doe');
formData.append('email', 'john@example.com');

// 방법 2: 기존 HTML form 요소에서 자동 생성
const form = document.querySelector('#uploadForm');
const formData = new FormData(form);

// 결과: 둘 다 키-값 쌍으로 저장된 FormData 객체

두 번째 방법이 편하다. form 요소 안의 모든 입력 필드가 자동으로 FormData에 포함되기 때문이다. 단, form 요소의 name 속성이 반드시 있어야 한다.

 

2. FormData에 파일 추가하기

파일 입력 필드에서 선택한 파일을 FormData에 추가하는 방법은 다음과 같다.

// ✗ 잘못된 방법: File 객체를 직접 문자열로 변환하려고 함
const fileInput = document.querySelector('input[type="file"]');
const formData = new FormData();
formData.append('file', String(fileInput.files[0])); // 결과: [object File]

// ✓ 올바른 방법: File 객체 그대로 추가
const formData = new FormData();
formData.append('file', fileInput.files[0]);

// 여러 개 파일 추가 (다중 파일 업로드)
const files = fileInput.files;
for (let i = 0; i < files.length; i++) {
  formData.append('files', files[i]); // 같은 key로 여러 파일 추가 가능
}

File 객체는 Blob의 상속 타입이므로 그대로 append()에 넘기면 된다. FormData가 자동으로 바이너리 처리를 한다.

 

실전 파일 업로드 예제

 

HTML 폼과 JavaScript 연동
<form id="uploadForm">
  <input type="text" name="title" placeholder="제목">
  <input type="file" name="profileImage" accept="image/*">
  <input type="file" name="documents" multiple>
  <button type="submit">업로드</button>
</form>

 

const form = document.querySelector('#uploadForm');

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  
  // form 요소에서 직접 FormData 생성 (모든 필드 자동 포함)
  const formData = new FormData(form);
  
  try {
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
      // Content-Type 헤더를 명시하지 않음 (fetch가 자동으로 설정)
    });
    
    if (response.ok) {
      const result = await response.json();
      console.log('업로드 성공:', result);
    } else {
      console.error('업로드 실패:', response.status);
    }
  } catch (error) {
    console.error('요청 오류:', error);
  }
});

 

서버 측 처리 (Node.js/Express)

JavaScript에서 FormData로 전송한 multipart 요청을 받으려면 서버 측에서도 multipart 파싱 미들웨어가 필요하다.

const express = require('express');
const multer = require('multer');
const app = express();

// multer 설정: 업로드된 파일을 /uploads 폴더에 저장
const upload = multer({ dest: 'uploads/' });

// FormData에서 파일과 텍스트 필드를 모두 받기
app.post('/api/upload', upload.single('profileImage'), (req, res) => {
  console.log('텍스트 필드:', req.body.title);
  console.log('파일:', req.file); // { filename, path, mimetype, size 등 }
  
  res.json({
    success: true,
    filename: req.file.filename,
    title: req.body.title
  });
});

// 여러 파일 받기
app.post('/api/upload-multi', upload.array('documents'), (req, res) => {
  console.log('업로드된 파일 개수:', req.files.length);
  
  res.json({
    success: true,
    files: req.files.map(f => f.filename)
  });
});

 

주의사항과 흔한 실수

 

실수 1: Content-Type 헤더를 직접 설정

✗ 잘못된 코드. FormData를 보낼 때 Content-Type 헤더를 명시하면 안 된다. 브라우저가 자동으로 multipart/form-data와 바운더리 문자열을 설정해야 하는데, 강제로 헤더를 지정하면 파싱 오류가 난다.

// ✗ 절대 금지
const formData = new FormData();
formData.append('file', file);

fetch('/api/upload', {
  method: 'POST',
  headers: {
    'Content-Type': 'multipart/form-data' // 이렇게 하면 안 됨!
  },
  body: formData
});

✓ 올바른 코드. fetch에 body에 FormData를 그냥 넘기면, 브라우저가 자동으로 올바른 Content-Type과 바운더리를 설정한다.

// ✓ 올바름
fetch('/api/upload', {
  method: 'POST',
  body: formData
  // Content-Type 헤더 제거 (fetch가 자동 처리)
});

 

실수 2: 파일 업로드 후 폼 데이터 재사용

FormData 객체는 일회용이 아니므로 여러 번 재사용할 수 있다. 다만 append()로 추가한 데이터는 누적되므로, 같은 필드를 다시 추가하려면 기존 값을 먼저 제거해야 한다.

const formData = new FormData();
formData.append('file', file1);
formData.append('file', file1); // 같은 파일이 두 번 추가됨

// ✓ 기존 값 제거 후 새로 추가
formData.delete('file'); // 모든 'file' 키 제거
formData.append('file', file2);

 

실수 3: FormData의 내용 확인 불가

FormData 객체는 console.log()로 출력해도 내용이 명확하지 않다. 디버깅할 때는 entries() 메서드를 써서 키-값을 확인하자.

const formData = new FormData();
formData.append('name', 'John');
formData.append('file', fileObj);

// ✗ 이렇게 하면 FormData {} 만 출력
console.log(formData);

// ✓ 올바른 디버깅 방법
for (const [key, value] of formData.entries()) {
  console.log(key, value);
}

 

FormData와 JSON의 차이점
구분 JSON FormData
파일 업로드 불가능 가능
이진 데이터 Base64 인코딩 필요 직접 전송 가능
Content-Type application/json multipart/form-data
크기 오버헤드 낮음 바운더리 문자열로 인해 약간 높음
HTML form 호환 없음 있음

 

진행 상황 추적과 성능 최적화

FormData로 대용량 파일을 업로드할 때는 진행 상황을 추적하고 싶을 수 있다. fetch API로는 업로드 진행률을 직접 얻을 수 없지만, XMLHttpRequest의 upload 이벤트를 쓰면 가능하다.

const file = document.querySelector('input[type="file"]').files[0];
const formData = new FormData();
formData.append('file', file);

const xhr = new XMLHttpRequest();

// 업로드 진행 상황 추적
xhr.upload.addEventListener('progress', (e) => {
  if (e.lengthComputable) {
    const percentComplete = (e.loaded / e.total) * 100;
    console.log('진행률:', percentComplete + '%');
  }
});

xhr.addEventListener('load', () => {
  if (xhr.status === 200) {
    console.log('업로드 완료:', xhr.responseText);
  }
});

xhr.open('POST', '/api/upload');
xhr.send(formData);

 

정리

FormData API는 파일 업로드와 폼 데이터 전송의 핵심 도구다. 별도 라이브러리 없이 JavaScript만으로 multipart/form-data 인코딩을 자동 처리해주므로, 파일이 포함된 요청을 다룰 때 반드시 알아야 한다. Content-Type 헤더를 직접 설정하지 않기, File 객체 그대로 추가하기, 재사용 전 기존 데이터 정리하기 같은 작은 주의사항들이 모여서 안정적인 업로드 경험을 만든다. 이 글의 실전 예제를 참고해서 직접 파일 업로드 기능을 구현해보면, 향후 비슷한 상황에서 자신 있게 대응할 수 있을 것이다.