콘텐츠로 이동

Spring Boot Resumable Upload — tus.io ↔ Azure Block Blob

모바일 대용량 파일을 tus 프로토콜과 Azure Block Blob으로 재개 업로드하는 구성을 설명합니다.

모바일 사용자 약 20만 명 규모, 일반 50 MB · 최대 200 MB 파일을 끊김 후 이어 올리되, 파드 OOM 없이 Azure Block Blob 으로 자연스럽게 흘러가게 만든 샘플.

요건

  • 사용자 규모: 모바일 사용자 약 20만 명. 다수가 동시에 업로드 시도 — 파드당 수십~수백 동시 PATCH 가능성
  • 파일 크기: 일반 50 MB, 최대 200 MB. 모바일 업로드 패턴상 1 MiB chunk 구조면 50~200개 PATCH/파일
  • 모바일 끊김 / 이어 올리기: 셀룰러↔WiFi 전환, 백그라운드 진입, OS suspend, 앱 강제 종료가 발생해도 처음부터 다시 보내지 않고 끊긴 지점부터 이어 올린다. iOS / Android 표준 SDK 그대로 사용 (클라이언트 커스텀 구현 회피)
  • 파드 OOM 방지: 동시 N 개의 PATCH 가 들어와도 chunk 전체(수 MB~수십 MB)를 힙에 들고 있지 않아야 함. JVM 메모리는 동시 접속 수와 무관하게 작은 버퍼만 유지
  • Azure Blob 으로 자연스러운 업로드: 서버가 chunk 를 임시 버퍼링으로 재조립하지 않고, 클라이언트 chunk 가 그대로 Blob block 으로 1:1 매핑되어 흘러가야 함

해결책

  • client ↔ server: tus.io 1.0.0 프로토콜 — chunk 단위 PATCH + HEAD 로 권위 offset 재동기화. 모바일은 TUSKit / tus-android-client / tus-js-client 그대로 사용 (백그라운드 task·재시도·망 전환·재부팅 후 resume 까지 SDK 가 처리)
  • server ↔ blob: 요청 InputStream 을 그대로 BinaryData.fromStream(in, length) → BlockBlobClient.stageBlock(...). chunk 1개를 한 번에 들고 있는 객체가 경로상 없음 → 동시 PATCH N 개라도 힙 ≈ N × ~64 KB. tus 의 "chunk → finalize" 가 Block Blob 의 "stage block + commit block list" 와 1:1 대응이라 별도 변환 레이어 불필요

추가:

  • 무상태: PATCH 1개 = staged block 1개. 진행 offset 은 매 요청마다 listBlocks(UNCOMMITTED) 로 Azure 에서 다시 계산 → 어떤 파드가 받든 동일 결과. 시나리오 C 가 다른 JVM 으로 검증. 지금은 진행 상태를 모두 Blob 에서 관리하지만, 어차피 이 서비스가 사용자 세션을 Redis 로 관리한다면 업로드 진행 상태도 같은 Redis 에 캐시해 성능을 끌어올릴 여지가 있음 — "알아둘 점" 참고
  • 재시도 위치 이동: stream 이 non-replayable 이라 Azure SDK 의 transient retry 는 꺼지지만, 그게 정확히 클라이언트 HEAD → PATCH 루프가 담당하는 일 — 시나리오 D 로 검증

아키텍처

Architecture — Mobile (tus 1.0.0) ▸ AKS Pods ▸ Azure Blob

편집 원본: spring-application/architecture.excalidraw

샘플 코드 위치: spring-application/

왜 tus 인가

모바일 업로드의 어려운 부분(백그라운드 진입, 셀룰러↔WiFi 전환, OS suspend, 부분 재전송 자체)은 클라이언트 SDK 영역이다. TUSKit (iOS) 과 tus-android-client + WorkManager 가 그걸 이미 다 풀어두었으므로, 서버를 tus 사양에 맞추면 안드/iOS 양쪽 모바일팀이 SDK를 그대로 가져다 쓸 수 있다.

내부 매핑은 자연스럽다 — tus의 "stage chunk + finalize" 흐름이 Azure Block Blob의 "stage block + commit block list" 모델과 1:1로 대응한다.

프로토콜 요약 (구현된 부분)

HTTP 경로 동작 Azure 호출
OPTIONS /files 지원 버전·확장 광고 (없음)
POST /files Upload-Length, Upload-Metadata 받아 upload id 생성 uploadWithResponse(empty, metadata) — 빈 blob에 메타데이터 저장
HEAD /files/{id} 현재 Upload-Offset 반환 (이어 올릴 지점) getProperties + listBlocks(UNCOMMITTED)
PATCH /files/{id} body=chunk를 누적 offset에 append. 누적이 length 도달 시 commit stageBlock, 마지막엔 commitBlockListWithResponse(metadata 유지)
DELETE /files/{id} upload 폐기 delete()

구현된 확장: creation, termination. 미구현: expiration, checksum, concatenation.

서버에 in-memory session 보관 없음. id가 곧 blob 이름이며, 진행 상태는 항상 Azure에서 읽는다.

Block ID 매핑

각 PATCH = 1개 staged block. block id = base64("block-" + 12자리 zero-padded offset) — 인덱스(개수)가 아니라 offset 에서 결정론적으로 파생한다. 같은 offset 은 어떤 파드가 받든, 클라이언트가 어떻게 재시도하든 항상 같은 block id 로 매핑되므로, 인플라이트 재시도로 동일 PATCH 두 건이 동시에 실행돼도 같은 바이트를 같은 block 에 다시 stage(멱등) 할 뿐 경쟁이 결과를 바꾸지 않는다. offset 을 고정 자리수(12자리, ~900 GiB 커버)로 zero-pad 하므로 모든 block id 길이가 동일해야 한다는 Azure 제약도 충족. commit 시엔 staged block 들을 block id 에 인코딩된 offset 순으로 정렬해 commitBlockList 에 넘기므로 chunk 크기가 중간에 바뀌어도 정상 조립된다.

Edge cases

  • Upload-Offset 가 서버 현재 offset과 다르면 → 409 Conflict
  • Content-Length 없거나 chunked transfer-encoding이면 → 411 Length Required
  • 클라이언트가 PATCH body를 끝까지 안 보내면 → Azure가 Content-Length 불일치로 거부하고 SDK exception 발생, block은 stage 되지 않으므로 offset 변화 없음
  • Tus-Resumable 헤더 누락/불일치 → 412 Precondition Failed (Tus-Version 으로 지원 버전 알려줌)

데이터 흐름 (end-to-end streaming)

PATCH 한 건이 힙을 거치지 않고 Azure 까지 흘러가는 경로
Mobile client
    │  PATCH /files/{id}  Content-Length=1048576
    │  body = 1 MiB chunk
    ▼
TusController.patch()                  ← byte[] 할당 없음
    │  req.getInputStream()             ← Tomcat 8 KB 링버퍼
    ▼
TusUploadService.appendChunk(...,InputStream,length)
    │  BinaryData.fromStream(in, length) ← non-replayable, SDK retry off
    ▼
BlockBlobClient.stageBlock(blockId, BinaryData)
    │  Netty가 8 KB 단위로 socket→socket relay
    ▼
Azure Storage (block staged, not yet committed)

전체 경로에서 chunk 1개를 한 번에 들고 있는 객체가 없다. PATCH가 동시 N개 들어와도 힙 점유는 N × (Tomcat read buffer + Netty pooled chunks) ≈ N × ~64 KB.

사전 준비

1. Storage Account (Entra ID 전용)

Storage Account 생성 + RBAC 부여
az login

RG=rg-resumable-upload
ACCT=stresumableup$RANDOM
LOC=koreacentral

az group create -n $RG -l $LOC
az storage account create -n $ACCT -g $RG -l $LOC --sku Standard_LRS \
    --allow-blob-public-access false \
    --allow-shared-key-access false                 # account key 사용 자체를 막음

SCOPE=$(az storage account show -n $ACCT -g $RG --query id -o tsv)
ME=$(az ad signed-in-user show --query id -o tsv)
az role assignment create --assignee-object-id "$ME" \
    --assignee-principal-type User \
    --role "Storage Blob Data Contributor" \
    --scope "$SCOPE"

sleep 60   # role propagation

2. 환경 변수

env
export AZURE_STORAGE_ACCOUNT=$ACCT
export AZURE_STORAGE_CONTAINER=uploads

DefaultAzureCredential 이 az login 컨텍스트(AzureCliCredential) 를 자동으로 집어간다.

3. Python deps

pip install
pip install requests

빌드 & 실행

build & run
cd docs/services/azure-storage/mobile-resumable-upload-tus/samples/spring-application
mvn -DskipTests package
java -jar target/spring-resumable-upload-0.1.0.jar
# or: mvn spring-boot:run

8080 포트에서 listen.

테스트 시나리오

0. 테스트 파일

50 MB 테스트 파일 생성
cd docs/services/azure-storage/mobile-resumable-upload-tus/samples/spring-application/scripts
./make-test-file.sh test-50mb.bin 50

시나리오 A — 정상 streaming 업로드

50 MB 파일을 1 MiB chunk × 50번의 PATCH로 올린다. 마지막 PATCH에서 서버가 자동으로 commitBlockList 호출.

실행
python3 tus_client.py upload --file test-50mb.bin
# 출력 마지막 줄에 upload id가 찍힘
./verify-blob.sh test-50mb.bin <printed-upload-id>

확인 포인트: - 서버 로그: POST /files 1회, PATCH /files/{id} 50회 - 클라이언트 출력: PATCH #1 ~ PATCH #50, 마지막에 committed blob ... - 서버 JVM 힙: PATCH 1개당 ~64 KB 만 점유 (Tomcat read buffer + Netty pooled chunk). -Xmx256m 으로 가동해도 RSS 누적 없음 (실측: 50개 PATCH 처리 중 RSS 오히려 8 MB 감소).

시나리오 B — 모바일 앱이 강제 종료된 후 같은 업로드 재개 (핵심)

목적: 모바일 환경의 표준 케이스. 앱이 백그라운드에서 OS에 의해 kill 되어도 다음 실행 때 정확히 이어 올림.

stop-after → HEAD → resume → verify
# 1) 20개 chunk만 PATCH 후 종료. upload-id 출력됨.
python3 tus_client.py upload --file test-50mb.bin --stop-after 20
# → 마지막 줄: "to resume: --id <UUID>"

# 2) 서버에 어디까지 받았는지 직접 묻기 (모바일 SDK가 부팅 시 하는 일)
python3 tus_client.py head --id <UUID>
# → offset=20971520 length=52428800 progress=40.0%

# 3) 같은 id로 재실행 → HEAD로 offset 확인 후 21번째 chunk부터 PATCH
python3 tus_client.py upload --file test-50mb.bin --id <UUID>
# → "server offset=20971520/52428800 (40.0%)"
# → PATCH #1 offset=20971520 ... (남은 30개만)

# 4) 바이트 정확성 검증
./verify-blob.sh test-50mb.bin <UUID>

시나리오 C — 서버 프로세스 재시작 후에도 재개 가능 (무상태성 증명)

목적: 서버에 in-memory 세션 상태가 없음을 확인. 진행 상태는 Azure에만 있다.

서버 재시작 후 같은 upload id로 재개
# 터미널 1 (클라이언트)
python3 tus_client.py upload --file test-50mb.bin --stop-after 25
# id 메모

# 터미널 2 (서버)
#   Ctrl-C 로 종료 → 다시 `mvn spring-boot:run`

# 터미널 1 (클라이언트, 서버 재시작 후)
python3 tus_client.py head --id <UUID>
# → 여전히 offset=26214400 (서버는 처음 보지만 Azure가 기억)

python3 tus_client.py upload --file test-50mb.bin --id <UUID>
./verify-blob.sh test-50mb.bin <UUID>

시나리오 D — PATCH 단위 실패 + 자동 재시도 (모바일 망 불안정 시뮬)

목적: 임의의 한 chunk 전송이 실패해도 클라이언트가 HEAD로 권위 있는 offset을 다시 받아서 그 지점부터 이어 올리는, 모바일 SDK의 표준 retry 패턴을 검증.

실행
# 10번째 PATCH 시도 시 강제로 망 끊김 시뮬레이션 (chunk 미전송)
python3 tus_client.py upload --file test-50mb.bin --fail-on-patch 10
기대 출력 (요약)
PATCH #9  offset=8388608 size=1048576 -> new_offset=9437184
PATCH #10 offset=9437184: INJECTED FAULT (chunk not sent)
PATCH failed (fail-on-patch), HEAD to re-sync
server says offset=9437184, resuming
PATCH #11 offset=9437184 size=1048576 -> new_offset=10485760
...
done: sent 50 chunk(s) over 51 attempt(s)
committed blob id=<UUID> size=52428800

핵심: fault 직후 HEAD가 마지막 성공 offset을 반환 — 서버는 실패한 chunk를 기록하지도, 잘못 기록하지도 않았다. 재시도 시 동일 offset으로 다시 PATCH하면 새 block index에 stage 되어 정상 commit.

검증 결과 요약

항목 동작 실측 (50 MB / 1 MiB chunk / -Xmx256m)
메모리 streaming PATCH body가 힙에 누적되지 않음 50개 PATCH 동안 RSS 변화 -8 MB (누적 0)
chunk 단위 resume 받은 chunk는 다시 안 받음 stop-after 20 → resume 시 PATCH 30개만 전송
서버 무상태 서버 재시작·교체에도 진행 상태 보존 PID 41650 → 45078 (다른 JVM)에서 offset 26214400부터 이어 올림
자동 재시도 client가 HEAD로 권위 offset 재동기화 후 같은 chunk 재전송 fail-on-patch 10 → "50 chunks over 51 attempts"
표준 프로토콜 TUSKit / tus-android-client / tus-js-client 같은 기성 SDK와 무조건 호환 —
Entra ID 전용 account key 비활성화 (--allow-shared-key-access false) 상태 동작 DefaultAzureCredential → AzureCliCredential
바이트 정확성 commit 후 verify-blob.sh 의 cmp 통과 4 시나리오 모두 통과

알아둘 점

  • Uncommitted block TTL은 7일. 그 사이 commit이 없으면 staged block GC. 즉 resume window가 7일.
  • 단일 blob 최대 50,000 blocks. 1 MiB chunk면 ~50 GiB, 4 MiB chunk면 ~200 GiB까지 1 blob 가능. 50 MB 테스트는 신경 쓸 일 없음.
  • Concurrent PATCH on same upload는 지원하지 않음 — tus 사양상 PATCH는 순차다. 서로 다른 chunk 가 동시에 들어오면 Upload-Offset 검사가 뒤엣것을 409로 막는다. 모바일에서 흔한 케이스(응답 유실 → 같은 chunk 재시도 → 원 요청과 인플라이트 충돌)는 offset 기반 결정론적 block id 덕에 같은 바이트를 같은 block 에 다시 stage 하는 멱등 연산이 되어 안전하다 — 따라서 분산 락은 불필요하고(무상태성·성능 유지) 낭비되는 건 중복 stageBlock 트랜잭션 1회뿐. 동일 파일을 진짜 병렬로 올리려면 concatenation 확장이 필요한데 이 샘플엔 없음.
  • PATCH는 end-to-end streaming. req.getInputStream() 을 그대로 BinaryData.fromStream(in, length) 로 SDK에 넘긴다. chunk 전체를 들고 있는 객체는 없음. 대신 stream이 non-replayable 이므로 Azure SDK의 transient retry 가 비활성화된다 — 재시도는 클라이언트의 HEAD→PATCH 루프가 담당한다 (시나리오 D가 정확히 이 경로를 검증).
  • chunk 크기 선택 = 1순위 비용 레버. Azure Storage 비용은 데이터 전송보다 Put/Stage Block 같은 쓰기(Class A) 트랜잭션 수가 지배적이다. 기본 1 MiB는 모바일 셀룰러 환경(끊김 시 손실 최소화)에 맞춘 값이지만, 4 MiB로 올리면 200 MB 파일 기준 PATCH가 200회 → 50회로 75% 감소하고 읽기(listBlocks)·쓰기(stageBlock) 트랜잭션이 함께 1/4로 줄어 인프라 비용이 확실하게 75% 절감된다. Background Task 환경에서도 4 MiB는 무난. --chunk-size $((4*1024*1024)). block 수 제한(50,000)과 끊김 시 재전송 손실의 trade-off이므로, Redis 도입보다 먼저 검토할 것 (Redis는 읽기 트랜잭션만 줄이고 쓰기는 못 줄인다).
  • 메타데이터 보존: commitBlockList 는 metadata를 덮어쓰므로, 최종 commit 시 POST에서 저장해둔 x_tus_* 메타를 다시 넘겨준다 (TusUploadService.appendChunk).
  • TLS / 인증 / CORS: 이 샘플은 평문 HTTP, 익명 접근. 운영에서는 HTTPS + Spring Security + 모바일 앱 인증(Entra ID, Firebase Auth, JWT 등) 필요.
  • 다중 파드 확장: 외부 상태(Redis 등) 불필요. id가 곧 blob 이름이고 진행 상태는 Azure가 보관 — 어떤 파드가 PATCH를 받든 listBlocks(UNCOMMITTED) 로 오프셋을 다시 계산한다 (Azure Blob 의 strong read-after-write consistency 에 의존). 시나리오 C가 "다른 JVM" 케이스로 이를 입증.
  • 성능 최적화 옵션 — Redis 캐시 도입 (2순위, chunk 상향 이후). chunk 크기 상향으로 트랜잭션 모수를 먼저 깎은 뒤 남는 건 읽기 트랜잭션의 p99 레이턴시다. Redis는 쓰기 트랜잭션을 줄이지 못하므로(실제 바이트는 그대로 stageBlock 해야 함) 비용 절감 효과는 읽기(listBlocks/getProperties)분에 한정된다. 대규모(예: 200K MAU 수준)에서 이 읽기 latency 와 읽기 트랜잭션을 더 줄이려면 Redis를 advisory 캐시 계층으로 끼울 수 있다.
  • 현재: HEAD/PATCH 1회당 getProperties + listBlocks (Azure RTT 2회, p99 30~50 ms)
  • Redis 추가 시: HGETALL upload:{id} 1 RTT(~1 ms) + 성공 후 HINCRBY offset. Azure metadata 호출 1회 절감
  • 200K MAU × 1.5 업로드/일 × 50 chunk 기준 storage transaction 비용 ~$400/월 → ~$0 (Redis Basic C1 ~$50/월 추가)
  • 단 source of truth는 그대로 Azure로 두고 Redis는 캐시로만 사용. MISS 시 listBlocks로 fallback해서 Redis에 재적재. Redis 장애가 데이터 무결성을 깨지 않음. 시나리오 C(서버 재시작)도 그대로 작동
  • commit 직후 캐시 무효화 필요 — commitBlockList 성공 시 Redis upload:{id} 키 DEL. 그래야 같은 id 로 완료된 업로드에 대해 stale offset 이 올라오지 않음
  • 코드 변경 폭은 작음 — TusUploadService.status / appendChunk 에 HGETALL / HINCRBY 두 줄과 try-fallback만 추가

모바일 SDK 연동 참고

플랫폼 SDK 핵심 사용법
iOS TUSKit let tusClient = TUSClient(server: URL(...), sessionIdentifier: "uploads", storageDirectory: ...)
Android tus-android-client TusUpload upload = new TusUpload(file); TusUploader uploader = tusClient.resumeOrCreateUpload(upload);
RN/웹 tus-js-client new tus.Upload(file, { endpoint, retryDelays: [0, 3000, 5000, 10000, 20000], chunkSize, onProgress, onSuccess })

세 SDK 모두 백그라운드 task, 망 전환, 재부팅 후 resume까지 자동으로 처리한다. 서버는 이 샘플 그대로면 호환.

참고 문서