본문으로 건너뛰기
HexaTransfer
블로그로 돌아가기
기술 심층 분석

프로그레시브 파일 업로드 UX와 기술 가이드

더 나은 사용자 경험을 위해 드래그 앤 드롭, 진행률 표시줄, 우아한 오류 처리로 프로그레시브 파일 업로드를 구축하세요.

프로그레시브 파일 업로드는 모든 단계에서 사용자에게 즉각적이고 신뢰할 수 있는 피드백을 제공합니다. 파일을 드롭하는 순간 선택 확인, 업로드 중에는 현실적인 ETA가 있는 부드러운 진행률 표시줄, 실패 시 구체적인 재시도 옵션, 완료 후에는 다음 작업이 있는 명확한 성공 상태. 기술적 구성 요소는 HTML5 DataTransfer API를 통한 드래그 앤 드롭, 재개 가능한 청크 업로드, 바이트 정확한 진행률을 위한 ReadableStream을 사용한 fetch 스트리밍, IndexedDB를 통해 탭 재로드에서도 살아남는 상태 관리입니다. 제대로 구현하면 5 GB 파일을 업로드하는 사용자가 앱이 멈춘 것인지 궁금해하지 않습니다.

"프로그레시브"가 여기서 실제로 의미하는 것

이 맥락에서 프로그레시브는 두 가지 의미가 있습니다. 하나: 프로그레시브 향상으로, 2012년 브라우저에서 일반 <input type="file"> POST로 작동하고, JavaScript가 사용 가능하면 드래그 앤 드롭, 청킹, 재시도가 추가됩니다. 둘: 프로그레시브 공개로, UI가 필요할 때만 복잡성을 드러냅니다. 업로드 중에는 백분율을 보여주지만, 실패 시에만 재시도 세부 정보를 보여줍니다. 두 의미 모두 같은 원칙을 가리킵니다. 사용자는 절대 막다른 골목에 닿지 않아야 하고, 정보 없이 기다려서는 안 됩니다.

피해야 할 실패 패턴은 "스피너 지옥"입니다. 진행 상황, ETA, 또는 무언가 잘못됐는지 전혀 알려주지 않는 일반 로딩 애니메이션. 사용자는 신뢰하지 않는 업로드를 취소합니다.

브라우저와 싸우지 않는 드래그 앤 드롭

HTML5 드래그 앤 드롭 API는 버그로 악명 높습니다. 견딜 수 있게 만드는 몇 가지 규칙:

const dropzone = document.querySelector('.dropzone');
dropzone.addEventListener('dragover', (e) => {
  e.preventDefault();
  dropzone.classList.add('dragging');
});
dropzone.addEventListener('dragleave', () => {
  dropzone.classList.remove('dragging');
});
dropzone.addEventListener('drop', (e) => {
  e.preventDefault();
  dropzone.classList.remove('dragging');
  handleFiles([...e.dataTransfer.files]);
});

dragover에서 preventDefault를 호출하지 않으면 드롭 대상이 드롭을 허용하지 않습니다. webkitGetAsEntry()를 통해 폴더를 허용해야 한다면 files 대신 e.dataTransfer.items를 사용하세요. Chrome과 Firefox에서 디렉터리 내용을 재귀적으로 캡처하는 유일한 방법입니다.

폴백도 사용 가능하게 만드세요. 스타일링된 <input type="file" multiple>을 감싸는 보이는 <label>은 키보드와 스크린 리더 탐색을 포함한 100%의 사용자에게 작동합니다.

사용자가 믿는 진행률 표시

진행률 표시줄이 들쭉날쭉한 이유는 세 가지입니다. 균등하지 않은 청크 크기, TCP 느린 시작, 네트워크 스택의 버퍼링. 2초 이동 평균으로 부드럽게 하세요.

const samples = []; // [{ time, bytes }]
function recordSample(bytes) {
  const now = performance.now();
  samples.push({ time: now, bytes });
  while (samples.length > 1 && now - samples[0].time > 2000) samples.shift();
}
function throughput() {
  if (samples.length < 2) return 0;
  const delta = samples[samples.length - 1];
  const base = samples[0];
  return (delta.bytes - base.bytes) / ((delta.time - base.time) / 1000);
}

ETA를 (totalBytes - uploadedBytes) / throughput()으로 계산하고, 표시를 최소 5초로 제한하며, 사람이 읽을 수 있는 형식으로 표시하세요. "124.3초"가 아닌 "약 2분". 백분율과 바이트 카운터("2.1 GB 중 340 MB")를 모두 표시하세요. 무언가 이상하게 느껴지면 사용자가 두 가지를 교차 확인합니다.

실행 가능한 복구가 있는 오류 상태

일반적인 "업로드 실패" 메시지는 신뢰를 파괴합니다. 실패를 다섯 가지 버킷으로 분류하고 각각을 명확하게 표면화하세요.

  • 네트워크 끊김 (오프라인 이벤트, TCP 재설정): 자동 재시도가 있는 "재연결 중..."
  • 서버 5xx: 수동 재시도 버튼이 있는 "서버 오류, 5초 후 재시도"
  • 서버 4xx (413 너무 큼, 415 잘못된 유형): 파일 교체 옵션이 있는 "파일 거부됨: 너무 큼"
  • 인증 만료 (401, 403): "세션 만료, 계속하려면 로그인하세요"
  • 클라이언트 충돌 (JS 오류, 브라우저가 탭 종료): 재로드 시 IndexedDB에서 복구

메시지를 해결하는 하나의 작업과 짝을 지으세요. 사용자가 오프라인이면 navigator.onLineonline 이벤트로 모니터링되는 온라인/오프라인 상태를 표시하세요.

Fetch 스트림으로 바이트 추적

XMLHttpRequest.upload.onprogress는 전통적인 업로드 진행률 추적 방법이지만 HTTP/3에서 불안정하고 전송 버퍼에 큐잉된 바이트를 놓칩니다. 현대적 접근은 바이트가 생성될 때 ReadableStream을 사용해 계산합니다.

function trackedStream(blob, onBytes) {
  let sent = 0;
  return new ReadableStream({
    async pull(controller) {
      const reader = blob.stream().getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) { controller.close(); return; }
        sent += value.byteLength;
        onBytes(sent);
        controller.enqueue(value);
      }
    }
  });
}

duplex: 'half'와 함께 스트림을 fetchbody로 전달하세요. Safari의 요청 스트림 지원은 17.4에서 도입됐습니다. 이전에는 XMLHttpRequest로 폴백하세요. 이렇게 하면 실제로 네트워크 스택에 전달된 바이트에 연결된 밀리초 정확도의 진행률을 얻습니다.

일시 정지, 재개, 취소

1분 이상 걸리는 모든 것에 일시 정지 버튼이 있을 것으로 사용자는 기대합니다. 청크 업로드에서 일시 정지는 "새 청크 디스패치 중지"이고, 재개는 작업 큐가 중단된 곳에서 계속됩니다. 취소는 AbortController를 사용합니다.

const ctrl = new AbortController();
cancelButton.onclick = () => ctrl.abort();
await fetch(url, { method: 'PUT', body: blob, signal: ctrl.signal });

중단 시 정리하세요. 스토리지가 누출되지 않도록 서버에서 업로드 세션을 DELETE하고, IndexedDB 항목을 지우고, 초기 상태로 돌아가세요. 일시 정지는 상태를 보존해야 하고, 취소는 파괴해야 합니다. UI에서 구분을 명확히 하세요.

탭 재로드에서 살아남기

각 성공적인 청크 후에 업로드 상태를 유지하세요.

await idb.put('uploads', {
  sessionId, fileFingerprint, fileName, fileSize,
  completedChunks: [...done], updatedAt: Date.now()
}, sessionId);

지문은 파일의 첫 1 MB, 크기, lastModified의 SHA-256입니다. 재로드 후 사용자가 다시 파일을 선택할 때 파일을 재식별하기에 충분합니다. 페이지 로드 시 1시간 이내의 세션을 IndexedDB에서 확인하고 재개를 제안하세요. "12분 전 업로드가 진행 중입니다. 재개하시겠습니까?" 동의 없이 자동 재개하지 마세요. 사용자가 취소하기 위해 특별히 재로드하는 경우가 있습니다.

접근 가능하고 키보드 친화적인 인터랙션

마우스 드래그에만 반응하는 드롭존은 스크린 리더 사용자와 키보드 사용자에게 실패합니다. 추가하세요.

  • 드롭존에 role="button"tabindex="0"
  • 파일 입력을 클릭하는 Enter/Space 키 핸들러
  • 스크린 리더가 마일스톤을 알리도록 진행률 영역에 aria-live="polite"
  • 호버만이 아닌 보이는 포커스 스타일
  • 명확한 레이블 — 빈 아이콘보다 "Browse"보다 "파일 업로드"가 낫습니다

키보드 테스트는 빠릅니다. 마우스를 10분 동안 뽑고 업로드를 완료해보세요. 할 수 없다면 사용자 중 일부도 할 수 없습니다.

HexaTransfer의 업로더는 정확히 이 프로그레시브 패턴을 사용합니다. 일반 폼 폴백, 드래그 앤 드롭 향상, fetch 스트리밍, IndexedDB 기반 재개, 구체적인 오류 복구. hexatransfer.com에서 사용해보세요 — 무료, 계정 불필요, 최대 10 GB.

사용자가 실제로 알아채는 세부 사항

평범한 업로더와 훌륭한 업로더를 구분하는 광택은 작은 순간에 있습니다. 파일이 캡처됐음을 확인하는 드롭 애니메이션, 튀지 않고 부드럽게 채워지는 진행률 표시줄, 들쭉날쭉하지 않고 시간이 지나면서 더 정확해지는 ETA, 다음에 할 일을 알려주는 구체적인 오류 메시지, 실수로 새로고침 후의 재개 프롬프트, 공유 링크를 복사하기에 충분한 시간 동안 지속되는 완료 상태, 업로드를 즉시 실제로 중지하는 취소 동작. 이것들 각각은 몇 줄의 코드입니다. 모두 출시하면 업로더가 기본 <input type="file"> 처리보다 한 차원 더 좋게 느껴집니다.

엔드투엔드 암호화로 대용량 파일을 안전하게 전송

엔드투엔드 암호화로 최대 10GB의 파일을 무료로 전송하세요. 계정이 필요하지 않습니다. 업로드 전에 브라우저에서 파일이 암호화되어 다른 사람은 읽을 수 없습니다.

파일 보내기