JavaScript FormData 사용하기

예제 기준 · JavaScript : 브라우저 내장 FormData API (별도 라이브러리 없음)

FormData는 form의 데이터를 key/value 항목으로 다루는 API입니다. 일반 입력값과 같은 name의 여러 값, 파일을 담고 fetch의 body로 전달할 수 있습니다. 값을 읽거나 추가·변경·삭제하는 방법부터 multipart 전송까지 살펴보겠습니다.

각 절의 코드는 해당 예제의 HTML 요소가 만들어진 뒤 독립적으로 사용합니다. 결과는 pre#result에 표시하며, form이 있는 예제는 기본 제출로 페이지가 이동하지 않도록 submit 이벤트를 막습니다.

form에서 FormData 만들기

‘FormData 읽기’ #read는 form#sampleForm의 현재 값을 읽습니다. 입력란 #nickname은 name="nickname", 초기값은 Test User입니다. Apple과 Banana checkbox는 모두 name="fruit"이며 value는 각각 apple과 banana입니다. 처음에는 둘 다 체크되어 있습니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}
document.getElementById('sampleForm').addEventListener('submit', function (event) {
    event.preventDefault();
});
function displayData(data) {
    const rows = [];
    for (const entry of data.entries()) {
        rows.push(entry[0] + ' = ' + entry[1]);
    }
    if (rows.length === 0) {
        show('(항목 없음)');
    } else {
        show(rows.join('\n'));
    }
}
document.getElementById('read').addEventListener('click', function () {
    const data = new FormData(document.getElementById('sampleForm'));
    displayData(data);
});

entries()는 각 항목을 [key, value] 형태로 순회할 수 있게 합니다. 위 코드는 구조 분해 없이 entry[0]과 entry[1]을 읽습니다. 초기 상태에서는 다음 세 줄이 표시됩니다.

nickname = Test User
fruit = apple
fruit = banana

new FormData(form)은 생성 시점에 제출 대상으로 포함되는 유효한 form control을 수집합니다. 보통 name이 있어야 하며 disabled 상태가 아니어야 합니다. id는 화면에서 요소를 찾는 식별자이고, FormData의 key는 name입니다. name이 같으면 여러 항목이 같은 key로 들어갈 수 있습니다.

disabled인 입력 요소는 제외됩니다. disabled fieldset 안의 요소도 원칙적으로 제외되지만 첫 번째 legend 내부에는 예외가 있습니다. readonly 입력은 disabled와 달리 포함될 수 있습니다. 아래 예제의 입력 요소는 모두 활성 상태이며 disabled를 전환하는 버튼은 없습니다.

FormData를 만든 뒤 입력란을 바꿔도 기존 data가 자동 갱신되지는 않습니다. 위 코드는 버튼을 누를 때마다 새로 만들어 현재 상태를 읽습니다. FormData 생성만으로 서버 요청이나 입력값 유효성 검사가 실행되는 것도 아닙니다.

get()과 getAll()로 값 읽기

‘get / getAll 확인’ #read는 같은 name="fruit"의 값을 비교합니다. get()은 첫 번째 값 하나를, getAll()은 같은 key의 모든 값을 배열로 반환합니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}
document.getElementById('sampleForm').addEventListener('submit', function (event) {
    event.preventDefault();
});
document.getElementById('read').addEventListener('click', function () {
    const data = new FormData(document.getElementById('sampleForm'));
    show('get("fruit"): ' + JSON.stringify(data.get('fruit'))
        + '\ngetAll("fruit"): ' + JSON.stringify(data.getAll('fruit')));
});

둘 다 체크하면 get('fruit')는 "apple", getAll('fruit')는 ["apple","banana"]입니다. Apple만 해제하면 첫 번째 값은 "banana"가 됩니다. 둘 다 해제하면 fruit 항목이 없어 get()은 null, getAll()은 빈 배열 []을 반환합니다.

이 예제의 값은 문자열이지만 파일 항목은 File일 수 있습니다. JSON.stringify()는 여기서 문자열과 배열을 결과 화면에 표시하기 위한 용도로 사용합니다.

append()·set()·delete()로 값 수정하기

이 예제는 form 없이 빈 FormData를 만들고 fruit=apple로 시작합니다. ‘fruit=banana 추가’ #append, ‘fruit=orange로 교체’ #set, ‘fruit 삭제’ #delete와 ‘초기화’ #reset을 비교합니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}
function displayData(data) {
    const rows = [];
    for (const entry of data.entries()) {
        rows.push(entry[0] + ' = ' + entry[1]);
    }
    if (rows.length === 0) {
        show('(항목 없음)');
    } else {
        show(rows.join('\n'));
    }
}
let data = new FormData();
function reset() {
    data = new FormData();
    data.append('fruit', 'apple');
    displayData(data);
}
document.getElementById('append').addEventListener('click', function () {
    data.append('fruit', 'banana');
    displayData(data);
});
document.getElementById('set').addEventListener('click', function () {
    data.set('fruit', 'orange');
    displayData(data);
});
document.getElementById('delete').addEventListener('click', function () {
    data.delete('fruit');
    displayData(data);
});
document.getElementById('reset').addEventListener('click', reset);
reset();

append('fruit', 'banana')는 기존 apple을 남겨 두고 banana를 추가합니다. 같은 버튼을 여러 번 누르면 banana도 여러 번 추가됩니다. set('fruit', 'orange')는 같은 key의 기존 항목들을 하나의 orange 값으로 대체합니다. 두 메서드 모두 key가 없으면 새 항목을 만듭니다.

delete('fruit')는 fruit의 모든 항목을 삭제합니다. 초기화는 새 FormData를 만든 뒤 apple 하나를 넣습니다. 이 변경은 FormData 객체에 적용되며 HTML 입력 요소나 서버 데이터를 직접 바꾸지 않습니다. 문자열 또는 Blob/File 외의 값은 문자열로 변환되므로 객체를 자동으로 JSON으로 처리한다고 가정하지 않습니다.

체크된 checkbox만 포함하기

‘포함된 값 확인’ #read는 nickname과 체크된 fruit 항목을 entries()로 표시합니다. 체크하지 않은 checkbox는 false라는 값으로 들어가는 것이 아니라 항목 자체가 제외됩니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}
document.getElementById('sampleForm').addEventListener('submit', function (event) {
    event.preventDefault();
});
function displayData(data) {
    const rows = [];
    for (const entry of data.entries()) {
        rows.push(entry[0] + ' = ' + entry[1]);
    }
    if (rows.length === 0) {
        show('(항목 없음)');
    } else {
        show(rows.join('\n'));
    }
}
document.getElementById('read').addEventListener('click', function () {
    const data = new FormData(document.getElementById('sampleForm'));
    displayData(data);
});

둘 다 해제해도 nickname 입력값은 남아 있으므로 전체 결과가 ‘항목 없음’이 되지는 않습니다. 체크된 과일만 따로 사용하려면 같은 form에서 getAll('fruit')로 읽습니다.

const form = document.getElementById('sampleForm');
const data = new FormData(form);
const fruits = data.getAll('fruit');

if (fruits.length === 0) {
    console.log('선택된 과일이 없습니다.');
} else {
    for (let i = 0; i < fruits.length; i++) {
        console.log(fruits[i]);
    }
}

위 보조 코드는 선택값을 사용하는 방법이며, 아래 예제의 화면 출력은 전체 항목 목록입니다. checkbox에 value를 명시하면 서버에 전달할 값을 분명하게 정할 수 있습니다. 이 예제는 apple·banana를 사용하며, 체크되어 있어도 disabled인 항목은 제출 대상에서 제외됩니다.

파일을 File 객체로 다루기

‘테스트 파일 선택’ input#file은 type="file", name="attachment"입니다. 파일 하나를 선택하고 ‘File 정보 확인’ #read를 누르면 이름·크기·type을 표시합니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}
document.getElementById('sampleForm').addEventListener('submit', function (event) {
    event.preventDefault();
});
document.getElementById('read').addEventListener('click', function () {
    const input = document.getElementById('file');
    if (input.files.length === 0) {
        show('파일을 선택하세요.');
        return;
    }
    const data = new FormData(document.getElementById('sampleForm'));
    const file = data.get('attachment');
    let fileType = file.type;
    if (fileType === '') {
        fileType = '(알 수 없음)';
    }
    show('File 객체: ' + (file instanceof File) + '\n이름: ' + file.name
        + '\n크기: ' + file.size + ' bytes\ntype: ' + fileType);
});

input.files는 사용자가 선택한 파일 목록입니다. 이 예제는 파일이 없으면 ‘파일을 선택하세요.’를 표시하고 중단합니다. 파일을 선택한 상태에서 new FormData(form)을 만들면 attachment 항목에 File 객체가 들어갑니다. file.name은 파일명, file.size는 바이트 단위 크기, file.type은 브라우저가 제공하는 MIME type입니다.

type을 알 수 없으면 빈 문자열일 수 있으므로 ‘(알 수 없음)’으로 표시합니다. 파일 확장자나 type만으로 실제 파일 내용이 안전하거나 올바르다고 판단할 수는 없습니다.

이 예제는 파일을 업로드하거나 내용을 읽지 않고 메타데이터만 표시합니다. 파일 선택과 FormData 생성은 네트워크 전송과 별개입니다. 빈 FormData에 직접 담으려면, 파일 선택을 확인한 뒤 다음처럼 append()를 사용합니다.

const input = document.getElementById('file');

if (input.files.length === 0) {
    console.log('파일을 선택하세요.');
} else {
    const data = new FormData();
    data.append('attachment', input.files[0]);
    console.log(data.get('attachment'));
}

fetch로 FormData 전송하기

‘FormData 전송’ #run은 nickname과 체크된 fruit를 POST /api/playground/form으로 전송합니다. 이 전송 예제에는 파일 입력이 없으며, 받은 더미 필드를 응답으로 돌려줄 뿐 저장하지 않습니다. 상대 URL은 코드를 실행하는 문서의 origin을 기준으로 해석됩니다.

FormData는 JSON.stringify()를 거치지 않고 fetch의 body에 직접 전달합니다. 아래 코드는 일반 함수와 Promise로 요청 결과를 처리합니다. 실행 예제의 async/await 표현과 문법은 다르지만 전송 필드, endpoint, 상태·JSON 출력과 버튼 복구는 같습니다.

function show(value) {
    document.getElementById('result').textContent = String(value);
}

document.getElementById('sampleForm').addEventListener('submit', function (event) {
    event.preventDefault();
});

function showResponse(response) {
    return response.json().then(function (body) {
        show('ok=' + response.ok + ' | status=' + response.status + '\n'
            + JSON.stringify(body, null, 2));
    });
}

function showFailure(error) {
    show('요청 실패: ' + error.name + ' | ' + error.message);
}

const runButton = document.getElementById('run');

function finishRequest() {
    runButton.disabled = false;
}

runButton.addEventListener('click', function () {
    runButton.disabled = true;
    show('요청 중...');

    try {
        const data = new FormData(document.getElementById('sampleForm'));
        // Content-Type과 boundary는 브라우저가 함께 설정한다.
        const request = fetch('/api/playground/form', {
            method: 'POST',
            body: data
        });
        request.then(showResponse).catch(showFailure).finally(finishRequest);
    } catch (error) {
        showFailure(error);
        finishRequest();
    }
});

Content-Type: multipart/form-data를 직접 설정하지 않습니다. 브라우저가 본문을 multipart 형식으로 구성하면서 각 항목을 구분할 boundary와 그 값을 포함한 Content-Type을 함께 만듭니다. 헤더만 직접 지정하면 본문과 맞는 boundary가 누락되어 서버에서 해석하지 못할 수 있습니다.

초기 상태로 전송하면 응답의 nickname은 "Test User", fruit는 ["apple","banana"]이며 contentType에는 multipart/form-data와 boundary가 포함됩니다. boundary 문자열은 브라우저가 정하므로 고정값으로 비교하지 않습니다. nickname을 비우면 빈 문자열이 전송되고, 두 checkbox를 해제하면 요청의 fruit 항목 자체가 빠집니다.

response.json()도 Promise를 반환합니다. showResponse()가 이를 반환해야 본문 읽기와 표시가 끝난 뒤 finally에서 버튼이 복구됩니다. 전송 실패나 JSON 파싱 실패는 catch에서 처리합니다. 실행 예제는 response.ok와 response.status를 표시만 하며, HTTP 오류를 직접 throw하지 않습니다. 404·500 응답도 fetch 자체는 성공적으로 응답을 받을 수 있으므로 실제 서비스에서는 상태 검사 후 성공 처리를 분기해야 합니다.

파일을 함께 보내려면 서버가 받는 필드명에 맞게 File을 담고 해당 서버의 multipart 처리 기능을 사용해야 합니다. 앞 절의 파일 선택만으로 이 전송 예제에 파일이 자동 추가되지는 않습니다.

주요 메서드와 JSON 전송의 차이

메서드 동작 같은 key가 여러 개일 때
get(key) 첫 번째 값 반환, 없으면 null 값 하나만 읽음
getAll(key) 모든 값을 배열로 반환, 없으면 [] 전체 값을 읽음
append(key, value) 새 항목 추가 기존 값을 유지하고 추가
set(key, value) 값 설정 기존 항목들을 하나로 대체
delete(key) 항목 삭제 해당 key의 항목을 모두 삭제

일반 JSON API에는 서버 계약에 맞는 객체를 JSON.stringify()로 변환하고 Content-Type: application/json을 지정합니다. 파일과 텍스트를 함께 보내거나 같은 name의 여러 항목을 multipart로 전달해야 한다면 FormData를 사용할 수 있습니다. FormData 전송의 Content-Type은 브라우저에 맡깁니다.

FormData는 일반 객체와 달리 같은 key를 여러 번 유지하고 File도 담습니다. 단순히 JSON.stringify(data)로 JSON 요청을 만들 수 없으며, JSON이 필요한 경우 각 값을 읽어 서버가 기대하는 객체·배열 구조로 명시적으로 구성해야 합니다.

참고자료