ゼロログイン設計:SwiftData + CloudKitで作る非公開日記アプリ#
個人日記アプリほど、サインアップ画面が似合わないカテゴリはないだろう。ユーザーがその月で最も心を開いた日について書こうとアプリを開いた瞬間、最初に目にするのが「アカウントを作成」「パスワードを設定」「メールを確認」だとしたら――これは仮説ではない。まさにこのステップで、Day Oneの競合として開発中の日記アプリ Lanternly のユーザーの一部を失うことになる。
私が辿り着いた答えは ゼロログイン だ。アプリには「アカウント」という概念そのものが存在しない。プロフィールはローカルに保存され、iPhoneとiPad間の同期は、端末にすでにサインインしているiCloudアカウントのCloudKitプライベートデータベースを通じて行われる。ログイン画面もパスワードも、それを検証するサーバーも存在しない。本稿ではそのアーキテクチャ、SwiftDataがCloudKitと組み合わさる際に課される制約、そしてゼロログインがエクスポートやインポートといったプロダクト判断にどう波及するかを扱う。
なぜ「Sign in with Appleで十分」ではないのか#
同僚からまず聞かれたのは「Sign in with Appleならボタン一つで済むのに、なぜ複雑にするのか」という質問だった。この違いは本質的なものだ。どれほど軽量な認証であっても、それは アカウント という実体を前提とする――どこかに登録し、データと紐付け、iCloud IDの変更時に処理し、サポート対応(「ログインできない」「機種変更したらデータが消えた」)を続けなければならない存在だ。
ゼロログインはこの問題のカテゴリ自体を消し去る。アカウントがなければログインもなく、パスワードを忘れることもなく、端末変更時の移行も発生しない。なぜなら「端末」と「ユーザーのアイデンティティ」がそもそも分離されていないからだ。CloudKitは、その端末でiCloudにサインインしている人物がデータの所有者だとすでに知っている。AppleがOSレベルで解決済みの問題を、私が再び解決する必要はない。
この選択は無償ではない。Lanternlyには「別のiCloudアカウントでログインして自分のデータを見る」という経路が存在せず、明示的な移行手段しかない。データが本質的にプライバシーに敏感な日記アプリにとって、これは欠陥ではなく合理的なトレードオフだと考えている。
アーキテクチャ:SwiftDataをローカルに、CloudKitを同期のトランスポートに#
WWDC23で発表されたCore Dataの後継 SwiftData は、標準でローカルストレージを提供する。そして cloudKitDatabase パラメータを持つ ModelConfiguration を使えば、サーバーコードを一行も書かずにオフラインファースト同期へと変わる。
import SwiftData
enum JournalStore {
static let cloudContainerID = "iCloud.com.yourteam.yourapp"
static let schema = Schema([
JournalEntry.self,
JournalAttachment.self,
Profile.self,
])
/// Test/CI builds pass `-localStoreOnly` to skip CloudKit entirely —
/// there is no entitlement to sign against in CI, and simulator
/// screenshots would otherwise crash trying to reach a private database.
private static var localStoreOnly: Bool {
#if DEBUG
CommandLine.arguments.contains("-localStoreOnly")
#else
false
#endif
}
@MainActor
static func makeContainer() -> ModelContainer {
guard !localStoreOnly else {
return makeLocalContainer()
}
let cloudConfig = ModelConfiguration(
schema: schema,
cloudKitDatabase: .private(cloudContainerID)
)
// CloudKit can be unavailable for reasons that have nothing to do
// with the schema: no iCloud sign-in, restricted account, missing
// entitlement in a dev build. Falling back to a local-only store
// keeps the app usable instead of crashing on first launch.
if let container = try? ModelContainer(for: schema, configurations: cloudConfig) {
return container
}
return makeLocalContainer()
}
@MainActor
private static func makeLocalContainer() -> ModelContainer {
let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none)
guard let container = try? ModelContainer(for: schema, configurations: localConfig) else {
fatalError("Could not create local ModelContainer: schema is invalid")
}
return container
}
}-localStoreOnly に注目してほしい。これは理論上のフラグではない。CI環境やシミュレーターのスクリーンショット撮影ではCloudKitが使えない――署名済みのエンタイトルメントがなく、有効なiCloudアカウントすらないことも多い。明示的なローカルフォールバックがなければ、アプリは本番ではなくテスト実行環境の最初の起動でクラッシュしてしまう。これは実は本番よりも厄介で、本当の問題が「テストが不安定」という誤解に隠れてしまうからだ。
CloudKit対応が@Modelに強制する変更点#
ここからがゼロログインアーキテクチャのやや厄介な部分だ。CloudKitはスキーマに厳格な要件を課しており、SwiftDataはそれを回避できない――非互換な機能を黙って無効化するか、コンテナ起動時に失敗するかのどちらかだ。同期コードを一行も書く前に直面した具体的な制約は次の3つだった。
- 一意性制約が使えない。
@Attribute(.unique)は純粋にローカルなSwiftDataストアでは機能するが、CloudKitを接続した設定では使えなくなる。CloudKitは、いつでもオフラインになりうる複数の端末間でアトミックな一意性を保証できないためだ。 - 非オプショナルな属性にはすべてデフォルト値が必要。 デフォルト値のない必須フィールドを持つスキーマは、穏やかに機能を落とすのではなく、コンテナ起動時に失敗する。
- すべてのリレーションシップはオプショナルでなければならない。 ローカルのみのアプリなら空配列をデフォルトにした非オプショナルにするようなto-manyリレーションでも例外ではない。
import SwiftData
@Model
final class JournalEntry {
var id: UUID = UUID()
var createdAt: Date = Date.now
var text: String = ""
var mood: Int?
var tags: [String] = []
// CloudKit requires every relationship to be optional — even
// to-many ones you would normally default to an empty array.
@Relationship(deleteRule: .cascade, inverse: \JournalAttachment.entry)
var attachments: [JournalAttachment]? = []
// No @Attribute(.unique) here: CloudKit cannot enforce atomic
// uniqueness across devices, so SwiftData disables it the moment
// a CloudKit-backed configuration is used. Uniqueness, if you need
// it, becomes an application-level check instead of a schema one.
init(id: UUID = UUID(), text: String = "", mood: Int? = nil) {
self.id = id
self.text = text
self.mood = mood
}
}実務上の結論はこうだ。以前はスキーマレベルで保証していたビジネス上の不変条件(一意なid、必須フィールドなど)は、アプリケーションレベルの検証へと移動する。これはCloudKitの不具合ではなく、同期が分散システムであることの帰結だ。オフラインになりうる端末間でネットワーク越しにアトミックな一意性チェックを行うことは、中央の調停者なしには物理的に不可能であり、その中央調停者こそがゼロログインアーキテクチャが避けようとしているサーバーそのものだからだ。
CloudKitのプライベートデータベース:「自分専用のiCloud」が本当に意味すること#
「データはユーザー自身のiCloudにあるのだから、最初からE2Eだ」という説明を単純化しすぎるのは避けたい。これは正確ではなく、マーケティング的なスローガンにするより正直に述べるべきだと考えている。
CloudKitのプライベートデータベース(.private(cloudContainerID))は、データを物理的にそのユーザー個人のiCloudストレージに配置する――私が管理するサーバーを経由することは一切ない。すべてのレコードは、アップロードされる前にユーザーの信頼できる端末上で生成された鍵で暗号化される。しかし、Apple自身でさえ鍵にアクセスできない完全なエンドツーエンド暗号化が有効になるのは、ユーザーがiCloud設定で Advanced Data Protection を自らオンにした場合のみだ。これはオプトインの設定であり、デフォルトの挙動ではない。オンにしていなければ、Appleは技術的には合法的な要請に応じてデータを提供できる。オンにしていれば、Appleは物理的に鍵を保持していないため提供できない。
Lanternlyにとってこれは、留保なしに「軍事レベルの暗号化」を謳うのではなく、プライバシーに関する説明で正直であることを意味する。CloudKitのプライベートデータベースは、私が完全に管理するサーバーよりも本質的に優れているが、デフォルトでゼロ知識証明ではない――そうではないと主張する開発者は、ユーザーを誤解させていることになる。
無制限エクスポートとDay Oneからのインポート:ゼロログインがプロダクト判断を形作る#
ゼロログインは単なる同期アーキテクチャの選択ではなく、プロダクト全体に波及する制約だ。サーバー側にアカウントが存在しないなら、バックエンドAPIを介した「クラウド」エクスポートも存在しえない――エクスポートは、データが物理的にすでに存在する端末上のローカル操作でなければならない。
Lanternlyでは、これが具体的なプロダクト判断につながった。日記を書籍として印刷する機能(PDFとEPUB)は完全にオンデバイスで生成され、テキストがレンダリング用の外部サービスにアップロードされることはない。そして、プロダクトの観点で同じくらい重要なのが、無制限エクスポートだ――「無料版は10ページまで」といったプレミアム制限も、自分のデータをオープンな形式で取り戻すためのサブスクリプション必須要件もない。データはサブスクリプションのものではなく、ユーザーのものだ。
Day Oneからのインポートも同じ論理に従う。アプリは競合の日記アプリのローカルエクスポートを読み込み、そのエントリーを自身のオンデバイスSwiftDataモデルへ移す――ここでも、移行の間だけ他人の個人的な記録を一時的に預かる仲介サーバーは存在しない。
自前バックエンド vs Firebase vs CloudKit + SwiftData#
| 基準 | 自前バックエンド | Firebase | CloudKit + SwiftData |
|---|---|---|---|
| ユーザー登録 | 自前実装(メール/OAuth)——必須 | Firebase Auth——必須 | 不要:端末上のiCloudサインインが本人確認になる |
| データの物理的な保存先 | 自分のサーバー/DB | Googleのサーバー | ユーザー自身のプライベートiCloudデータベース |
| デフォルトのE2E暗号化 | 実装次第 | なし(Googleのサーバー上で読み取り可能) | デフォルトではなし。ユーザーのAdvanced Data Protectionで有効化 |
| インフラとDevOps | 開発者が全責任 | マネージドだが規模とともにコスト増 | 不要——サーバーではなくアプリのコンテナ |
| クロスプラットフォーム対応 | 任意 | iOS/Android/Web | Appleエコシステムのみ |
| 標準でオフラインファースト | 手動で実装が必要 | Firestoreの部分的なオフラインキャッシュ | SwiftData + ModelConfiguration に組み込み済み |
| スキーマ上の制約 | なし(自前DB) | 柔軟なNoSQLスキーマ | 一意性制約なし、オプショナル/デフォルト値が必須 |
まとめとリリース前チェックリスト#
SwiftDataとCloudKitによるゼロログインは万能のレシピではなく、意図的なトレードオフだ。Apple以外へのクロスプラットフォーム展開とスキーマの柔軟性の一部を手放す代わりに、サーバーもログイン画面も持たず、データが物理的にユーザー自身のものになる。個人日記のようにAppleエコシステム内で完結するアプリにとって、このトレードオフはユーザーにとって制約ではなく「この記録を見られるのはあなただけ」という約束として受け取られる。
このアーキテクチャを本番にリリースする前に、短いチェックリストを確認しておく価値がある。
- SwiftDataのスキーマがCloudKitと互換である:
@Attribute(.unique)を使わない、非オプショナルなフィールドはすべてデフォルト値を持つ、すべてのリレーションシップがオプショナル - CloudKitが使えない場合(iCloud未サインイン、制限付きアカウント、エンタイトルメントのない開発ビルド)の明示的なローカルフォールバックがある
- テスト/CIビルドは実際のCloudKitコンテナに接続しようとせず、
-localStoreOnlyのようなフラグを使う - プライバシーの説明で、完全なE2Eがいつ有効になるか(Advanced Data Protection)を正直に示しており、デフォルトであるかのように書いていない
- データのエクスポートは仲介サーバーを必要としない、端末上のローカル操作である
- プロダクト上の制限(ペイウォール、上限)がユーザー自身のデータへのアクセスを妨げない
CloudKitにおける競合解決や変更通知サブスクリプションについては、「CloudKitでサーバーなし:本番環境のオフラインファースト同期」で、MeteoHealthの健康データに同じアーキテクチャを適用した例を詳しく解説している。



