Swiftでデータ内部保存できる機能を比較してみた
本記事の文章作成には生成AIを補助的に利用しています。内容は確認していますが、記載に誤りがある可能性があります。
iOSアプリで「内部保存」を実装するとき、選択肢が多く、個人的に実際に使ったことがないものが多くあります。
この記事では、同じTODOアプリを題材に以下6方式を比較します。
- UserDefaults
- Keychain
- File(JSON/CSV)
- Core Data
- SwiftData
- Realm
本記事のコードは DataStore プロジェクトで実際に動かしている実装です。
この記事で比較する観点
- どんなデータに向くか
- CRUD(登録・読み込み・更新・削除)をどう書くか
- メリット/注意点
先に結論
- 軽量設定値: UserDefaults
- 機密データ: Keychain
- 可搬性・人が読める形式: File(JSON/CSV)
- 複雑なデータ構造・既存資産: Core Data
- iOS 17+でSwiftらしく書く: SwiftData
- クロスプラットフォーム寄り・軽快なオブジェクトDB: Realm
比較表(要点)
| 方式 | 主用途 | 保存形式 | 向いているケース | 注意点 |
|---|---|---|---|---|
| UserDefaults | 設定・小さな状態 | Key-Value | フラグ、軽量リスト | 大量/機密データには不向き |
| Keychain | 秘密情報 | Key-Value(暗号化保護) | トークン、パスワード | APIが低レベルで少し複雑 |
| File | 自由なファイル保存 | JSON/CSV等 | 互換性・エクスポート | 同時更新や整合性を自前で管理 |
| Core Data | Apple標準ORM | SQLite等(内部管理) | 複雑クエリ/実績重視 | 学習コストあり |
| SwiftData | 新しい永続化API | SwiftData管理 | SwiftUI連携重視 | 対応OS条件を確認 |
| Realm | サードパーティDB | Realm独自形式 | 直感的なオブジェクト操作 | 依存追加が必要 |
比較表(実務向け・相対評価)
| 方式 | セキュリティ | データ量 | 検索・絞り込み | スキーマ変更 | 実装コスト |
|---|---|---|---|---|---|
| UserDefaults | 低 | 小 | 弱い | 手動対応 | 低 |
| Keychain | 高 | 小 | 弱い | 手動対応 | 中 |
| File(JSON/CSV) | 低〜中 | 小〜中 | 弱い(自前実装) | 手動対応 | 低〜中 |
| Core Data | 中 | 中〜大 | 強い | 比較的強い | 高 |
| SwiftData | 中 | 中 | 中〜強 | 比較的強い | 中 |
| Realm | 中 | 中〜大 | 強い | 比較的強い | 中 |
※ この表は「本記事のTODOアプリ実装」を前提にした相対評価です。要件(監査要件、データ量、オフライン戦略など)で最適解は変わります。
画面操作とCRUDの対応
全タブでUIは統一しており、操作は同じです。違うのは保存先だけです。
| 画面操作 | 意味 | 内部で呼ぶ処理 |
|---|---|---|
+ ボタンで追加 |
登録(Create) |
add(...) / save(...)
|
| 行タップで編集 | 更新(Update) | update(...) |
| 左スワイプ削除 | 削除(Delete) | delete(...) |
| 画面表示時に一覧表示 | 読み込み(Read) |
reload() / load()
|
| チェックボタン | 完了状態更新(Update) | toggleDone(...) |
1. UserDefaults
使える機能
- Key-Value保存
-
Bool / Int / String / Data / Array / Dictionaryはそのまま保存可能 - 独自構造体は
CodableをData(例: Property List)にして保存 - 小さいデータの高速な読み書き
CRUDコード(実装例)
final class UserDefaultsTodoService {
private let key = "ud_todos"
private let defaults = UserDefaults.standard
// Read
func load() throws -> [UserDefaultsTodo] {
guard let data = defaults.data(forKey: key) else { return [] }
return try PropertyListDecoder().decode([UserDefaultsTodo].self, from: data)
}
// Create/Update(一括保存)
func save(_ todos: [UserDefaultsTodo]) throws {
let data = try PropertyListEncoder().encode(todos)
defaults.set(data, forKey: key)
}
}
PropertyListEncoder/Decoder が必要なのは、UserDefaultsTodo のような独自型を Data に変換して保存するためです。
Bool や String のような標準型だけなら、エンコード自体が不要です。
単純な値だけなら、次のようにJSON化なしで直接保存できます。
// 直接保存
UserDefaults.standard.set(true, forKey: "hasOnboarded")
// 直接読み込み
let hasOnboarded = UserDefaults.standard.bool(forKey: "hasOnboarded")
2. Keychain
使える機能
- 秘密情報を安全に保存
-
SecItemUpdate/SecItemAdd/SecItemCopyMatchingで管理 - App再起動後も保持
CRUDコード(実装例)
final class KeychainTodoService {
private let account = "kc_todos"
// Create/Update(既存は更新、未作成なら追加)
func save(todos: [UserDefaultsTodo]) throws {
let data = try JSONEncoder().encode(todos)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account
]
let updateAttributes: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
if updateStatus == errSecSuccess { return }
if updateStatus != errSecItemNotFound {
throw NSError(domain: "KeychainError", code: Int(updateStatus), userInfo: nil)
}
var addAttributes = query
addAttributes[kSecValueData as String] = data
let addStatus = SecItemAdd(addAttributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw NSError(domain: "KeychainError", code: Int(addStatus), userInfo: nil)
}
}
// Read
func load() throws -> [UserDefaultsTodo] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound { return [] }
guard status == errSecSuccess, let data = item as? Data else {
throw NSError(domain: "KeychainError", code: Int(status), userInfo: nil)
}
return try JSONDecoder().decode([UserDefaultsTodo].self, from: data)
}
}
3. File(JSON/CSV)
使える機能
- Documents配下へ自由に保存
- JSONで永続化、CSVで外部連携
- ユーザー向けエクスポートが作りやすい
CRUDコード(実装例)
final class FileTodoService {
private var documentsURL: URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
var jsonFileURL: URL { documentsURL.appendingPathComponent("todos.json") }
var csvFileURL: URL { documentsURL.appendingPathComponent("todos.csv") }
// Create/Update
func saveJSON(_ todos: [UserDefaultsTodo]) throws {
let data = try JSONEncoder().encode(todos)
try data.write(to: jsonFileURL, options: .atomic)
}
// Read
func loadJSON() throws -> [UserDefaultsTodo] {
guard FileManager.default.fileExists(atPath: jsonFileURL.path) else { return [] }
let data = try Data(contentsOf: jsonFileURL)
return try JSONDecoder().decode([UserDefaultsTodo].self, from: data)
}
// Export
@discardableResult
func exportCSV(_ todos: [UserDefaultsTodo]) throws -> URL {
let header = "id,title,detail,isDone,createdAt\n"
let rows = todos.map { todo in
[
todo.id.uuidString,
"\"\(todo.title.replacingOccurrences(of: "\"", with: "\"\""))\"",
"\"\(todo.detail.replacingOccurrences(of: "\"", with: "\"\""))\"",
todo.isDone ? "true" : "false",
ISO8601DateFormatter().string(from: todo.createdAt)
].joined(separator: ",")
}
let csv = header + rows.joined(separator: "\n")
guard let data = csv.data(using: .utf8) else {
throw NSError(domain: "FileTodoService", code: -1, userInfo: [NSLocalizedDescriptionKey: "CSVエンコードに失敗しました"])
}
try data.write(to: csvFileURL, options: .atomic)
return csvFileURL
}
}
4. Core Data
使える機能
- エンティティ管理、フェッチ、ソート、更新、削除
- 永続化ストアをApple標準で扱える
- アプリ規模拡大に耐えやすい
CRUDコード(実装例)
@MainActor
final class CoreDataTodoViewModel: ObservableObject {
@Published var todos: [CoreDataTodo] = []
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
reload()
}
// Read
func reload() {
let request = CoreDataTodo.fetchRequestAll()
todos = (try? context.fetch(request)) ?? []
}
// Create
func add(title: String, detail: String) {
let todo = CoreDataTodo(context: context)
todo.id = UUID()
todo.title = title
todo.detail = detail
todo.isDone = false
todo.createdAt = Date()
try? context.save()
reload()
}
// Update
func update(todo: CoreDataTodo, title: String, detail: String) {
todo.title = title
todo.detail = detail
try? context.save()
reload()
}
// Delete
func delete(at offsets: IndexSet) {
for index in offsets {
context.delete(todos[index])
}
try? context.save()
reload()
}
}
5. SwiftData
使える機能
-
@Modelでモデル定義 -
ModelContextで insert/fetch/delete/save - SwiftUIとの統合がしやすい
CRUDコード(実装例)
@Model
final class SwiftDataTodo {
@Attribute(.unique) var id: UUID
var title: String
var detail: String
var isDone: Bool
var createdAt: Date
init(id: UUID = UUID(), title: String, detail: String, isDone: Bool = false, createdAt: Date = Date()) {
self.id = id
self.title = title
self.detail = detail
self.isDone = isDone
self.createdAt = createdAt
}
}
@MainActor
final class SwiftDataTodoViewModel: ObservableObject {
@Published var todos: [SwiftDataTodo] = []
private let context: ModelContext
init(context: ModelContext) {
self.context = context
reload()
}
// Read
func reload() {
let descriptor = FetchDescriptor<SwiftDataTodo>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
todos = (try? context.fetch(descriptor)) ?? []
}
// Create
func add(title: String, detail: String) {
context.insert(SwiftDataTodo(title: title, detail: detail))
try? context.save()
reload()
}
// Update
func update(todo: SwiftDataTodo, title: String, detail: String) {
todo.title = title
todo.detail = detail
try? context.save()
reload()
}
// Delete
func delete(at offsets: IndexSet) {
for index in offsets {
context.delete(todos[index])
}
try? context.save()
reload()
}
}
6. Realm
使える機能
-
Objectモデルをそのまま保存 -
realm.write {}でトランザクション管理 - ソートやフィルタ、リアクティブ更新を行いやすい
CRUDコード(実装例)
import RealmSwift
final class RealmTodo: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var id: ObjectId
@Persisted var title: String = ""
@Persisted var detail: String = ""
@Persisted var isDone: Bool = false
@Persisted var createdAt: Date = Date()
}
@MainActor
final class RealmTodoViewModel: ObservableObject {
@Published var todos: [RealmTodo] = []
private let realm = try? Realm()
// Read
func reload() {
guard let realm else { return }
todos = Array(realm.objects(RealmTodo.self).sorted(byKeyPath: "createdAt", ascending: false))
}
// Create
func add(title: String, detail: String) {
guard let realm else { return }
let todo = RealmTodo()
todo.title = title
todo.detail = detail
try? realm.write { realm.add(todo) }
reload()
}
// Update
func update(todo: RealmTodo, title: String, detail: String) {
guard let realm else { return }
try? realm.write {
todo.title = title
todo.detail = detail
}
reload()
}
// Delete
func delete(at offsets: IndexSet) {
guard let realm else { return }
try? realm.write {
for index in offsets {
realm.delete(todos[index])
}
}
reload()
}
}
どれを選ぶべきか
- 個人アプリの軽量設定: UserDefaults
- ログイン情報やAPIキー: Keychain
- バックアップや人間可読な検証: File(JSON/CSV)
- 長期運用の業務アプリ: Core Data
- iOS 17以降でSwiftUI中心: SwiftData
- 既にRealm知見がある/クロス展開したい: Realm