個人開発でリリースしたiOSアプリ「情シスの教科書」(ITパスポート学習アプリ、約300問収録)を、Core Data + CloudKitで設計した時にハマった3つのポイントを共有します。
- ハマり①: CloudKit同期を後付けしようとして詰んだ。最初から制約込みで設計するのが正解
- ハマり②: シラバス6.3 → 6.5 のマスタ更新で、ユーザー進捗データの整合性確保に半日溶かした
- ハマり③: SQL実践モードを Core Data でやろうとして失敗。用途別に永続化レイヤを分ける判断に至った
学習アプリ・コンテンツアプリを Core Data + CloudKit で作ろうとしている個人開発者の参考になればと思います。
アプリ概要と前提
開発したアプリのホーム画面はこんな構造です。
- IT基礎: 全20トピック(ITパスポート試験対策レベル)
- 分野別学習: IT基盤・ネットワーク・セキュリティ・クラウド・AI活用の5カテゴリ
- 各トピックに解説と確認テストがあり、進捗を「解説 6/6 / 習得 1/6」のように表示
- iPhone と iPad で同じユーザーの進捗を同期したい
検討したスタック
| 候補 | 採用しなかった理由 |
|---|---|
| SwiftData | iOS 17+限定で対象端末が狭まる、CloudKit統合の挙動がまだ枯れていない |
| Realm + Atlas | ランニングコストが個人開発に重い |
| Firebase Firestore | サーバーレスだが従量課金、Appleエコシステム外の依存が増える |
| Core Data + CloudKit | 無料、Apple純正、ユーザーデータは各自のiCloudに閉じる |
個人開発でランニングコスト0かつプライバシー的にも閉じた設計にしたかったので、Core Data + CloudKit を選択しました。
ハマり① CloudKit同期を「後付け」する罠
最初に踏んだ地雷がこれです。「とりあえずローカルで Core Data 動かして、後で CloudKit 化しよう」と考えていました。
結論から言うと、これは破滅への道です。
NSPersistentCloudKitContainer を使ってCloudKit連携する場合、以下の制約がスキーマ全体に課されます。
- すべての属性は optional または default valueあり
- すべてのリレーションシップは optional
- すべてのリレーションシップに inverse が必須
- unique constraint は使えない
- to-one リレーションシップでも optional
ローカル前提で required 属性や unique 制約を多用していた最初のスキーマは、ほぼ全エンティティで作り直しになりました。
学んだこと
最初からCloudKit制約込みで設計する。
「ローカルだけ」「あとで同期化」は嘘。
特に id 系の属性。Core Data 単体なら unique 制約で重複防止できますが、CloudKit 連携ではアプリ側で重複チェックロジックを書く必要があります。
// CloudKit互換のID重複チェック
func upsertTopic(id: String, title: String) throws {
let request = Topic.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1
let existing = try context.fetch(request).first
let topic = existing ?? Topic(context: context)
topic.id = id
topic.title = title
try context.save()
}
「DBの制約に頼れない、アプリ側で守る」というのが CloudKit 連携の基本姿勢です。
ハマり② シラバス6.3 → 6.5 のマスタ更新で半日
これが本記事で一番伝えたい話です。
何が起きたか
開発途中で IPA が ITパスポート試験のシラバスを 6.3 から 6.5 に更新 しました。生成AI関連トピックの追加、既存トピックの再編、いくつかの統廃合があり、約300問の問題と解説マスタを更新する必要が出ました。
問題は、すでにベータ版を配っていたユーザーの学習進捗データをどう扱うかです。
私のスキーマはこうなっていました(簡略版)。
Topic (マスタ - シラバスのトピック)
├─ id: String
├─ title: String
├─ syllabusVersion: String
└─ questions: [Question]
Question (マスタ - 問題)
├─ id: String
├─ topicId: String
└─ ...
UserMastery (ユーザーデータ - 習得記録)
├─ topicId: String
├─ masteredAt: Date
└─ ...
シラバス更新で起きた事象:
-
削除されたトピック → 旧
topicIdを参照するUserMasteryが参照先を失う - 分割されたトピック (1つだったものが2つに) → どちらに進捗を引き継ぐべきか曖昧
- 統合されたトピック (2つだったものが1つに) → 進捗の扱いどうする
- id がそのまま残ったが内容が大幅変更されたトピック → 進捗を「習得済み」のままで良いのか問題
「進捗リセット」という選択肢を捨てた理由
最初は「シラバス更新でリセットしますごめんなさい」も選択肢に入れていました。が、有料サブスクユーザーが進捗を失うのは体験として最悪です。ユーザーデータ保護を最優先に再設計しました。
解決策: マイグレーションマップ
シラバスバージョン間のトピックID対応表をJSONで持たせ、起動時に解決する方針にしました。
// MigrationMap_6.3_to_6.5.json
{
"from": "6.3",
"to": "6.5",
"mappings": [
{ "old": "topic_security_basic", "new": ["topic_security_basic", "topic_ai_security"], "kind": "split" },
{ "old": "topic_legacy_storage", "new": [], "kind": "deprecated" },
{ "old": "topic_cloud_intro", "new": ["topic_cloud_basic"], "kind": "rename" }
]
}
起動時のマイグレーション処理:
struct SyllabusMigrator {
let context: NSManagedObjectContext
let map: MigrationMap
func migrate() throws {
let masteries = try context.fetch(UserMastery.fetchRequest())
for mastery in masteries {
guard let oldTopicId = mastery.topicId,
let mapping = map.mapping(for: oldTopicId) else { continue }
switch mapping.kind {
case .split:
// 分割: 元の習得記録を新トピック全てにコピー
for newId in mapping.newIds {
if !exists(topicId: newId) {
let copy = UserMastery(context: context)
copy.topicId = newId
copy.masteredAt = mastery.masteredAt
copy.migratedFrom = oldTopicId
}
}
context.delete(mastery)
case .rename:
mastery.topicId = mapping.newIds.first
case .deprecated:
// 削除されたトピック: 物理削除せず archived フラグで残す
mastery.isArchived = true
}
}
try context.save()
}
}
CloudKit 同期との戦い
これだけだとまだ問題が残ります。マイグレーションが端末ごとに走ると、同じ UserMastery レコードに対して複数端末が同時に更新をかけて競合するのです。
最終的にこうしました。
- マイグレーション完了フラグを
UserDefaultsではなく CloudKit Key-Value Store (NSUbiquitousKeyValueStore) に置く - 1台目がマイグレーション完了したら、2台目はマイグレーションをスキップしてCloudKit同期を待つ
- 競合解決ポリシーは
NSMergeByPropertyObjectTrumpMergePolicy(最新書き込み優先)
let migrationKey = "syllabus_migration_6.3_to_6.5_completed"
let store = NSUbiquitousKeyValueStore.default
if !store.bool(forKey: migrationKey) {
try migrator.migrate()
store.set(true, forKey: migrationKey)
store.synchronize()
}
学んだこと
マスタデータ更新が前提のアプリでは、
最初から「シラバスバージョン」概念を設計に組み込む。
問題マスタを差し替えるだけのアプリだと思って設計を始めると、ユーザーデータの整合性で必ず詰みます。バージョン情報、マイグレーションマップ、archive フラグはMVPの段階で入れておくべきでした。
ハマり③ SQL実践モードはあえて Core Data 外
このアプリには「フリーSQL実行」という、学習者が任意の SELECT 文を書いて実行結果を確認できるサンドボックス機能があります。
SELECT * FROM servers; のような文を書くと、サンプルデータが表示される。実行履歴も保存される、というシンプルな機能です。
最初の試み: Core Data でやろうとした
「永続化は Core Data でやってるんだから、サンプルテーブルも Core Data で」と素直に考えました。が、すぐに無理だと気づきます。
- Core Data の
NSFetchRequestは SQL ではない - 学習者が書いた任意の SQL 文字列を Core Data で評価する手段がない
- そもそも「SQL を学ぶ機能」なのに本物の SQL エンジンが裏にいないのは本末転倒
採用した設計: 用途別に永続化レイヤを分離
| データ種別 | 永続化レイヤ | 理由 |
|---|---|---|
| トピック・問題マスタ | アプリにバンドル(JSON) → 起動時に Core Data | コンテンツ更新時はアプリアップデート |
| ユーザー学習進捗 | Core Data + CloudKit | 端末間同期が必要 |
| SQL実践用サンプルテーブル | in-memory SQLite (GRDB) | 本物のSQLエンジンが必要・揮発で良い |
| SQL実行履歴 | Core Data (CloudKit同期はオフ) | 端末ローカルで十分 |
// SQL実践用は完全に独立したin-memory DB
final class SQLPracticeRepository {
private let dbQueue: DatabaseQueue
init() throws {
// メモリDB - アプリ終了で消える
self.dbQueue = try DatabaseQueue()
try seedSampleData()
}
private func seedSampleData() throws {
try dbQueue.write { db in
try db.execute(sql: """
CREATE TABLE servers (
server_id INTEGER PRIMARY KEY,
hostname TEXT NOT NULL,
ip_address TEXT NOT NULL,
os TEXT NOT NULL
);
INSERT INTO servers VALUES
(1, 'web-prod-01', '10.0.1.10', 'RHEL 9'),
(2, 'web-prod-02', '10.0.1.11', 'RHEL 9'),
(3, 'db-prod-01', '10.0.2.10', 'Oracle Linux 8'),
-- ...
""")
}
}
func executeQuery(_ sql: String) throws -> QueryResult {
try dbQueue.read { db in
// SELECT 以外は弾く
guard sql.trimmingCharacters(in: .whitespaces)
.uppercased().hasPrefix("SELECT") else {
throw SQLError.onlySelectAllowed
}
return try Row.fetchAll(db, sql: sql)
}
}
}
学習者が DROP TABLE を書いても永続データには一切影響しない、起動するたびに同じサンプルデータがロードされる、という独立した世界を作れました。
学んだこと
「永続化レイヤは1つに統一すべき」は思い込み。
用途が違うなら分けたほうが設計はシンプルになる。
UIへのこだわりとデータ設計のフィードバック
最後に、UI設計がデータ設計に影響した話を1つ。
ホーム画面の「解説 6/6 / 習得 1/6」のような進捗表示。
これを毎回 Core Data の集計クエリで計算すると、トピック数が増えた時にメインスレッドが詰まります。最初は Topic エンティティに masteredCount を denormalize しよう と考えました。
ただし CloudKit 同期があると話が変わります。denormalized フィールドは複数端末からの同時更新で簡単に壊れるのです。
最終的にこうしました。
- 集計値はエンティティに持たせない
-
NSFetchedResultsControllerでローカルキャッシュして都度計算 - CloudKit で同期するのは生の
UserMasteryレコードのみ - 表示用集計は
@Publishedな ViewModel プロパティで派生
CloudKit 同期環境では「正規化された生データだけを同期し、集計は各端末で独立に計算する」が鉄則。
これに気づくのに2日かかりました。
まとめ
Core Data + CloudKit は 無料で同期付きの永続化 が手に入る素晴らしい組み合わせですが、設計の自由度は大きく制限されます。
- CloudKit制約は最初から織り込む(後付けは破滅)
- マスタ更新を前提にした設計(バージョン、マイグレーションマップ、archiveフラグ)
- 用途別に永続化レイヤを分離(統一原理主義は捨てる)
- denormalize は同期前提では危険(生データ同期 + ローカル集計)
「個人開発で資格学習アプリを Core Data + CloudKit で作ろう」と考えている方の参考になれば嬉しいです。
次回は本アプリのStoreKit 2サブスクリプション実装で踏んだ地雷について書く予定です(StoreKit 2の基本的なハマりポイントは 別記事 で先に書きました)。
何か質問・ツッコミがあれば、コメントでお気軽にどうぞ。
参考リンク
- Apple Developer - Core Data and CloudKit
- Apple Developer - NSPersistentCloudKitContainer
- GRDB.swift (今回SQL実践モードで使用)
- 「情シスの教科書」 App Store / LP