파일 업로드를 처리할 때마다 복잡한 문자열 조합이나 라이브러리에 의존했던 경험이 있을까. 대부분의 개발자들은 FormData 객체가 존재한다는 것은 알지만, 정확히 어떤 상황에서 어떻게 써야 하는지 모른 채 구글링한 코드를 그냥 복사해 붙인다. 이번에는 FormData API가 정확히 뭔지, 일반 객체 전송과 뭐가 다른지, 실제로 파일을 어떻게 처리하는지 완벽하게 정리해서 소개하겠다.
웹 애플리케이션에서 서버로 데이터를 보낼 때, JSON 형식을 쓰는 게 가장 간단하다. 그런데 파일이 포함되면 달라진다. JSON에는 파일 바이너리 데이터를 직접 담을 수 없기 때문이다. 이 문제를 해결하기 위해 HTTP는 multipart/form-data 인코딩 방식을 제공한다. FormData API는 이 복잡한 인코딩 형식을 JavaScript에서 자동으로 처리해주는 도구다.
FormData 객체는 키-값 쌍으로 폼 필드와 파일을 저장했다가, fetch나 XMLHttpRequest로 요청을 보낼 때 브라우저가 자동으로 multipart/form-data 형식으로 변환해서 전송한다. 직접 헤더를 조작하거나 바운더리 문자열을 만들 필요가 없다.
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 속성이 반드시 있어야 한다.
파일 입력 필드에서 선택한 파일을 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가 자동으로 바이너리 처리를 한다.
<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);
}
});
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)
});
});
✗ 잘못된 코드. 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가 자동 처리)
});
FormData 객체는 일회용이 아니므로 여러 번 재사용할 수 있다. 다만 append()로 추가한 데이터는 누적되므로, 같은 필드를 다시 추가하려면 기존 값을 먼저 제거해야 한다.
const formData = new FormData();
formData.append('file', file1);
formData.append('file', file1); // 같은 파일이 두 번 추가됨
// ✓ 기존 값 제거 후 새로 추가
formData.delete('file'); // 모든 'file' 키 제거
formData.append('file', file2);
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);
}
| 구분 | 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 객체 그대로 추가하기, 재사용 전 기존 데이터 정리하기 같은 작은 주의사항들이 모여서 안정적인 업로드 경험을 만든다. 이 글의 실전 예제를 참고해서 직접 파일 업로드 기능을 구현해보면, 향후 비슷한 상황에서 자신 있게 대응할 수 있을 것이다.