콘텐츠로 건너뛰기

fetch/axios를 사용한 파일 업로드 구현 방법|FormData・프로그레스 바・에러 처리

카테고리: JavaScript·프런트엔드
이 기사는 현재 일본어로만 제공됩니다. 번역본은 순차적으로 공개될 예정입니다.

최신 웹 애플리케이션에서 파일 업로드를 구현할 때, <code>fetch</code> API 또는 <code>axios</code>를 사용하여 비동기적으로 파일을 전송하는 구현이 표준이 되었습니다. 단순 전송만이라면 몇 줄로 구현할 수 있지만, 프로그레스 바, 에러 처리, 대용량 파일 청킹, CORS 지원까지 포함하면 고려해야 할 사항이 많습니다. 본 문서에서는 이들을 체계적으로 설명합니다.

fetch / axios / FormData 기능 비교 기능 지원 비교 fetch axios XHR + FormData POST送信 OK OK OK JSON自動シリアライズ - OK - 進捗イベント (upload) × OK OK リクエスト中断 AbortController CancelToken xhr.abort() タイムアウト manual config xhr.timeout インターセプター - OK - 진행률 표시줄이 필요하면 axios 또는 XHR 선택
그림 1: fetch / axios / XHR+FormData 기능 지원 비교

FormData 사용 방법 (append / set)

<code>FormData</code>는 HTML 폼의 <code>enctype="multipart/form-data"</code>와 동등한 데이터를 JavaScript로 구축하기 위한 API입니다. 파일뿐만 아니라 텍스트 필드도 포함한 폼 데이터 전체를 다룰 수 있습니다.

// input[type="file"] からファイルを取得
const fileInput = document.querySelector('#file-input');
const file = fileInput.files[0];

// FormData を作成
const formData = new FormData();

// append: 同名のキーで複数の値を追加できる
formData.append('file', file);
formData.append('file', anotherFile); // 複数ファイルを同じキーで送れる
formData.append('description', 'アップロードテスト');
formData.append('userId', '12345');

// set: 既存のキーがあれば上書き(追加ではなく置換)
formData.set('file', newFile); // 既存の 'file' エントリをすべて削除して置換

// 中身の確認
for (const [key, value] of formData.entries()) {
    console.log(key, value);
}

// 複数ファイルを input[type="file" multiple] から追加する場合
const multipleFiles = fileInput.files;
Array.from(multipleFiles).forEach(f => {
    formData.append('files[]', f);
});

fetch를 사용한 파일 업로드

<code>fetch</code>로 FormData를 전송할 때 중요한 점은 <strong>Content-Type 헤더를 수동으로 설정하지 않는다</strong>는 것입니다.

async function uploadWithFetch(file) {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('name', file.name);

    // NG: Content-Type を手動で設定してはいけない
    // fetch('/upload', {
    //     method: 'POST',
    //     headers: { 'Content-Type': 'multipart/form-data' }, // ← これは誤り!
    //     body: formData,
    // });

    // OK: Content-Type は fetch が自動で設定する(boundary パラメータも含む)
    const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
        // headers は指定しない(または Content-Type 以外のヘッダーのみ指定)
    });

    if (!response.ok) {
        const error = await response.json().catch(() => ({ message: 'Unknown error' }));
        throw new Error(`Upload failed: ${response.status} - ${error.message}`);
    }

    return response.json();
}

// 使用例
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;

    try {
        const result = await uploadWithFetch(file);
        console.log('アップロード成功:', result);
    } catch (err) {
        console.error('アップロード失敗:', err.message);
    }
});

Content-Type을 수동으로 설정하면 안 되는 이유: <code>multipart/form-data</code> 요청에는 <code>boundary</code> 매개변수(예: <code>boundary=----WebKitFormBoundaryXXX</code>)가 필요하며, 이는 브라우저가 FormData를 기반으로 자동 생성합니다. 수동으로 <code>Content-Type: multipart/form-data</code>를 설정하면 boundary가 누락되어 서버에서 파싱할 수 없게 됩니다.

fetch를 사용한 진행 표시(ReadableStream)

fetch API에서는 응답 수신 진행 상황을 <code>ReadableStream</code>으로 얻을 수 있지만, <strong>업로드 진행 상황은 fetch 단독으로는 얻을 수 없습니다</strong>. 업로드 진행률이 필요한 경우 axios 또는 XMLHttpRequest를 사용합니다.

// fetch でダウンロード進捗を取得する例(アップロードとは逆方向)
async function downloadWithProgress(url, onProgress) {
    const response = await fetch(url);
    const contentLength = response.headers.get('Content-Length');
    const total = parseInt(contentLength, 10);
    let loaded = 0;

    const reader = response.body.getReader();
    const chunks = [];

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        chunks.push(value);
        loaded += value.length;
        onProgress(loaded, total);
    }

    return new Blob(chunks);
}

axios를 이용한 업로드 + onUploadProgress로 진행률 표시줄 구현

axios는 <code>onUploadProgress</code> 콜백으로 업로드 진행 상황을 얻을 수 있습니다. XMLHttpRequest를 래핑하고 있으므로 브라우저의 업로드 진행 이벤트를 활용할 수 있습니다.

import axios from 'axios';

async function uploadWithAxios(file, onProgress) {
    const formData = new FormData();
    formData.append('file', file);

    const response = await axios.post('/api/upload', formData, {
        headers: {
            // axios は FormData を自動検出して Content-Type を設定するが、
            // 明示的に undefined を指定して axios に任せることもできる
        },
        onUploadProgress: (progressEvent) => {
            if (progressEvent.total) {
                const percent = Math.round(
                    (progressEvent.loaded * 100) / progressEvent.total
                );
                onProgress(percent);
            }
        },
        // タイムアウト設定(ミリ秒)
        timeout: 300000, // 5分
    });

    return response.data;
}

// プログレスバーと組み合わせた使用例
const progressBar = document.querySelector('#progress-bar');
const progressText = document.querySelector('#progress-text');

async function handleUpload(file) {
    try {
        const result = await uploadWithAxios(file, (percent) => {
            progressBar.style.width = `${percent}%`;
            progressBar.setAttribute('aria-valuenow', percent);
            progressText.textContent = `${percent}%`;
        });
        console.log('完了:', result);
    } catch (err) {
        if (axios.isAxiosError(err)) {
            console.error('ステータス:', err.response?.status);
            console.error('メッセージ:', err.response?.data?.message);
        } else {
            console.error('予期しないエラー:', err);
        }
    }
}

대용량 파일의 청크 분할 업로드

단일 요청으로 전송할 수 없는 크기의 파일(수백MB~GB)은 청크(작은 단위)로 분할하여 전송할 수 있습니다. 서버 측에서 각 청크를 수신한 후 마지막에 결합합니다.

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MiB ずつ分割

async function uploadInChunks(file, onProgress) {
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    const uploadId = crypto.randomUUID(); // アップロードセッションID

    for (let i = 0; i < totalChunks; i++) {
        const start = i * CHUNK_SIZE;
        const end = Math.min(start + CHUNK_SIZE, file.size);
        const chunk = file.slice(start, end);

        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('uploadId', uploadId);
        formData.append('chunkIndex', String(i));
        formData.append('totalChunks', String(totalChunks));
        formData.append('filename', file.name);

        await fetch('/api/upload/chunk', {
            method: 'POST',
            body: formData,
        });

        // 各チャンク送信後に進捗を更新
        const percent = Math.round(((i + 1) / totalChunks) * 100);
        onProgress(percent);
    }

    // 全チャンク送信後にサーバーへ結合を指示
    const response = await fetch('/api/upload/complete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ uploadId, filename: file.name }),
    });

    return response.json();
}

에러 처리(413 / 네트워크 에러 / 타임아웃)

파일 업로드 중 발생할 수 있는 오류를 패턴별로 처리합니다.

async function robustUpload(file) {
    const formData = new FormData();
    formData.append('file', file);

    try {
        const response = await fetch('/api/upload', {
            method: 'POST',
            body: formData,
            signal: AbortSignal.timeout(60000), // 60秒でタイムアウト
        });

        // ステータスコード別のエラー処理
        if (response.status === 413) {
            throw new Error('ファイルサイズが大きすぎます。サーバーの上限を超えています。');
        }

        if (response.status === 415) {
            throw new Error('サポートされていないファイル形式です。');
        }

        if (response.status === 422) {
            const data = await response.json();
            throw new Error(`バリデーションエラー: ${data.message}`);
        }

        if (!response.ok) {
            throw new Error(`サーバーエラー: ${response.status} ${response.statusText}`);
        }

        return await response.json();

    } catch (err) {
        if (err.name === 'AbortError' || err.name === 'TimeoutError') {
            throw new Error('アップロードがタイムアウトしました。ネットワーク接続を確認してください。');
        }

        if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
            throw new Error('ネットワークエラーが発生しました。接続を確認してください。');
        }

        throw err; // それ以外は再スロー
    }
}

CORS 설정 포인트

다른 오리진(도메인·포트)의 API로 업로드할 경우 CORS(Cross-Origin Resource Sharing) 설정이 필요합니다. multipart/form-data를 포함하는 POST 요청은 "단순 요청"이 아니며 프리플라이트 요청(OPTIONS 메서드)이 발생합니다.

// フロントエンド側:特別な設定は不要(ブラウザが自動でプリフライトを送信)
const response = await fetch('https://api.example.com/upload', {
    method: 'POST',
    body: formData,
    // credentials: 'include', // クッキーを含む場合(サーバー側で credentials: true が必要)
});
# Nginx でのCORS設定例
location /api/upload {
    # プリフライトリクエスト(OPTIONS)への応答
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com';
        add_header 'Access-Control-Allow-Methods' 'POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
        add_header 'Access-Control-Max-Age' '86400';
        add_header 'Content-Length' '0';
        return 204;
    }

    add_header 'Access-Control-Allow-Origin' 'https://frontend.example.com';
    proxy_pass http://app_backend;
}

파일 업로드와 관련하여 CORS에서 특히 주의가 필요한 점:

  • <code>Access-Control-Allow-Headers</code>에 <code>Content-Type</code>이 포함되어 있으면 브라우저가 Content-Type을 자유롭게 설정할 수 있다고 오해될 수 있습니다
  • FormData의 경우 Content-Type 헤더를 사용자 정의 설정하지 않았으므로 preflight가 발생하지 않을 수도 있습니다.
  • <code>Authorization</code> 헤더로 인증 토큰을 보낼 때는 프리플라이트 요청이 반드시 발생합니다.

이 기사에서 사용할 수 있는 테스트 파일 (무료)

  • → <a href="/ja/files/threshold/" class="text-primary-600 dark:text-primary-400 hover:underline">경계값 테스트 파일 목록</a> — 다양한 크기의 파일로 fetch/axios 동작 검증
  • → <a href="/ja/files/images/" class="text-primary-600 dark:text-primary-400 hover:underline">테스트 이미지 목록</a> — JPEG, PNG, WebP 등 다양한 형식의 이미지로 업로드 테스트

관련 기사

  • → <a href="/ja/blog/php-file-upload/" class="text-primary-600 dark:text-primary-400 hover:underline">PHP에서 파일 업로드를 구현하는 방법 | 검증・저장・보안 완벽 가이드</a>
  • → <a href="/ja/blog/multipart-form-data-overhead/" class="text-primary-600 dark:text-primary-400 hover:underline">multipart/form-data 의 오버헤드를 정확하게 계산하기</a>
  • → <a href="/ja/blog/file-validation-checklist/" class="text-primary-600 dark:text-primary-400 hover:underline">웹 폼 파일 검증 구현 체크리스트</a>

자주 묻는 질문

fetch로 파일을 업로드할 때 Content-Type을 설정해야 할까요?

아니요, FormData를 사용하는 경우 <code>Content-Type</code> 헤더를 수동으로 설정하지 마십시오. 브라우저가 자동으로 <code>multipart/form-data</code>와 <code>boundary</code>를 설정합니다.

axios로 업로드 진행 상황(진행률)을 표시하려면?

axios의 onUploadProgress 콜백을 사용합니다. event.loaded / event.total로 진행 비율을 계산할 수 있습니다.

fetch와 axios 중 어느 것을 사용해야 할까요?

브라우저 표준 fetch로도 충분하지만, 업로드 진행률을 표시해야 하는 경우 axios가 편리합니다. fetch의 ReadableStream으로도 구현 가능하지만 코드가 복잡해집니다.