📁 File1. 전송 프로토콜03. Resumable Upload & tus — 끊긴 업로드를 이어붙이는 법

03. Resumable Upload & tus — 끊긴 업로드를 이어붙이는 법

5GB 영상을 95% 올렸는데 와이파이가 끊겼다. 사용자에게 “처음부터 다시 올리세요”라고 할 것인가? 재개 가능 업로드(resumable upload) 가 답이고, 그 표준 후보가 tus, 그 클라우드 사실상 표준이 S3 Multipart의 재개 흉내다.


한 줄 답

재개 가능 업로드는 “어디까지 올렸는지를 서버가 기억” 하는 것이다. tus는 그 합의를 RESTful한 PATCH 시퀀스로 표준화했고, S3는 같은 의미를 UploadId + ListParts로 구현했다.


Why — 왜 재개가 필요한가

모바일·저속 네트워크의 현실

환경5GB 업로드 시간95% 시점 끊김 시 손실
사무실 Wi-Fi 100Mbps~7분6.7분
카페 Wi-Fi 20Mbps~33분31분
LTE 5Mbps~133분126분
4G 약전계 1Mbps~11시간10.5시간

→ 처음부터 다시 = 사용자 이탈. 비즈니스 비용 = 무한대.

끊김의 실제 원인

  • 페이지 새로고침 / 탭 닫음 (사용자 실수)
  • Wi-Fi → LTE 전환
  • 백그라운드 전환으로 OS가 connection drop
  • 서버 5xx 일시 장애
  • presigned URL 만료 (5분 만료가 일반적인데 한 파트 업로드가 6분 걸렸다면?)

How — 두 가지 재개 모델

모델 A — tus.io 프로토콜 (1.0.0, RFC 표준화 중)

“Open Protocol for Resumable File Uploads” — Vimeo가 후원하는 오픈 표준.

핵심 헤더

헤더의미
Tus-Resumable프로토콜 버전 (1.0.0)
Upload-Length전체 파일 크기
Upload-Offset지금까지 받은 바이트
Upload-Metadatabase64 인코딩된 키-값 (filename ZmlsZS5tcDQ=)
Tus-Extension지원 기능 (creation,termination,checksum)

시퀀스

# 1) 업로드 생성
POST /files/ HTTP/1.1
Tus-Resumable: 1.0.0
Upload-Length: 5242880000
Upload-Metadata: filename ZmlsZS5tcDQ=
 
HTTP/1.1 201 Created
Location: /files/abc123
Tus-Resumable: 1.0.0
 
# 2) 청크 업로드 (PATCH)
PATCH /files/abc123 HTTP/1.1
Tus-Resumable: 1.0.0
Upload-Offset: 0
Content-Type: application/offset+octet-stream
Content-Length: 1048576
 
<바이너리 1MB>
 
HTTP/1.1 204 No Content
Tus-Resumable: 1.0.0
Upload-Offset: 1048576
 
# 3) 끊김 후 재개 — HEAD로 offset 조회
HEAD /files/abc123 HTTP/1.1
Tus-Resumable: 1.0.0
 
HTTP/1.1 200 OK
Upload-Offset: 3145728
Upload-Length: 5242880000
 
# 4) offset부터 PATCH 재개
PATCH /files/abc123 HTTP/1.1
Upload-Offset: 3145728
...

tus의 장점

  • 단순: HEAD/PATCH만 알면 됨. 복잡한 multipart 시퀀스 없음.
  • 표준: Vimeo, Cloudflare Stream, Mux가 채택.
  • 확장 모듈: termination(취소), concatenation(파트 합치기), checksum(무결성).

단점

  • 자체 서버 필요: S3는 tus를 직접 지원하지 않음 (게이트웨이 필요).
  • 프록시 호환성: 어떤 CDN/WAF는 PATCH를 거른다.
  • 검색 대비 자료가 적다 (여전히 multipart 자료가 압도적).

모델 B — S3 Multipart Upload의 재개 흉내 (사실상 표준)

S3는 tus를 모르지만 재개와 동등한 능력을 다음 3개 API로 제공:

API역할
CreateMultipartUploadUploadId세션 시작
ListParts(UploadId)지금까지 S3가 받은 파트 목록 조회 ← 핵심
누락된 파트 번호만 UploadPart재개

재개 흐름 (한 프로덕션 사례)

핵심 코드 (의사 코드)

// 의사 코드 — 실제 파일 경로는 프로젝트별로 다름
async listUploadedParts(s3Key, uploadId) {
  const command = new ListPartsCommand({
    Bucket: this.bucketName,
    Key: s3Key,
    UploadId: uploadId,
  });
  const response = await this.s3Client.send(command);
  return response.Parts?.map(p => ({
    partNumber: p.PartNumber,
    etag: p.ETag,
    size: p.Size,
  })) ?? [];
}
// 의사 코드 — 클라이언트 업로드 유틸
export const getRemainingPartNumbers = (totalParts, uploadedParts) => {
  const uploadedPartNumbers = new Set(uploadedParts.map(p => p.partNumber));
  return Array.from({ length: totalParts }, (_, i) => i + 1)
    .filter(n => !uploadedPartNumbers.has(n));
};

핵심 통찰: Redis 세션은 캐시이고, S3의 ListParts가 진실의 원천(source of truth). Redis가 잃어버려도 S3에 물어보면 어디까지 올렸는지 알 수 있다 → 무상태 재개 가능.


What — tus vs S3 Multipart 비교

항목tusS3 Multipart
표준성오픈 프로토콜AWS API (그러나 사실상 표준)
청크 크기임의 (1바이트도 OK)5MB+ (마지막 제외)
재개 정밀도1바이트1파트 (5MB 단위 손실 가능)
서버 구현자체 구현 필요S3가 제공
클라이언트 라이브러리tus-js-client, Uppy @uppy/tusAWS SDK, Uppy @uppy/aws-s3-multipart
presigned 흐름✗ (서버 직접 통과)✓ (S3 직접 PUT)
CloudFront 친화△ (PATCH 필요)
동시 청크 업로드✗ (순차가 표준)✓ (병렬 가능)

어느 쪽을 쓸까

  • 이미 S3/GCS/Azure Blob 쓰는 중 → 그 SDK의 multipart 이용. 추가 인프라 없음.
  • 자체 서버에 저장 + 재개 정밀도 중요 (1바이트 단위) → tus.
  • Uppy 쓰는 중@uppy/aws-s3-multipart이 가장 검증됨 (한 프로덕션 사례에서 선택).
  • Cloudflare R2/Stream → 둘 다 지원. 통합 흐름이면 tus 추천.

What — 무결성 검증 (체크섬)

재개 후에도 받은 바이트가 보낸 바이트와 같은지 확인해야 한다.

S3의 체크섬 옵션 (2022 추가)

new UploadPartCommand({
  Bucket, Key, UploadId, PartNumber,
  ChecksumAlgorithm: 'SHA256',  // 또는 CRC32, CRC32C, SHA1
});
  • 클라이언트가 SHA256 헤더 (x-amz-checksum-sha256) 첨부
  • S3가 계산해서 불일치 시 거부
  • 완료 시 Complete에 각 파트의 체크섬을 다시 보냄

tus의 checksum 확장

PATCH /files/abc123 HTTP/1.1
Upload-Checksum: sha256 9b5c2dc...

→ 서버가 검증, 불일치면 460 Checksum Mismatch.

자세한 내용: 06-integrity-and-checksum.md


What-if — 흔한 함정

함정결과해결
Redis 세션이 사라지면 재개 불가UploadId를 잃어버려서 고아 파트가 비용으로DB에 영구 저장 또는 ListMultipartUploads로 복구
같은 파트를 두 번 PUT마지막 PUT의 ETag만 유효, 결과 정상OK — 멱등
presigned URL이 만료된 채로 재개403 SignatureDoesNotMatch재개 시 항상 새로 presign
파트 크기를 도중에 바꿈EntityTooSmall (마지막 제외)청크 크기는 세션 단위로 고정
모바일에서 백그라운드 진입iOS는 fetch를 죽임Service Worker + Background Fetch API (제한적)
Complete 후 재개 시도NoSuchUpload 에러UploadId 재사용 불가 — 새로 만들어야
Abort 안 하고 떠남영원히 비용 (S3는 14일까지 가만히 보관 후 결제)Lifecycle Rule + 클라이언트 cleanup

Insight — 흥미로운 이야기

“tus를 만든 건 Vimeo, 그 이유는 ‘파일을 잃어버려서’”

2013년 Vimeo는 사용자가 50% 업로드한 영상이 끊기면 처음부터 다시 올리게 했다. 그 한 가지 이유로 비싼 사용자가 경쟁사로 이탈하는 것을 보고 자체 프로토콜을 만들어 오픈소스화 한 게 tus다. “고객 잡기 위해 만든 표준” — 그래서 서버 구현이 단순한 PATCH 하나.

“S3 Multipart의 ListParts는 ‘memento mori’다”

클라우드의 모든 비용 누수는 내가 시작했지만 끝내지 않은 작업에서 온다. ListMultipartUploads로 미완료 세션을 주기적으로 확인하는 운영 습관은 곧 “내가 이 시스템을 이해하고 있다”의 증거다. AbortIncompleteMultipartUpload Lifecycle Rule을 안 걸어두면 6개월 뒤 청구서로 깨닫는다.

“Uppy는 양다리”

Uppy는 Transloadit이 만든 업로드 라이브러리인데, tus와 S3 multipart 둘 다 지원한다. 즉, 백엔드가 무엇이든 같은 프론트엔드 코드로 대응 가능. 한 프로덕션 사례는 @uppy/aws-s3-multipart만 쓰고 재개 로직을 직접 짰다 — Uppy의 재개가 너무 자동화돼서 Redis 세션 + S3 ListParts 동기화 윈도우를 못 끼워 넣어서.


요약 + Mermaid

재개 가능 업로드는 두 가지 흐름이 사실상 표준이다: tus는 1바이트 정밀도의 RESTful 표준, S3 Multipart는 5MB 정밀도의 클라우드 사실상 표준. 둘 다 핵심은 “서버가 어디까지 받았는지를 기억하고, 클라이언트가 그것을 조회한다” 는 합의. Redis 세션은 캐시일 뿐 — 진실의 원천은 서버 측 객체 스토어다.