이 글은 누구를 위한 것인가
- 파일 업로드 UX가 답답해서 사용자 이탈이 생기는 팀
- 대용량 파일 업로드 시 진행 상태를 보여주고 싶은 개발자
- 이미지 업로드 미리보기와 편집 기능을 만들려는 팀
들어가며
파일 업로드는 사용자가 실수할 위험이 높은 인터페이스다. 잘못된 형식, 용량 초과, 업로드 실패 — 이 모든 상황을 명확하게 처리해야 한다. 드래그앤드롭은 편리하지만 모바일에서는 작동하지 않으므로 버튼 대안도 항상 제공해야 한다.
이 글은 bluefoxdev.kr의 파일 업로드 UX 가이드 를 참고하여 작성했습니다.
1. 파일 업로드 UX 원칙
[업로드 인터페이스 체크리스트]
허용 형식과 크기 명시:
"JPG, PNG, GIF 파일 (최대 10MB)"
→ 업로드 전에 미리 알려야 실패가 없음
드래그앤드롭 + 버튼 클릭 병행:
드래그: 데스크탑 사용자 편의
버튼: 모바일 필수, 접근성 기본
즉각적 미리보기:
이미지: 썸네일 즉시 표시
문서: 파일 아이콘 + 이름 + 크기
진행도 표시:
작은 파일 (< 1MB): 0% → 100% 빠르게
대용량 파일: % + 남은 시간 + 속도
실패 시: 명확한 오류 + 재시도 버튼
다중 파일:
개별 취소 가능
전체 목록 표시
최대 파일 수 명시
[드래그앤드롭 시각 피드백]
기본: 점선 테두리 영역
드래그 진입: 테두리 색상 + 배경색 변경
유효한 파일: 초록색 하이라이트
유효하지 않은 파일: 빨간색 경고
2. 드래그앤드롭 컴포넌트
"use client";
import { useState, useCallback, useRef } from "react";
interface UploadFile {
id: string;
file: File;
preview?: string;
status: "pending" | "uploading" | "done" | "error";
progress: number;
error?: string;
}
interface DropzoneProps {
accept: string[];
maxSizeMB?: number;
maxFiles?: number;
onFilesAdded: (files: File[]) => void;
}
function Dropzone({ accept, maxSizeMB = 10, maxFiles = 10, onFilesAdded }: DropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragError, setDragError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const validateFiles = (files: File[]): { valid: File[]; errors: string[] } => {
const valid: File[] = [];
const errors: string[] = [];
for (const file of files) {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
if (!accept.includes(`.${ext}`) && !accept.includes(file.type)) {
errors.push(`${file.name}: 지원하지 않는 파일 형식`);
continue;
}
if (file.size > maxSizeMB * 1024 * 1024) {
errors.push(`${file.name}: 파일 크기 초과 (최대 ${maxSizeMB}MB)`);
continue;
}
valid.push(file);
}
return { valid, errors };
};
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
setDragError(null);
const files = Array.from(e.dataTransfer.files);
const { valid, errors } = validateFiles(files);
if (errors.length > 0) {
setDragError(errors.join(", "));
}
if (valid.length > 0) {
onFilesAdded(valid);
}
},
[accept, maxSizeMB, onFilesAdded]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
return (
<div>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={() => { setIsDragging(false); setDragError(null); }}
onClick={() => inputRef.current?.click()}
className={`relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragging
? "border-blue-500 bg-blue-50"
: dragError
? "border-red-400 bg-red-50"
: "border-gray-300 hover:border-blue-400 hover:bg-gray-50"
}`}
>
<div className="text-4xl mb-3">{isDragging ? "📂" : "📁"}</div>
<p className="text-sm font-medium text-gray-700">
파일을 여기에 끌어다 놓거나{" "}
<span className="text-blue-600 underline">클릭해서 선택</span>
</p>
<p className="text-xs text-gray-400 mt-1">
{accept.join(", ")} · 최대 {maxSizeMB}MB
</p>
<input
ref={inputRef}
type="file"
accept={accept.join(",")}
multiple={maxFiles > 1}
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files || []);
const { valid } = validateFiles(files);
if (valid.length > 0) onFilesAdded(valid);
e.target.value = ""; // 같은 파일 재업로드 허용
}}
/>
</div>
{dragError && (
<p className="text-red-500 text-sm mt-2 flex items-center gap-1">
<span>⚠</span> {dragError}
</p>
)}
</div>
);
}
// 업로드 진행도 표시
function FileUploadItem({ uploadFile, onRemove }: { uploadFile: UploadFile; onRemove: () => void }) {
return (
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
{uploadFile.preview ? (
<img src={uploadFile.preview} alt="" className="w-10 h-10 rounded object-cover" />
) : (
<div className="w-10 h-10 bg-gray-200 rounded flex items-center justify-center text-lg">📄</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{uploadFile.file.name}</p>
<p className="text-xs text-gray-400">{(uploadFile.file.size / 1024 / 1024).toFixed(1)}MB</p>
{uploadFile.status === "uploading" && (
<div className="mt-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all"
style={{ width: `${uploadFile.progress}%` }}
/>
</div>
)}
{uploadFile.status === "error" && (
<p className="text-xs text-red-500 mt-0.5">{uploadFile.error}</p>
)}
</div>
<div className="flex items-center gap-2">
{uploadFile.status === "done" && <span className="text-green-500">✓</span>}
{uploadFile.status === "error" && (
<button className="text-xs text-blue-500 underline">재시도</button>
)}
<button onClick={onRemove} className="text-gray-400 hover:text-red-500">✕</button>
</div>
</div>
);
}
마무리
파일 업로드 UX에서 가장 중요한 것은 "실패 시 명확한 피드백과 복구 경로"다. 업로드 실패 시 왜 실패했는지(파일 형식? 크기? 네트워크?)를 알려주고, 재시도 버튼을 제공해야 한다. 진행도 없이 스피너만 보여주는 업로드는 사용자를 불안하게 만든다.