0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ITパスポート300問アプリをCore Data + CloudKitで設計した話 - スキーマ設計の試行錯誤

0
Last updated at Posted at 2026-05-14

個人開発でリリースした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
  └─ ...

シラバス更新で起きた事象:

  1. 削除されたトピック → 旧 topicId を参照する UserMastery が参照先を失う
  2. 分割されたトピック (1つだったものが2つに) → どちらに進捗を引き継ぐべきか曖昧
  3. 統合されたトピック (2つだったものが1つに) → 進捗の扱いどうする
  4. 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の基本的なハマりポイントは 別記事 で先に書きました)。

何か質問・ツッコミがあれば、コメントでお気軽にどうぞ。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?