현업에서 파일 업로드는 사용자 경험과 신뢰도를 좌우합니다. 이 글은 React에서 다중 파일 드래그 앤 드롭, 개별 진행률, 전체 진행률, 업로드 취소, 동시 업로드 제어까지 한 번에 구현하는 실전 예제를 제공합니다. 프런트는 Axios로 진행률을 추적하고, 서버는 Express + multer로 테스트합니다.
1. 요구사항과 UX 포인트
다중 파일을 드래그 앤 드롭으로 추가하고, 각 파일의 업로드 진행률과 전체 진행률을 보여줍니다. 최대 동시 업로드 수를 제한해 브라우저 리소스를 보호합니다. 업로드 중 개별 취소, 실패 재시도, 파일 타입/크기 검증과 접근성을 고려합니다.
2. 핵심 라이브러리와 설치
Axios는 브라우저 XHR의 upload progress 이벤트를 노출하여 업로드 진행률을 계산하기 쉽습니다.
// 프로젝트 루트에서 실행
// npm
npm i axios
// yarn
yarn add axios3. 전체 코드: 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로 대용량에도 대응하세요.
'React' 카테고리의 다른 글
| React에서 TanStack Table로 고성능 데이터 테이블 구현하기 (0) | 2026.07.15 |
|---|---|
| React 앱에서 주기적인 데이터 동기화 스케줄러 구축하기 (0) | 2026.07.15 |
| React 앱에서 AR(증강현실) 콘텐츠 뷰어 구현하기 (0) | 2026.07.14 |
| React에서 이메일 템플릿 미리보기 UI 개발하기 (0) | 2026.07.13 |
| React 앱에서 파일 시스템 접근 API 활용하기 (0) | 2026.07.13 |