블로그

2026년 8월 15일 · 8분 읽기

CloudKit 첨부 저장소 이관: 서버가 바이트 대신 manifest만 갖기

서버에 쌓던 첨부 바이트를 각 계정의 iCloud로 옮기고 서버는 manifest만 갖도록 전환했습니다. 결정 두 개를 철회하고 검증에서 결함 두 개를 고쳤습니다.

  • CloudKit
  • iCloud
  • 데이터 동기화
  • iOS

2026년 8월 23일 업데이트

첨부 파일을 우리 서버에 저장하고 있었습니다. 사용자가 늘면 비용이 늘어납니다.

2026-08-10에 재 봤습니다.

무엇 바이트 전환 후
첨부 암호문 93,345,511 사용자 iCloud로
기록 암호문 1,362,433 우리 서버에 남음
전환 전 잔여 객체 212.3MB 정리 대상

과금되는 바이트의 98.6%가 첨부입니다. 기록은 옮기지 않습니다. 서버가 동기화해야 하고 용량도 작습니다.

옮기기로 했습니다. 서버는 바이트를 갖지 않고 어디에 있는지만 갖습니다. 바이트는 사용자의 iCloud에 있습니다.

한눈에 보기

문제 첨부 파일을 우리 서버에 저장하고 있었습니다.
결정 이 작업 중에 복구 키 백업 UI를 제거했습니다.
결과 배포 후 확인했습니다.
제약 서버 레코드는 지워졌고 iCloud의 바이트는 남았습니다.

서버 스키마에서 파일 필드를 지웠습니다

마이그레이션 하나가 구조 전체를 바꿉니다.

// platform/backend/migrations/0063_attachments_become_manifests.go
// attachments.file (FileField, B2)  → 제거
// attachments.cloud_record_name (Text 100) → 추가
// users.cloudkit_user_hash (Text 64) → 추가

cloud_record_name은 CloudKit 레코드를 가리키는 포인터입니다. 서버는 이 값을 해석하지 않습니다. 앱이 쓰고 앱이 읽습니다.

cloudkit_user_hash는 첫 파일을 올린 기기의 iCloud 사용자 식별자를 해시한 값입니다. 이것이 왜 필요한지는 뒤에서 설명합니다.

이 결정의 근거가 마이그레이션 주석에 스물다섯 줄로 적혀 있습니다. 그중 파일 필드를 남기지 않은 이유가 두 줄입니다.

// migrations/0063_attachments_become_manifests.go
// `file` goes with them. A FileField nothing writes would keep the S3 wiring
// load-bearing and leave the 212.3 MB of pre-cutover objects looking live.

아무도 쓰지 않는 필드를 남기면 두 가지가 따라옵니다. S3 배선이 계속 필요한 것으로 남고, 전환 전에 쌓인 212.3MB가 살아 있는 데이터처럼 보입니다. 쓰지 않는 필드는 지웠습니다.

required로 두지 않았습니다

cloud_record_name을 필수 필드로 만들 수 있었습니다. 안 했습니다.

앞선 마이그레이션에서 배운 것이 있었습니다. 저장 용량 컬럼을 required로 넣었더니, PocketBase가 그 필드의 영값을 거부했습니다. 0바이트가 정당한 값인 경우를 막았습니다.

이 값도 마찬가지입니다. 핸들러가 어차피 검증합니다. 스키마 레벨에서 한 번 더 막으면 정당한 경우가 걸립니다.

엔드포인트가 바이트를 안 받게 됐습니다

업로드가 multipart에서 JSON으로 바뀌었습니다.

이전: upload endpoint  (multipart, 파일 바이트)
이후: upload endpoint  (JSON manifest)

다운로드 엔드포인트는 삭제했습니다.

single download endpoint  → 라우트와 핸들러 모두 제거

바이트는 이제 iCloud에서만 나옵니다. 서버에 없으니 서버가 줄 수도 없습니다.

배포 후 확인했습니다.

single download endpoint → 404

이전에는 401이 나왔습니다. 인증이 필요하다는 뜻입니다. 이제 404입니다. 라우트가 없어졌다는 뜻입니다.

멱등성은 그대로 유지했습니다. 파일 내용 해시로 중복을 판정합니다. 같은 파일을 두 번 올려도 레코드는 하나입니다.

계정 하나에 iCloud 하나를 묶었습니다

사용자가 "아이디 하나에 iCloud 하나"를 제안했습니다. 조사하다가 더 강한 근거를 찾았습니다.

결합이 없으면 첨부가 흩어집니다.

기기가 Apple ID X로 파일 다섯 개를 올립니다. 그 기기가 Y로 갈아탑니다. 다음 다섯 개는 Y의 iCloud로 갑니다.

서버의 manifest에는 열 개가 있습니다. 어느 기기도 절반 넘게 읽지 못합니다.

더 나쁜 것은 데이터 어디에도 이유가 없다는 것입니다. 사용자는 파일이 안 열리는 것만 봅니다. 앱도 왜 안 열리는지 모릅니다.

첫 파일을 올릴 때 그 기기의 iCloud 사용자 식별자를 해시해 계정에 저장합니다. 이후 다른 iCloud 계정이 오면 저장된 해시와 달라집니다.

그 해시에 유니크 인덱스를 나중에 붙였습니다

마이그레이션 두 개 뒤에 같은 컬럼을 다시 건드렸습니다. 첨부를 묶으려고 만든 값이 신원 판정에도 쓰이게 됐기 때문입니다.

가입하지 않은 iCloud 게스트 사용자는 이 해시로 계정을 찾거나 만듭니다. 유니크 인덱스가 없으면 그 find-or-create가 경쟁 조건입니다. 같은 사람의 두 기기가 같이 켜지면 각자 계정을 만들고, 그 사람은 자기 기록이 절반씩 든 계정 두 개를 갖게 됩니다.

그런데 그냥 유니크로 만들 수 없었습니다.

// migrations/0065_users_cloudkit_hash_unique.go
users.AddIndex(
  "idx_users_cloudkit_user_hash", true, "`cloudkit_user_hash`",
  "`cloudkit_user_hash` != ''")        // 부분 인덱스

이메일로 가입한 계정은 첫 첨부를 올리기 전까지 이 값이 빈 문자열입니다. SQLite는 빈 문자열도 값으로 셉니다. 조건 없는 유니크 인덱스를 걸면 그런 계정이 딱 하나만 존재할 수 있습니다.

WHERE cloudkit_user_hash != ''가 그것을 피합니다. iCloud를 실제로 주장하는 계정에만 유일성을 요구합니다.

같은 컬럼이 두 가지 일을 하게 됐고, 두 번째 일이 첫 번째에는 없던 제약을 요구했습니다. 첨부를 묶는 데는 중복이 문제가 아니고, 신원을 정하는 데는 문제입니다.

같은 컬럼을 건드린 순서를 적어 두면 이렇습니다.

마이그레이션 무엇을 했나
0062 tiers.quota_bytes를 required로 넣음 영값이 거부되는 것을 배움
0063 cloudkit_user_hash 추가, required 아님 첨부와 계정을 묶음
0065 부분 유니크 인덱스 추가 게스트 신원 판정의 경쟁 조건

첫 줄이 두 번째 줄의 이유입니다. 앞선 마이그레이션에서 required의 대가를 치렀으니 이번에는 넣지 않았습니다. 세 번째 줄이 두 번째의 부족한 점을 메웁니다. 한 컬럼의 계약이 마이그레이션 세 개에 걸쳐 정해졌습니다.

사용자 제안 중 하나는 반대했습니다

사용자는 불일치가 감지되면 다른 계정으로 가입을 유도하자고 했습니다. 반대했습니다.

개인 아이폰(Apple ID X)과 회사 아이패드(Apple ID Y)를 쓰는 사람을 생각해 봤습니다.

계정을 새로 만들라고 하면 첨부만 쪼개지는 것이 아닙니다. 기록 전체가 쪼개집니다.

기록 동기화는 우리 서버가 합니다. iCloud와 무관합니다. 두 기기에서 멀쩡히 동작하고 있습니다. 그걸 포기시키는 것이 손해가 더 큽니다.

채택한 안은 범위를 좁힌 것입니다.

불일치 기기에서
첨부 로컬 전용 (동기화 안 함)
기록·요약 계속 동기화
앱 표시 사유를 명시

첨부만 막습니다. 나머지는 그대로 씁니다.

제 서술을 하나 철회했습니다

조사 중에 제가 쓴 문장이 있었습니다.

이메일로 가입한 사용자는 Apple과 무관하다

틀렸습니다.

CloudKit 컨테이너는 기기에 로그인된 iCloud 계정을 씁니다. 앱에 어떻게 로그인했는지와 무관합니다.

이메일로 가입한 사용자도 기기에 iCloud가 로그인돼 있으면 그 iCloud를 씁니다. 결합은 모든 사용자에게 적용됩니다.

업로드 순서를 정했습니다

파일 하나를 올리는 데 두 단계입니다. iCloud에 바이트를 넣고, 서버에 manifest를 넣습니다.

순서는 하나만 맞습니다.

올바름:  blob put → manifest PUT
잘못됨:  manifest PUT → blob put

반대로 하면 창이 열립니다. manifest는 있는데 blob이 없는 상태입니다.

그 창에서 다른 기기가 동기화하면 "파일이 있다"고 보고 받으러 갑니다. 없습니다.

바이트를 먼저 넣으면 반대 상태가 됩니다. blob은 있는데 manifest가 없습니다. 이건 안전합니다. 아무도 그 파일을 모르므로 아무 일도 일어나지 않습니다.

레코드 이름을 로컬 UUID로 고정했습니다

CloudKit 레코드에 이름을 붙여야 합니다. 첨부의 로컬 UUID를 씁니다.

매번 새로 만들면 문제가 생깁니다.

업로드 응답이 유실됐다고 가정합니다. 앱은 실패로 보고 재시도합니다. 레코드 이름을 새로 만들면 iCloud에 같은 파일이 두 개가 됩니다.

첫 번째는 아무도 모릅니다. 사용자의 iCloud에 고아 blob이 쌓입니다.

로컬 UUID를 쓰면 재시도가 같은 자리에 씁니다. 덮어씁니다.

검증 중에 결함 두 개가 나왔습니다

배포하고 실제로 돌려보니 두 가지가 잘못돼 있었습니다.

삭제가 404를 받고 영원히 재시도했습니다

데이터를 초기화한 뒤였습니다. 옛 항목 두 개가 남아 있었고 서버에는 없었습니다.

앱이 삭제를 시도하면 404가 옵니다. 앱은 실패로 보고 재시도합니다. 다음 동기화에서도 404입니다.

삭제 큐가 직렬이었습니다. 앞에서 막히면 뒤가 못 갑니다.

그 뒤의 모든 삭제가 함께 막혔습니다. 사용자가 무엇을 지워도 서버에서 지워지지 않는 상태였습니다.

고침은 한 줄입니다. 404를 성공으로 처리합니다.

지우려는 것이 없다면 목적은 달성됐습니다. 재배포 후 404가 0건이 됐습니다.

삭제할 때 iCloud blob이 남았습니다

서버 레코드는 지워졌고 iCloud의 바이트는 남았습니다.

원인은 삭제 큐가 서버 행 id만 들고 다녔기 때문입니다. blob이 어디 있는지 몰랐습니다.

이걸 놓치면 사용자의 iCloud에 우리 쓰레기를 영구히 두는 것이 됩니다. 사용자는 앱에서 지웠는데 iCloud 용량이 줄지 않습니다.

삭제 큐 항목에 CloudKit 레코드 이름을 추가하고, 저장소에서 앱까지 콜백을 배선했습니다.

결정을 하나 더 철회했습니다

이 작업 중에 복구 키 백업 UI를 제거했습니다. 제가 처음에 반대했던 일입니다.

암호화 키는 사용자만 갖습니다. 잃으면 복구할 수 없습니다. 그래서 백업 UI를 뒀습니다.

사용자가 제거를 제안했을 때 제가 반대한 근거는 이것이었습니다.

iCloud가 꺼진 사용자가 서버에 기록을 쌓고 키만 못 찾는 상황이 생긴다

사용자의 데이터 모델을 확인하고 나서 이 근거가 성립하지 않는 것을 알았습니다.

상태 서버 동기화 키 필요
게스트, iCloud 없음 없음 (완전 로컬) 불필요
게스트, iCloud 있음 임시 저장 100건 필요
로그인 정상 필요

iCloud가 이 모델에서 서버 동기화의 전제입니다.

iCloud가 없으면 서버에 기록이 안 생깁니다. 복구할 것이 없으면 키도 필요 없습니다.

제 근거가 그리지 못한 상태였습니다. 철회하고 제거했습니다.

자동 경로는 그대로 삽니다. 키를 발급할 때 iCloud 키체인에 조용히 저장하고, 재설치나 새 기기에서 조용히 복원합니다. 사용자가 볼 UI만 사라졌습니다.

문서와 코드가 다른 것을 발견했습니다

같은 파일 안에서 두 주석이 반대되는 말을 하고 있었습니다.

// InstallIdentity.swift:5
// reinstalling the app is the boundary that changes identity

// InstallIdentity.swift:66
// Keychain은 앱 삭제와 무관하게 남는다

5행은 앱을 지우면 신원이 바뀐다고 합니다. 66행은 안 바뀐다고 합니다.

66행이 맞고 5행이 틀렸습니다. 키체인 항목은 앱 삭제로 사라지지 않습니다. iOS의 동작입니다.

이것을 그대로 두면 제품 약속이 거짓이 됩니다. "앱을 지우면 임시 기록도 사라진다"고 말했는데 실제로는 남습니다.

고침은 앱 컨테이너에 생존 표식을 두는 것입니다. 표식 없이 키체인 항목만 남아 있으면 재설치로 판정합니다.

주석 두 개가 반대되는 말을 하는데 아무도 몰랐던 이유는, 둘 다 맞다고 가정해도 코드가 동작했기 때문입니다.

옛 데이터를 정리했습니다

전환이 끝나고 서버에 남은 바이트를 지웠습니다.

대상 이전 이후
B2 객체 수 197 1
B2 크기 94,381,308B 삭제
PVC 286.6MB 75.5MB

남은 객체 하나는 공유 링크용 자산입니다. 첨부와 다른 경로입니다.

PVC 정리에 걸림이 있었습니다. rm -rf가 가드에 막혀서 find -type f -deletermdir로 했습니다. 운영 볼륨에서 재귀 삭제를 막아 둔 것이 일하고 있었습니다.

배포도 정상 경로가 아니었습니다. GitHub Actions 워크플로가 수동 비활성 상태라 CI가 돌지 않았습니다. docker buildx로 직접 빌드해 레지스트리에 올리고 롤아웃했습니다.