본문 바로가기

React

React에서 다중 파일 드래그 앤 드롭 업로드 및 진행률 표시하기

현업에서 파일 업로드는 사용자 경험과 신뢰도를 좌우합니다. 이 글은 React에서 다중 파일 드래그 앤 드롭, 개별 진행률, 전체 진행률, 업로드 취소, 동시 업로드 제어까지 한 번에 구현하는 실전 예제를 제공합니다. 프런트는 Axios로 진행률을 추적하고, 서버는 Express + multer로 테스트합니다.

1. 요구사항과 UX 포인트

다중 파일을 드래그 앤 드롭으로 추가하고, 각 파일의 업로드 진행률과 전체 진행률을 보여줍니다. 최대 동시 업로드 수를 제한해 브라우저 리소스를 보호합니다. 업로드 중 개별 취소, 실패 재시도, 파일 타입/크기 검증과 접근성을 고려합니다.

2. 핵심 라이브러리와 설치

Axios는 브라우저 XHR의 upload progress 이벤트를 노출하여 업로드 진행률을 계산하기 쉽습니다.

// 프로젝트 루트에서 실행
// npm
npm i axios
// yarn
yarn add axios

3. 전체 코드: DragDropUploader 컴포넌트

다음 코드는 드래그 앤 드롭 영역, 파일 리스트, 개별/전체 진행률, 업로드 취소, 동시 업로드 제어를 포함합니다.

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";

// 실사용 팁
// - 대용량/민감 데이터는 서버 직접 업로드 대신 S3 Pre-signed URL 권장 (아래 7장 참고)
// - Safari/iOS는 백그라운드 전환 시 업로드가 중단될 수 있음

const CONCURRENCY = 3; // 최대 동시 업로드 수
const MAX_FILES = 50;  // UX 보호용 상한
const ACCEPT = ["image/*", "application/pdf", "video/*"]; // 허용 MIME
const MAX_SIZE_MB = 200; // 파일 개당 최대 크기

function bytesToMB(b) { return Math.round((b / (1024 * 1024)) * 10) / 10; }
function uid() { return `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; }

export default function DragDropUploader({ uploadUrl = "/upload" }) {
  const [items, setItems] = useState([]); // {id, file, progress, status, error, controller}
  const [errorMsg, setErrorMsg] = useState("");
  const [isDragging, setDragging] = useState(false);
  const inputRef = useRef(null);
  const dragCounter = useRef(0);

  const totalProgress = useMemo(() => {
    if (!items.length) return 0;
    const sum = items.reduce((acc, it) => acc + (it.progress || 0), 0);
    return Math.round(sum / items.length);
  }, [items]);

  const validateFiles = useCallback((fileList) => {
    const accepted = [];
    const errors = [];
    const acceptPatterns = ACCEPT;

    for (const f of fileList) {
      if (f.size > MAX_SIZE_MB * 1024 * 1024) {
        errors.push(`${f.name}: ${bytesToMB(f.size)}MB (최대 ${MAX_SIZE_MB}MB 초과)`);
        continue;
      }
      // accept 체크 (간단 매칭)
      const ok = acceptPatterns.some((p) => {
        if (p.endsWith("/*")) return f.type.startsWith(p.replace("/*", "/"));
        return f.type === p;
      });
      if (!ok) {
        errors.push(`${f.name}: 허용되지 않는 형식 (${f.type || "unknown"})`);
        continue;
      }
      accepted.push(f);
    }
    return { accepted, errors };
  }, []);

  const enqueueFiles = useCallback((fileList) => {
    setErrorMsg("");
    const asArray = Array.from(fileList || []);
    if (!asArray.length) return;

    const { accepted, errors } = validateFiles(asArray);
    if (errors.length) setErrorMsg(errors.slice(0, 5).join("\n"));

    setItems((prev) => {
      const remain = Math.max(0, MAX_FILES - prev.length);
      const toAdd = accepted.slice(0, remain).map((f) => ({
        id: uid(),
        file: f,
        progress: 0,
        status: "queued",
        error: "",
        controller: null,
      }));
      return prev.concat(toAdd);
    });
  }, [validateFiles]);

  const onInputChange = (e) => {
    enqueueFiles(e.target.files);
    e.target.value = ""; // 같은 파일 재선택 허용
  };

  const onDragOver = (e) => { e.preventDefault(); };
  const onDragEnter = (e) => { e.preventDefault(); dragCounter.current += 1; setDragging(true); };
  const onDragLeave = (e) => {
    e.preventDefault();
    dragCounter.current -= 1;
    if (dragCounter.current <= 0) setDragging(false);
  };
  const onDrop = (e) => {
    e.preventDefault();
    dragCounter.current = 0;
    setDragging(false);
    enqueueFiles(e.dataTransfer.files);
  };

  const startNextUploads = useCallback(() => {
    const active = items.filter((it) => it.status === "uploading").length;
    const capacity = Math.max(0, CONCURRENCY - active);
    if (capacity === 0) return;

    const next = items.filter((it) => it.status === "queued").slice(0, capacity);
    next.forEach(uploadItem);
  }, [items]);

  useEffect(() => { startNextUploads(); }, [items, startNextUploads]);

  function uploadItem(it) {
    setItems((prev) => prev.map((p) => (p.id === it.id ? { ...p, status: "uploading", progress: 0, error: "" } : p)));

    const controller = new AbortController();
    setItems((prev) => prev.map((p) => (p.id === it.id ? { ...p, controller } : p)));

    const form = new FormData();
    form.append("file", it.file);

    axios
      .post(uploadUrl, form, {
        signal: controller.signal,
        onUploadProgress: (evt) => {
          if (!evt.total) return; // total이 없는 브라우저 방어
          const pct = Math.min(99, Math.round((evt.loaded / evt.total) * 100));
          setItems((prev) => prev.map((p) => (p.id === it.id ? { ...p, progress: pct } : p)));
        },
        headers: {
          // axios가 multipart/form-data 경계를 자동 설정함
          // 서버 디버그용 커스텀 헤더 (CORS에 노출 필요)
          "X-File-Name": encodeURIComponent(it.file.name),
        },
      })
      .then(() => {
        setItems((prev) => prev.map((p) => (p.id === it.id ? { ...p, status: "done", progress: 100 } : p)));
      })
      .catch((err) => {
        if (axios.isCancel?.(err) || err.name === "CanceledError") {
          setItems((prev) => prev.map((p) => (p.id === it.id ? { ...p, status: "canceled", error: "사용자 취소" } : p)));
        } else {
          const msg = err?.response?.data?.message || err.message || "업로드 실패";
          setItems((prev) => prev.map((p) => (p.id === it.id ? { ...p, status: "error", error: msg } : p)));
        }
      });
  }

  const cancelUpload = (id) => {
    const target = items.find((x) => x.id === id);
    if (target?.controller) target.controller.abort();
  };

  const removeItem = (id) => {
    setItems((prev) => prev.filter((p) => p.id !== id));
  };

  const retryItem = (id) => {
    setItems((prev) => prev.map((p) => (p.id === id ? { ...p, status: "queued", progress: 0, error: "" } : p)));
  };

  const openPicker = () => inputRef.current?.click();

  const styles = {
    zone: {
      border: `2px dashed ${isDragging ? "#2563eb" : "#94a3b8"}`,
      background: isDragging ? "#eff6ff" : "#f8fafc",
      color: "#0f172a",
      borderRadius: 12,
      padding: 24,
      textAlign: "center",
      cursor: "pointer",
      outline: "none",
    },
    list: { marginTop: 16 },
    row: { display: "flex", alignItems: "center", gap: 12, padding: "8px 0", borderBottom: "1px solid #e2e8f0" },
    name: { flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
    barWrap: { flex: 2, background: "#e2e8f0", borderRadius: 999, overflow: "hidden", height: 10 },
    bar: (w, color) => ({ width: `${w}%`, background: color, height: "100%" }),
    tag: (bg, color = "#0f172a") => ({ background: bg, color, padding: "2px 8px", borderRadius: 999, fontSize: 12 }),
    btn: { border: "1px solid #cbd5e1", padding: "6px 10px", borderRadius: 8, background: "white", cursor: "pointer" },
    danger: { border: "1px solid #ef4444", color: "#ef4444" },
  };

  return (
    <div>
      <div
        role="button"
        tabIndex={0}
        aria-label="파일 드래그 앤 드롭 영역"
        onClick={openPicker}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") openPicker(); }}
        onDragOver={onDragOver}
        onDragEnter={onDragEnter}
        onDragLeave={onDragLeave}
        onDrop={onDrop}
        style={styles.zone}
      >
        <p>파일을 이 영역에 드롭하거나 클릭하여 선택하세요 (최대 {MAX_FILES}개, {MAX_SIZE_MB}MB)</p>
        <p>허용 형식: {ACCEPT.join(", ")}</p>
      </div>

      <input
        ref={inputRef}
        type="file"
        multiple
        accept={ACCEPT.join(",")}
        onChange={onInputChange}
        style={{ display: "none" }}
      />

      {errorMsg && (<p style={{ color: "#b91c1c", marginTop: 8 }}>{errorMsg}</p>)}

      {items.length > 0 && (
        <div style={{ marginTop: 16 }}>
          <p>전체 진행률: {totalProgress}%</p>
          <div style={styles.barWrap}>
            <div style={styles.bar(totalProgress, "#22c55e")} />
          </div>

          <div style={styles.list} aria-live="polite">
            {items.map((it) => (
              <div key={it.id} style={styles.row}>
                <span style={styles.name}>{it.file.name} ({bytesToMB(it.file.size)}MB)</span>

                <div style={{ width: 220 }}>
                  <div style={styles.barWrap}>
                    <div style={styles.bar(it.progress, it.status === "error" ? "#ef4444" : "#3b82f6")} />
                  </div>
                </div>
                <span style={styles.tag("#f1f5f9")}>{it.progress}%</span>

                {it.status === "uploading" && (
                  <button style={styles.btn} onClick={() => cancelUpload(it.id)} aria-label="업로드 취소">취소</button>
                )}
                {it.status === "error" && (
                  <>
                    <span style={styles.tag("#fee2e2", "#991b1b")}>실패</span>
                    <button style={{ ...styles.btn, ...styles.danger }} onClick={() => retryItem(it.id)}>재시도</button>
                  </>
                )}
                {it.status === "done" && (
                  <span style={styles.tag("#dcfce7", "#166534")}>완료</span>
                )}
                {(it.status === "done" || it.status === "error" || it.status === "canceled") && (
                  <button style={styles.btn} onClick={() => removeItem(it.id)} aria-label="목록에서 제거">제거</button>
                )}
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

4. 서버 예시: Express + multer

테스트용 최소 서버입니다. 실제 서비스에서는 인증, 확장자/바이러스 검사, 저장 전략(S3/Cloud Storage) 등을 적용하세요.

// server.js
import express from "express";
import cors from "cors";
import multer from "multer";
import path from "path";
import fs from "fs";

const app = express();
app.use(cors({
  origin: true,
  credentials: true,
  exposedHeaders: ["X-Request-Id"],
}));

const uploadDir = path.join(process.cwd(), "uploads");
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, uploadDir),
  filename: (req, file, cb) => {
    const safe = Date.now() + "_" + file.originalname.replace(/[^a-zA-Z0-9_.-]/g, "_");
    cb(null, safe);
  },
});
const upload = multer({ storage });

app.post("/upload", upload.single("file"), (req, res) => {
  // 업로드 메타 응답
  res.json({ ok: true, file: req.file?.filename });
});

app.listen(4000, () => console.log("upload server on http://localhost:4000"));

프런트에서 uploadUrl을 http://localhost:4000/upload 으로 지정하세요. CORS를 적절히 설정합니다.

5. Pre-signed URL로 직접 업로드(선택)

대용량/트래픽 최적화를 위해 서버는 URL만 발급하고, 브라우저가 스토리지로 직접 업로드합니다.

// 1) 서버에서 사전 서명 URL 발급 (예: AWS S3)
// pseudo code (실서비스는 IAM/보안 필수)
app.post("/signed-url", async (req, res) => {
  const { fileName, contentType } = req.body;
  const url = await s3.getSignedUrlPromise("putObject", {
    Bucket: process.env.BUCKET,
    Key: `uploads/${Date.now()}_${fileName}`,
    ContentType: contentType,
    Expires: 60,
  });
  res.json({ url });
});

// 2) 프런트에서 PUT으로 업로드하며 진행률 표시
async function uploadToSignedUrl(file, signedUrl, onProgress) {
  return axios.put(signedUrl, file, {
    headers: { "Content-Type": file.type },
    onUploadProgress: (e) => {
      if (!e.total) return;
      onProgress(Math.round((e.loaded / e.total) * 100));
    },
  });
}

6. 접근성, 성능, 보안 체크리스트

접근성: 드래그 영역에 role, aria-label, 키보드 Enter/Space로 파일 선택을 지원합니다. 상태 변화는 aria-live로 알립니다.

성능: 동시 업로드 수(CONCURRENCY)를 조절합니다. 썸네일 생성은 Web Worker로 분리하세요. 너무 많은 파일은 페이지 렌더에 부담을 줍니다.

보안: 서버에서 MIME/확장자 검증, 바이러스 스캔, 인증/권한 체크를 수행하세요. 업로드 경로 탐색 방지와 Rate Limit을 적용하세요.

신뢰성: 모바일 네트워크 변동에 대비해 재시도 전략(지수 백오프)과 일시중지/재개(대용량은 청크 업로드) 전략을 고려하세요.

7. 트러블슈팅

onUploadProgress가 0%에서 멈춤: 일부 프록시/브라우저 환경에서 Content-Length가 누락될 수 있습니다. 서버/스토리지가 Content-Length를 노출하도록 설정하거나 Axios/XHR 사용을 확인하세요.

CORS 오류: 서버에서 Access-Control-Allow-Origin, Allow-Methods(POST, PUT), Allow-Headers(Content-Type, X-File-Name 등)과 credentials 정책을 올바르게 설정하세요.

multipart/form-data 수동 지정 금지: Axios가 boundary를 자동 설정합니다. headers에 Content-Type을 직접 지정하면 실패합니다.

iOS/Safari 이슈: 백그라운드 전환 시 업로드 중단 가능. 사용자를 유도하는 안내 문구 또는 재시도 로직을 둡니다.

8. 마무리

본 예제는 실제 서비스에서 바로 쓸 수 있는 구조로, 다중 파일 드래그 앤 드롭, 진행률, 동시성 제어, 취소/재시도까지 포함합니다. 백엔드 저장 전략만 맞추면 손쉽게 확장할 수 있습니다. 필요 시 청크 업로드와 Pre-signed URL로 대용량에도 대응하세요.