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

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

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

개인정보 보호법(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에서 항상 발화하지 않으므로 fetchReadableStreamTransform을 사용해 바이트를 계산하세요.

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

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

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

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

파일 보내기