왜 단일 PUT은 한계에 부딪히는가

수백 MB에서 수 GB에 달하는 파일을 단일 PUT으로 올리면 세 가지 문제가 동시에 터진다. 첫째, 대부분의 object storage는 단일 오브젝트 업로드에 5GB 상한이 있다. 둘째, 하나의 TCP 스트림으로는 대역폭을 전부 못 쓴다. RTT가 큰 원거리 리전일수록 단일 커넥션 처리량은 급격히 떨어진다. 셋째, 네트워크가 한 번 끊기면 전체를 처음부터 다시 올려야 한다. 멀티파트 업로드는 파일을 여러 파트로 쪼개 병렬 전송하고, 실패한 파트만 재시도하는 방식으로 이 세 문제를 한 번에 해결한다.

파트 크기: 가장 중요한 튜닝 포인트

멀티파트는 파트당 최소 5MB(마지막 파트 예외), 최대 파트 수 10,000개라는 제약이 있다. 파트가 너무 작으면 요청 오버헤드와 API 호출 비용이 늘고, 너무 크면 재시도 비용이 커지고 병렬성이 떨어진다. 실무 기준은 아래와 같다.

파일 크기권장 파트 크기파트 수
~1GB16MB약 64개
1~10GB32~64MB수백 개
10~100GB128~256MB수백 개
100GB 이상512MB 이상10,000개 상한 주의

핵심은 10,000개 상한을 넘지 않도록 파일 크기에 비례해 파트 크기를 동적으로 계산하는 것이다. 고정 파트 크기를 쓰면 대용량에서 업로드가 통째로 실패한다.

병렬 업로드 구현

SDK의 고수준 API를 쓰되 동시성과 파트 크기는 명시적으로 지정한다. boto3 예시다.

import boto3
from boto3.s3.transfer import TransferConfig

s3 = boto3.client("s3")

config = TransferConfig(
    multipart_threshold=16 * 1024 * 1024,   # 16MB 이상이면 멀티파트
    multipart_chunksize=32 * 1024 * 1024,   # 파트 크기 32MB
    max_concurrency=16,                       # 동시 파트 수
    use_threads=True,
)

s3.upload_file(
    "large.bin", "my-bucket", "backups/large.bin",
    Config=config,
)

max_concurrency는 CPU·대역폭과 함께 튜닝한다. 무작정 올리면 커넥션 풀 고갈과 메모리 급증(파트 크기 × 동시성 만큼 버퍼가 잡힌다)을 부른다.

저수준 API로 재시도 제어하기

고수준 API로 안 되는 세밀한 재시도·체크섬 검증이 필요하면 low-level API를 직접 다룬다. 각 파트의 ETag를 모아 완료 요청에 넘기는 것이 핵심이다.

upload = s3.create_multipart_upload(Bucket="my-bucket", Key="k")
parts = []
part_no = 1
with open("large.bin", "rb") as f:
    while chunk := f.read(32 * 1024 * 1024):
        resp = s3.upload_part(
            Bucket="my-bucket", Key="k",
            UploadId=upload["UploadId"],
            PartNumber=part_no, Body=chunk,
        )
        parts.append({"ETag": resp["ETag"], "PartNumber": part_no})
        part_no += 1

s3.complete_multipart_upload(
    Bucket="my-bucket", Key="k",
    UploadId=upload["UploadId"],
    MultipartUpload={"Parts": parts},
)

미완료 업로드는 반드시 청소한다

가장 흔히 놓치는 부분이다. 업로드가 중간에 실패하고 abort도 complete도 호출되지 않으면, 이미 올라간 파트는 스토리지에 그대로 남아 과금된다. 오브젝트 목록에는 보이지 않아 원인을 찾기 어렵다. 라이프사이클 규칙으로 자동 정리하자.

{
  "Rules": [
    {
      "ID": "abort-incomplete-multipart",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "AbortIncompleteMultipartUpload": {
        "DaysAfterInitiation": 7
      }
    }
  ]
}

애플리케이션 레벨에서도 예외 발생 시 abort_multipart_upload를 반드시 호출하는 방어 코드를 둔다. 두 장치를 겹쳐야 누수를 막는다.

무결성과 성능을 함께 챙기는 주의점

첫째, 파트별 체크섬(예: SHA-256, CRC32C)을 함께 전송하면 서버가 파트 단위로 무결성을 검증한다. 대용량일수록 전송 중 비트 손상 검출 가치가 크다. 둘째, ETag는 멀티파트 오브젝트에서는 MD5가 아니라 파트들의 해시를 조합한 값이므로 무결성 비교 용도로 그대로 쓰면 안 된다. 셋째, 키 이름에 임의 프리픽스를 분산하면 특정 파티션 핫스팟을 피해 처리량을 끌어올릴 수 있다. 넷째, 같은 리전에서 업로드하도록 클라이언트 위치와 버킷 리전을 맞추고, 원거리라면 가속 전송 엔드포인트나 CDN 기반 업로드를 검토한다.

정리

멀티파트 업로드 최적화의 실질은 세 가지로 요약된다. 파일 크기에 맞춘 동적 파트 크기, 대역폭에 맞춘 동시성, 그리고 미완료 업로드 정리다. 이 셋만 제대로 잡아도 처리량은 배 이상 오르고, 숨은 과금과 재시도 지옥에서 벗어난다.