← 개발 노트

500 GB를 R2로: 거대한 업로드가 멈출 때, 그리고 다시 시작할 때 일어나는 일

2026년 9월 8일 · RocketBucket의 멀티파트 업로드와 이어올리기

TL;DR

RocketBucket으로 500 GB 파일을 Cloudflare R2에 올리다가 중간에 멈추고, 다시 시작했습니다. 끝났습니다 — 536,870,912,000바이트, 9,847개 파트. 첫 파트부터 조립이 끝난 오브젝트까지 실행 내내 메모리를 지켜봤는데, 약 900 MB에서 1 GB를 넘은 적이 없습니다. 그 아래에서 무슨 일이 일어나는지, 그리고 이 문장이 참이 되기 전에 무엇을 고쳐야 했는지에 대한 이야기입니다.

S3에게 500 GB는 어떻게 보이는가

S3와 그 API를 말하는 모든 서비스는 큰 파일을 요청 하나로 받지 않습니다. 파트로 받습니다. 최대 10,000개, 각 파트는 5 MiB에서 5 GiB 사이, 그리고 마지막에 그것들을 하나의 오브젝트로 붙이는 호출 한 번. RocketBucket의 기본 파트는 8 MiB지만 500 GB를 8 MiB로 나누면 64,000개 파트가 되므로, 업로드가 시작되기 전에 거꾸로 계산합니다. 500 GB ÷ 10,000, MiB 단위로 올림하면 52 MiB. 이 결정은 영구적입니다. 첫 파트가 서버에 올라간 뒤에는 파트 크기를 바꿀 수 없고, 이어올리기는 파일을 정확히 같은 자리에서 잘라야지 아니면 아무것도 맞지 않습니다.

동시에 다섯 파트가 날아갑니다. 파일은 스트리밍으로 읽히고 통째로 적재되지 않으므로, 전송에 필요한 메모리는 대략 다섯 파트 더하기 버퍼 하나 — 약 300 MB — 에 네트워크 라이브러리 자체의 복사본이 얹힌 정도입니다. 우리가 측정한 900 MB~1 GB가 그 범위입니다.

업로드가 진행되는 동안 두 가지가 기록되는데, 둘 다 멈추는 순간만을 위해 존재합니다. 오브젝트는 생성 시 메타데이터에 무작위 커밋 토큰이 찍힙니다. 그리고 디스크의 작은 저널에 파트마다 한 줄 — 번호, 크기, 그 바이트의 우리 자신의 SHA-256 — 이 서버가 그 파트를 받아들인 뒤에만 쓰입니다. 서버의 ETag를 믿지 않고 직접 해시하는 이유는, SSE-KMS 아래에서는 ETag가 파트의 MD5가 아니라 나중에 비교할 대상이 없어지기 때문입니다.

멈출 때 일어나는 일

업로드가 멈추는 이유는 둘입니다. 연결이 끊기거나, 당신이 중지를 누르거나. 두 번째에는 고백이 붙어 있습니다. 이번 주까지, 큰 파일 하나의 중간에 중지를 눌러도 아무 일도 일어나지 않았습니다. 취소 플래그는 파일과 파일 사이에서만 확인됐고, 9,847개 파트의 파일은 하나의 파일입니다. 고쳤습니다 — 취소는 이제 파트 업로드 안까지 들어가 날아가고 있는 파트들 안에서 효력을 냅니다 — 그리고 발견된 방식은 이 글 대부분과 같습니다. 실제로 10 GB 이어올리기를 돌리고 버튼을 눌러 본 것.

어느 경우든 같은 규칙이 적용됩니다. 전부 남긴다. 저널은 디스크에 기록되고, 서버의 멀티파트 업로드는 중단되지 않습니다. 도착한 6,000개 파트는 업로드 ID 아래 그대로 남아 기다립니다. 기다리는 동안 과금됩니다 — 모든 S3 호환 스토리지는 미완료 업로드의 파트에 요금을 부과하고, 일반 목록에는 보이지 않습니다 — 그래서 RocketBucket은 버킷의 미완료 업로드를 보여주고 삭제할 수 있게 합니다. 하지만 스스로 중단하는 일은 절대 없습니다. 자기가 만든 것이 아닐 수도, 다른 클라이언트의 것일 수도 있으니까요.

다시 시작할 때 일어나는 일

같은 파일을 같은 곳에 끌어다 놓습니다. RocketBucket은 그 파일의 한 바이트도 읽기 전에, 서버에서 그 키의 미완료 업로드를 나열하고 저널과 맞춰 봅니다. 해시도 디스크 읽기도 없이 — 무엇을 묻기 전에 해도 될 만큼 싼 작업입니다. 그런 다음 묻습니다. 이것을 이어갈지, 처음부터 할지(선택한 업로드만 중단), 취소할지.

이어가기를 선택하면 첫 확인은 이것이 아직 같은 파일인가입니다. 같은 크기, 같은 수정 시각, 같은 inode — 그래서 정확히 같은 크기의 다른 파일로 바꿔 놓은 것은 여기서 걸리고, 저널은 신뢰되지 않습니다. 다음으로 서버의 파트 목록이 저널과 일치해야 합니다. 우리가 기록한 모든 파트가, 기록한 ETag와 크기로 있어야 합니다. 그때에야 그 파트들이 채택됩니다.

채택된 파트는 다시 읽히지 않습니다. RocketBucket은 그것들을 건너뛰고 빠진 파트만 바이트 범위로 읽어, 각각을 해시해 저널에 쓰고 업로드합니다. 모든 파트가 이미 서버에 있는 이어올리기는 디스크에서 0바이트를 읽고 0번 업로드합니다. 10,000개 파트로 정확히 그것을 단언하는 테스트가 있습니다.

첫 버전은 그렇게 동작하지 않았고, 그 첫 버전이 500 GB가 무엇을 뜻하는지 우리에게 가르쳐 주었습니다. 건너뛰기 전에 각 파트를 검증하려고 파일을 0바이트부터 읽어 이미 올라간 9,800개까지 포함해 모든 파트를 해시했습니다. 한 번도 멈추지 않는 루프 안에서. 메모리가 14.5 GB까지 올라갔고 macOS가 죽였습니다. 처음엔 저널을 의심했습니다 — 파트마다 파일 전체를 다시 쓰고, 계속 자라는 문서를 만 번 다시 썼으니 — 그래서 64개 파트마다 묶었습니다. 그것은 진짜 버그였고 옳은 수정이었지만, 이어올리기는 여전히 14.5 GB에서 죽었습니다. 진짜 원인은 해시 루프에 있었습니다. 16진 인코딩에 String(format:)을 썼는데, 바이트마다 Objective-C 문자열을 하나씩 할당했고, 그것이 남긴 약 127,000개의 임시 객체를 아무것도 회수하지 않았습니다. 빠진 파트만 읽게 하자 그 질문 자체가 사라졌습니다.

어떻게 끝나는가

마지막 파트가 올라가면 RocketBucket은 1번부터 9,847번 파트가 모두 있고 크기 합이 파일과 일치하는지 확인한 뒤 Complete 요청을 보냅니다. 작은 파일이라면 그것으로 끝입니다. R2의 500 GB에서는 기묘한 30초의 시작입니다. 서버는 요청을 받아들이고, 그런 다음 9,847조각을 하나의 오브젝트로 조립하는 데 수십 초를 씁니다. 그 동안 오브젝트는 아직 존재하지 않고, 멀티파트 업로드는 이미 사라져 있습니다.

이걸 아는 이유는, 첫 500 GB 완료가 업로드 완료 여부가 불확실합니다. 오브젝트를 직접 확인해 주세요라고 보고했기 때문입니다 — 그런데 오브젝트는 거기 있었고, 완전했고, 우리 토큰이 붙어 있었습니다. Complete가 모호할 때 도는 대조 로직은 "오브젝트가 있는가, 내 토큰을 갖고, 내 크기인가"를 4초 간격으로 세 번 물었고, 매번 "업로드는 사라졌고 오브젝트는 없다"를 보고 포기했습니다. 이제는 여섯 번 — 2, 4, 8, 15, 15, 15초 — 묻고, 사라진 업로드를 답이 아닌 질문으로 다룹니다. 성공의 정의는 움직이지 않았습니다. 오브젝트가 응답하고, 우리 토큰을 갖고, 정확히 기대한 크기여야 합니다. 토큰이나 크기가 다르면 여전히 확신이 없다고 말합니다. 그 대안은 남의 것일지도 모르는 오브젝트를 우리 것이라 주장하는 것이니까요.

리뷰는 출시 전 이어올리기 경로에서 두 가지를 더 찾았습니다. 범위 읽기가 요청한 바이트를 모두 돌려줬는지 확인하지 않았다는 것 — 서버가 기꺼이 받아들일 잘린 오브젝트를 완료시킬 수 있었습니다 — 그리고 빠른 경로가 Complete를 직접 호출해 위의 대조를 건너뛰었다는 것. 둘 다 고쳤습니다.

그 한 번의 실행

500 GB 업로드를 시작한다. 멈춘다. 앱을 종료한다. RocketBucket을 열고 파일을 다시 끌어다 놓고 이어가기를 선택한다. 서버의 파트를 채택하고, 나머지를 올리고, 완료하고, 조립을 기다리고, 오브젝트를 확인한다. 536,870,912,000바이트, 9,847개 파트, 커밋 토큰 있음, 남은 멀티파트 업로드 없음. 전체 실행의 최대 메모리는 약 900 MB에서 1 GB.


이 모든 것의 설계는 다섯 번 리뷰됐고 옳았습니다. 망가진 것은 그 어디에도 없는 것들이었습니다. 할당자, 자라나는 저널, 잘못된 루프에서 확인되던 중지 버튼, 그리고 구천 조각을 붙이는 데 30초가 필요한 서버. 이런 것들은 리뷰로 찾지 못합니다. 500 GB를 올리고, 멈추고, 다시 시작해 봐야 찾습니다.

현재 상태. 같은 세션과 세션을 넘어서는 이어올리기는 S3 호환 스토리지로의 로컬 파일 업로드에 대해 현재 빌드에 들어 있습니다. 클라우드 간 이어올리기(예: S3 → R2)는 아직 소스를 다시 읽습니다. 클라우드 소스용 바이트 범위 읽기가 아직 없기 때문이며, Range를 붙인 GET이 다음 작업입니다.

RocketBucket은 S3, R2, B2, MinIO를 위한 듀얼 페인 파일 전송 앱이며 Mac App Store에 출시되었습니다. rocketbucket.app