02. Multipart & Range — 큰 파일은 “잘라서” 다룬다
5GB 동영상을 단일 PUT으로 올리는 건 이론적으론 가능, 실전에선 자살. 큰 파일은 올릴 때도(multipart upload) 받을 때도(Range) 잘라서 다룬다. 이 문서는 그 두 분할 모델을 한 번에 정리한다.
한 줄 답
큰 파일은 두 번 잘린다 — 올라갈 때 S3 Multipart로, 내려올 때 HTTP Range로. 둘 다 “단일 거대 객체”라는 환상을 내부에서는 작은 청크의 집합으로 바꿔 다루는 트릭이다.
Why — 왜 잘라야 하는가
큰 단일 PUT의 실패 모드
5GB 동영상을 한 번의 PUT으로 올린다고 해보자. LTE 5MB/s에서:
- 업로드 시간 = 5GB / 5MB/s ≈ 1000초 (16분)
- 그 16분 동안 단 한 번의 네트워크 끊김이 전체를 무효화
- TCP 1개에 5GB가 흐르므로 혼잡 제어가 띄엄띄엄해서 처리량 저하
- 진행률 표시도 전송된 바이트만 알 수 있어 부정확
큰 단일 GET의 실패 모드
- 비디오 시킹: 사용자가 30분짜리 영상의 25분 지점으로 이동 → 처음부터 다운로드?
- 동시 시청자 1만 명: CDN edge가 동일한 5GB를 1만 번 origin에서 fetch?
- 모바일에서 일시정지: 백그라운드에서 5GB 전체를 계속 받는 게 맞나?
답은 모두 NO. 그래서 잘라야 한다.
How — 두 가지 분할 모델
모델 1 — multipart/form-data (브라우저 폼 업로드)
<input type=file> + <form enctype=multipart/form-data>로 업로드할 때 브라우저가 만드는 포맷.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4
------WebKitFormBoundary7MA4
Content-Disposition: form-data; name="title"
제목
------WebKitFormBoundary7MA4
Content-Disposition: form-data; name="file"; filename="video.mp4"
Content-Type: video/mp4
<binary 5GB...>
------WebKitFormBoundary7MA4--⚠️ 이건 여러 필드를 한 요청에 묶는 포맷이지 큰 파일을 잘라 보내는 포맷이 아니다. 5GB 파일이 통째로 바디에 들어간다 — 그래서 큰 파일에는 부적합.
모델 2 — S3 Multipart Upload (진짜 분할)
S3 API. 1 파일을 N개의 파트로 잘라서 각각 PUT, 마지막에 합치라고 명령.
| 단계 | API | 의미 |
|---|---|---|
| 1 | CreateMultipartUpload | UploadId 발급 (서버 측 세션) |
| 2 | UploadPart × N | 각 파트를 PUT (병렬 가능) |
| 3 | CompleteMultipartUpload | 파트 ETag 목록을 보내 합치기 명령 |
| 또는 | AbortMultipartUpload | 취소 (안 하면 영원히 비용 발생) |
제약
- 파트 크기: 5MB ~ 5GB (마지막 파트는 5MB 미만 OK)
- 파트 수: 1 ~ 10,000개
- 객체 최대: 5TB
- UploadId 유효 기간: 명시적 abort/complete까지 (= 오래되면 비용)
한 프로덕션 사례 실측
// 의사 코드 — 클라이언트 상수 파일
export const FILE_UPLOAD = {
CHUNK_SIZE: 100 * 1024 * 1024, // 100MB
MAX_CONCURRENT_UPLOADS: 5,
...
};→ 5GB 영상을 100MB × 50파트, 동시 5개로 올린다. 실효 처리량: 단일 파트보다 3~4배 빠름 (혼잡 제어 병렬화 + 재시도 비용 분산).
모델 3 — HTTP Range (다운로드 분할)
Range: bytes=START-END 헤더로 일부만 요청. 서버는 206 Partial Content로 응답.
GET /video.mp4 HTTP/1.1
Range: bytes=0-1048575
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/5242880000
Content-Length: 1048576
Accept-Ranges: bytes
Content-Type: video/mp4
<바이너리 1MB>Range 헤더 형식
| 형식 | 의미 |
|---|---|
bytes=0-499 | 처음 500바이트 |
bytes=500- | 500부터 끝까지 |
bytes=-500 | 마지막 500바이트 |
bytes=0-0,-1 | 첫 1바이트 + 마지막 1바이트 (multipart/byteranges 응답) |
누가 Range를 자동으로 쓰나
<video>,<audio>태그 — 시킹 시 해당 시점의 byte range 요청- HLS/DASH 플레이어 (Shaka Player 등) — 세그먼트 단위 GET
- yt-dlp, aria2 등 다운로드 매니저 — 병렬 분할 다운로드
- Chrome의 다운로드 매니저 — 큰 파일은 자동 분할
Range 미지원의 증상
S3, CloudFront는 기본 지원. 자체 서버에서 nginx 같은 거 쓸 때 누락하면:
- 비디오를 시킹 못 함 (처음부터만 재생)
- 모바일 브라우저에서 재생 자체가 거부되는 케이스 있음 (iOS Safari)
- DRM 매니페스트 로드 실패
What — 실전 흐름 (한 프로덕션 사례)
업로드 시퀀스 (5GB 영상)
핵심 코드 위치
backend/libs/file-storage-service.ts (의사 경로)
├─ initMultipartUpload() → CreateMultipartUploadCommand
├─ generateMultipartUploadUrls() → presigned URL × N (병렬)
├─ completeMultipartUpload() → 파트 정렬 후 합치기
├─ abortMultipartUpload() → 취소 (비용 누수 방지)
└─ listUploadedParts() → 재개용 (다음 문서)
frontend/features/file/utils/upload.ts (의사 경로)
├─ uploadPartToS3(url, chunk, signal)
│ - fetch(url, { method: 'PUT', body: chunk })
│ - response.headers.get('ETag') → 트림(따옴표 제거) → 반환
└─ extractChunk(file, partNumber)
- file.slice((n-1)*CHUNK_SIZE, n*CHUNK_SIZE) (Blob slice는 zero-copy)다운로드 (Range) 실전 예시
# 1) 헤더 확인 — Accept-Ranges 광고가 있어야
curl -I https://cdn.example.com/video.mp4
# accept-ranges: bytes
# 2) 첫 1MB만 (moov 박스 + 인덱스 포함)
curl -r 0-1048575 -o head.mp4 https://cdn.example.com/video.mp4
# 206 Partial Content
# 3) 마지막 1MB만
curl -r -1048576 -o tail.mp4 https://cdn.example.com/video.mp4
# 4) 병렬 다운로드 (aria2)
aria2c -x 8 -s 8 https://cdn.example.com/video.mp4
# 8개 connection으로 8분할 병렬 → 단일 GET 대비 3~5배 빠름multipart/form-data vs S3 multipart 비교
| 항목 | multipart/form-data | S3 Multipart Upload |
|---|---|---|
| 정의 | 폼 필드 직렬화 포맷 | S3 API 시퀀스 |
| 청크화 | ✗ (한 바디에 모두) | ✓ (각 파트가 독립 PUT) |
| 병렬 가능 | ✗ | ✓ |
| 재개 가능 | ✗ | ✓ (UploadId만 알면) |
| 사용처 | 폼 + 파일 1개, 작은 파일 | 큰 파일 (5MB+) |
| 대표 라이브러리 | 그냥 <form> | Uppy AwsS3Multipart, AWS SDK |
What-if — 흔한 함정
| 함정 | 결과 | 해결 |
|---|---|---|
abortMultipartUpload 안 부름 | 미완료 파트가 영원히 S3에 남아 비용. 고객 발견 시 분노 | S3 Lifecycle Rule: AbortIncompleteMultipartUpload (예: 7일) |
| 파트 크기 5MB 미만 (마지막 제외) | EntityTooSmall 에러 | 정확히 5MB+ 또는 마지막에 몰기 |
| 파트 100,000개 만들기 | TooManyParts | 청크 크기 키워서 ≤ 10,000으로 |
| Range 미지원 서버에 비디오 올림 | 시킹 불가, iOS 재생 거부 | nginx add_header Accept-Ranges bytes; + 정적 파일이면 자동 |
Content-Range 응답을 직접 만들면서 off-by-one | 마지막 바이트 누락 → 파일 깨짐 | bytes A-B/Total은 inclusive: A~B 둘 다 포함 |
| 파트 ETag를 복원할 때 따옴표 안 벗김 | MalformedXML complete 실패 | etag.replace(/"/g, '') 필수 |
| ETag로 무결성 검증 | multipart 객체는 MD5 아님 → 검증 깨짐 | SHA256 trailer 또는 별도 매니페스트 (06번 문서) |
Insight — 흥미로운 이야기
“Multipart/form-data는 1995년 RFC 1867”
폼 파일 업로드는 거의 30년이 됐다. boundary라는 텍스트 구분자로 바이너리를 직렬화하는 그 발상은 이메일 첨부(MIME)에서 가져왔다. 즉, HTTP 파일 업로드는 이메일 첨부의 후손이다.
“S3 Multipart는 GFS의 청크에서 영감”
Google File System(2003)이 64MB 청크 단위로 파일을 잘라 저장한 이래, 분산 스토리지의 합의된 단위는 64~128MB가 됐다. S3 Multipart의 권장 파트 크기 100MB도 그 흐름에 정확히 맞춰져 있다.
“FFmpeg는 Range로 비디오를 읽을 수 있다”
ffmpeg -i https://...만으로 원격 mp4를 읽는 게 가능한 이유는 FFmpeg의 HTTP 프로토콜 모듈이 Range 요청으로 moov만 먼저 가져오기 때문. 그래서 Lambda에서 5GB 영상을 전체 다운로드 없이 메타데이터만 추출하는 게 가능하다. → 한 프로덕션 미디어 변환 람다가 정확히 이 트릭을 쓴다.
다음 문서로
100MB 파트를 50개 올리다 35번째에서 끊기면 어떻게 이어붙이는가?
→ 03-resumable-tus.md (재개 가능 업로드)
요약 + Mermaid
큰 파일은 두 방향에서 잘린다 — 업로드는 S3 Multipart, 다운로드는 HTTP Range. multipart/form-data는 폼 직렬화 포맷이지 분할 업로드가 아니다 (자주 혼동). 100MB 청크 + 동시 5개가 실측에서 자주 나오는 sweet spot. Range는 비디오 시킹과 ABR 스트리밍의 원자 단위이며, 다음 챕터들의 토대다.