파일 업로드·드래그앤드롭 UX: 대용량 파일 처리와 진행 상태 설계

UX 디자인

파일 업로드드래그앤드롭UX 설계진행 상태이미지 업로드

이 글은 누구를 위한 것인가

  • 파일 업로드 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에서 가장 중요한 것은 "실패 시 명확한 피드백과 복구 경로"다. 업로드 실패 시 왜 실패했는지(파일 형식? 크기? 네트워크?)를 알려주고, 재시도 버튼을 제공해야 한다. 진행도 없이 스피너만 보여주는 업로드는 사용자를 불안하게 만든다.