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

JavaScript로 청크 파일 업로드 구현하기

JavaScript로 재개 가능한 청크 파일 업로드를 구현하세요. 대용량 파일을 처리하고 진행률을 추적하고 네트워크 중단에서 복구합니다.

Gaprod — Groupe Dwenola SAS가 운영하는 HexaTransfer의 가이드입니다. 자사 서비스와 다른 제품을 함께 다룹니다. 발행자 소개

개인정보 보호법(PIPA) 제29조는 개인정보의 전송 시 안전한 방법을 적용하도록 요구합니다. 대용량 파일의 청크 업로드는 단순한 성능 최적화가 아니라 신뢰할 수 있는 전송을 위한 기술적 기반입니다. JavaScript 청크 파일 업로드는 대용량 파일을 고정 크기 조각(보통 5~10MB)으로 분할하고, 각각을 별도의 HTTP 요청으로 업로드하며, 서버에서 재조립합니다. File.slice()로 청크를 추출하고, 청크당 AbortSignal과 함께 fetch를 사용하며, S3 멀티파트 또는 커스텀 병합기로 서버 측 조립하고, IndexedDB의 로컬 인덱스로 탭 재로드 후에도 재개할 수 있습니다.

청크가 단발성 업로드보다 나은 이유

4GB 파일을 하나의 요청으로 업로드하면 예측 가능한 이유로 실패합니다: Nginx의 기본 client_max_body_size는 1MB, Cloudflare 무료 티어는 요청당 100MB, AWS API Gateway는 10MB에서 하드 스톱, 모바일 Safari는 4GB ArrayBuffer를 메모리에 보유하면 탭을 종료합니다. 청크 업로드는 이 모든 한계를 회피합니다. 실제로 움직이는 진행 표시줄, 처음부터 다시 시작하지 않는 재시도, 일시 중지·재개 기능을 얻습니다.

청크 크기 선택

청크 크기는 처리량 대 복원력의 트레이드오프입니다. 너무 작으면(1MB 미만) TLS 핸드셰이크에 데이터보다 더 많은 시간을 씁니다. 너무 크면(100MB 이상) 연결 끊김으로 분 단위 업로드가 낭비됩니다. 대부분의 네트워크에서 최적점은 5~10MB로, S3의 5MB 멀티파트 최소값과 일치하고 일반적인 TCP 윈도우 크기와도 잘 맞습니다.

먼저 사용자 네트워크를 측정하세요:

const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;

파일 슬라이싱과 해싱

File.slice()는 복사 없이 기반 디스크 바이트를 참조하는 Blob을 반환하므로, 20GB 파일 슬라이싱이 비용 없습니다:

function* sliceFile(file, chunkSize) {
  for (let offset = 0; offset < file.size; offset += chunkSize) {
    yield {
      index: Math.floor(offset / chunkSize),
      blob: file.slice(offset, offset + chunkSize),
      start: offset,
      end: Math.min(offset + chunkSize, file.size)
    };
  }
}

업로드 전에 각 청크의 SHA-256 해시를 계산하여 서버가 무결성을 검증할 수 있게 하세요. 10GB 데이터 해싱은 현대 노트북에서 약 20초를 추가합니다—불안정한 셀룰러 업링크의 무음 손상을 잡기 위해 가치 있습니다.

제어된 동시성으로 업로드

순차 업로드는 대역폭을 낭비하고, 무제한 병렬은 브라우저를 충돌시킵니다. 3~4개의 진행 중인 청크 동시성 제한이 균형을 맞춥니다:

async function uploadAll(file, sessionId) {
  const queue = [...sliceFile(file, 5 * 1024 * 1024)];
  const workers = Array.from({ length: 4 }, async () => {
    while (queue.length) {
      const chunk = queue.shift();
      await uploadChunk(chunk, sessionId);
      emitProgress(chunk.index);
    }
  });
  await Promise.all(workers);
}

각 uploadChunk 호출은 블롭을 본문으로, 해시를 헤더로 하는 PUT /upload/:sessionId/:index입니다. 청크당 AbortController를 사용해 전체 배치를 중단하지 않고 개별 요청을 취소할 수 있게 하세요.

서버를 폭격하지 않는 재시도

네트워크 오류는 지수 백오프가 필요합니다. 합리적인 정책: 3회 시도, 기본 지연 500ms, 최대 50% 지터:

async function uploadChunk(chunk, sessionId, attempt = 0) {
  try {
    const res = await fetch(`/upload/${sessionId}/${chunk.index}`, {
      method: 'PUT', body: chunk.blob, headers: { 'X-Hash': chunk.hash }
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
  } catch (e) {
    if (attempt >= 3) throw e;
    const delay = 500 * 2 ** attempt + Math.random() * 250;
    await new Promise(r => setTimeout(r, delay));
    return uploadChunk(chunk, sessionId, attempt + 1);
  }
}

5xx 응답은 재시도 가능, 4xx는 치명적(408과 429 제외)으로 처리하세요. 429에서는 로컬 백오프 대신 Retry-After 헤더를 준수하세요.

탭 재로드 후 재개

성공적인 청크마다 IndexedDB에 업로드 상태를 유지하세요. 사용자가 같은 파일 피커로 페이지를 다시 열면 파일의 size, lastModified, 이름을 저장된 세션과 비교하고, 일치하면 서버에 이미 받은 청크를 쿼리한 후 누락된 것만 업로드하세요. tus.io 프로토콜은 Upload-Offset 헤더로 이 패턴을 정확히 공식화하고 있으며, tus-js-client 라이브러리가 직접 구현하지 않으려는 경우 견고한 구현을 제공합니다.

서버에서 청크 조립

두 가지 진지한 옵션: S3 멀티파트 업로드(각 청크가 PartNumber가 되고 최종 CompleteMultipartUpload로 결합)와 커스텀 조립기(각 청크를 임시 파일에 쓰고 최후에 연결). S3 방식은 대규모에서 더 저렴하며 R2는 이그레스 무료 읽기를 제공합니다. 10,000 파트 한도에 주의하세요—50GB 이상의 파일에는 5MB+ 청크가 필요합니다.

사용자가 신뢰하는 진행 표시줄

진행 표시줄이 들쭉날쭉하면 고장난 것처럼 느껴집니다. 완료된 청크가 아닌 업로드된 바이트 대 전체 바이트로 진행률을 계산하고, 2초 이동 평균으로 지터를 숨기세요. XMLHttpRequest.upload.onprogress는 HTTP/3에서 항상 발화하지 않으므로 fetch와 ReadableStream에 Transform을 사용해 바이트를 계산하세요.

HexaTransfer는 이와 같은 청크 + 재개 가능 파이프라인을 내부적으로 10GB 업로드에 사용하며, PUT 전에 각 청크에 클라이언트 측 AES-256-GCM을 추가합니다.

https://hexatransfer.com — 무료, 계정 불필요, 최대 50GB.

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

엔드투엔드 암호화로 최대 50 GiB까지 무료 전송. 계정과 이메일 주소가 필요 없습니다. 보관 기간은 1~30일 중 선택하며 기본값은 14일입니다.

파일 보내기