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-Metadata | base64 인코딩된 키-값 (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 | 역할 |
|---|---|
CreateMultipartUpload → UploadId | 세션 시작 |
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 비교
| 항목 | tus | S3 Multipart |
|---|---|---|
| 표준성 | 오픈 프로토콜 | AWS API (그러나 사실상 표준) |
| 청크 크기 | 임의 (1바이트도 OK) | 5MB+ (마지막 제외) |
| 재개 정밀도 | 1바이트 | 1파트 (5MB 단위 손실 가능) |
| 서버 구현 | 자체 구현 필요 | S3가 제공 |
| 클라이언트 라이브러리 | tus-js-client, Uppy @uppy/tus | AWS 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 세션은 캐시일 뿐 — 진실의 원천은 서버 측 객체 스토어다.