Outboxアーキテクチャ:メッセージを取りこぼさないための設計
2026年2月13日
課題:送信即クリアでも、メッセージを取りこぼさないには
Obsidian連携シンプルメモでは、ユーザーが送信ボタンをタップした瞬間にUIがクリアされ、すぐに次のメモを書き始められる。これがCaptioスタイルの根幹をなすUXだ。
しかし現実のネットワークは300ms〜2秒のレイテンシがあり、失敗もするし、そもそもオフラインかもしれない。多くのアプリはネットワーク応答を待ってからUIをクリアする。安全だが、UXは悪い。逆に即座にクリアすると、メッセージを失うリスクがある。
Outboxパターンは、この相反する要件を同時に解決する。送信タップの瞬間にメッセージをローカルの暗号化ストレージ(Outbox)に永続保存し、UIを即座にクリアし、バックグラウンドでネットワーク送信を行う。送信に成功したらOutboxから削除し、失敗したら自動リトライする。これにより未送信のメッセージを端末に残して再送しつつ、体感レイテンシはゼロに近づく。
Outboxパターンの設計フロー
以下が、送信ボタンをタップしてからメッセージが送信されるまでの全フローだ。
- 送信タップ → まずOutboxにAES-GCM暗号化して永続保存
ユーザーが送信ボタンをタップした瞬間、メッセージはAES-GCM 256bitで暗号化され、デバイスのローカルストレージに永続保存される。このステップが完了するまで、UIクリアは行わない。つまり、Outbox保存が取りこぼしを防ぐ最初の防波堤になる。 - UIを即座にクリア(ユーザーは次のメモを書き始められる)
Outbox保存が成功した時点で、テキストビューをクリアし、送信アニメーションを再生する。ユーザーから見れば「送信した」と同義。ネットワーク応答を一切待っていない。Send-to-Resetの目標は、待ちを感じない短さ(アニメーション込みで0.25秒)。 - バックグラウンドでRelay API経由でメール送信
UIクリアと並行して、SendManagerがCloudflare Workers上のRelay APIへHTTPリクエストを送信する。この処理はメインスレッドをブロックしない完全非同期。 - 成功したらOutboxから削除
Relay APIから200レスポンスを受信したら、該当メッセージをOutboxから安全に削除する。これでメッセージのライフサイクルが完了する。 - 失敗したら自動で送り直す
ネットワークエラーやサーバーエラーの場合は、送信待ちに残したまま、回線が戻ったとき・アプリを開いたとき・バックグラウンドの処理(BGTaskScheduler。実行のタイミングは iOS が決める)をきっかけに送り直す。自動の再送には回数の上限がある。 - オフラインならNWPathMonitorで回線復帰を検知して自動再送
送信時点でオフラインの場合、Network.frameworkのNWPathMonitorが回線状態を監視し、Wi-Fiやセルラーの復帰を検知すると、Outboxの未送信のメッセージを自動で送り直す(上の5と同じく、自動の再送には回数の上限がある)。
SendManagerのコード
SendManagerのsend()関数は、Outboxパターンのエントリーポイントだ。まずOutboxに保存し、ネットワーク接続がなければキューイングし、接続があれば即座に送信を実行する。
// 保存完了と送信結果を分ける説明用の簡略API
@MainActor
func send(message: String, onSaved: () -> Void,
completion: @escaping (SendResult) -> Void) {
// まずOutboxに保存(クラッシュや圏外で失わないように)
let outboxMessage: OutboxMessage
do {
outboxMessage = try OutboxManager.shared.add(body: message)
} catch {
DispatchQueue.main.async { completion(.failure(error)) }
return
}
// 同期保存に成功した場合だけ、メインスレッドでUIへ通知する
onSaved()
// ネットワーク接続がない場合はキューに入れたことを通知
guard NetworkMonitor.shared.isConnected else {
BackgroundTaskManager.shared.scheduleRetryTask()
DispatchQueue.main.async { completion(.queued) }
return
}
// 送信処理を実行
performSend(message: message, outboxId: outboxMessage.id, ...)
}
重要なポイントは、OutboxManager.shared.add(body:)が同期的に永続ストレージに書き込むこと。この呼び出しが成功した時点で、メッセージは端末に安全に保存されている。ネットワーク送信がどのような結果になっても、メッセージはOutboxに残り続ける。
AES-GCM暗号化の実装
Outbox に保存されるメッセージは、ユーザーのプライベートなメモだ。ローカルストレージとはいえ、平文で保存するわけにはいかない。Apple CryptoKit の AES-GCM を使い、暗号化に関しては外部 SDK を一切介さない実装にしている。
- Apple CryptoKit — iOS 13以降で利用可能。暗号化処理に外部 SDK を介さない(Apple 純正のみ)。
- 256-bit対称鍵 — Keychainに保存する。
- 認証付き暗号化 — AES-GCMはデータの機密性と完全性を同時に保証。改ざんされたデータは復号時に検知される。
enum OutboxEncryption {
private static var encryptionKey: SymmetricKey {
if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
return SymmetricKey(data: existingKey)
}
let newKey = SymmetricKey(size: .bits256)
let keyData = newKey.withUnsafeBytes { Data($0) }
KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
return newKey
}
static func encrypt(_ plainText: String) throws -> Data {
guard let data = plainText.data(using: .utf8) else {
throw OutboxError.encryptionFailed
}
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
guard let combined = sealedBox.combined else {
throw OutboxError.encryptionFailed
}
return combined
}
}
鍵は初めて必要になったときに自動で作られ、Keychainに保存される。保存の設定は「この端末だけ・最初のロック解除のあとから読める」(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)で、ほかのアプリからは読めず、バックアップから別の端末へ移ることもない。再起動のあと一度ロックを解除すれば、ロック中のバックグラウンド処理からも読める。なお、上のコード例は記事を書いた時点の簡略版で、今のコードでは、キーチェーンの読み取りに失敗したときに「鍵が無い」と扱って新しい鍵を作ることはしない。
データパスを Apple 純正のみで構成する意味
Outboxアーキテクチャで使用しているフレームワークはすべてAppleネイティブだ。
- CryptoKit — AES-GCM暗号化。サードパーティの暗号化ライブラリは不要。
- Network.framework — NWPathMonitorによる回線状態監視。Reachabilityライブラリは不要。
- BackgroundTasks — BGTaskSchedulerによるバックグラウンドリトライ。
送信待ちの経路に外部のライブラリが無いことには、次の意味がある。
- 外部の更新に左右されない — 送信待ちの処理が、サードパーティのライブラリの更新や提供終了で動かなくなることがない。
- セキュリティ — 送信待ちのメモの本文に触れる処理(暗号化・保存・送信)に、第三者のコードが入らない。
なお、アプリ全体では外部のSDK(Googleサインイン、Firebase、AppsFlyer)も使っている(プライバシーファースト設計を参照)。ここで言う「Apple純正のみ」は、送信待ちのデータの経路の話だ。
UIクリアとネットワーク送信の分離
Outboxパターンの真の価値は、UIレイヤーとネットワークレイヤーの完全な分離にある。送信ボタンのタップハンドラを見てみよう。
@objc private func sendButtonTapped() {
PerformanceLogger.shared.beginSendToReset()
let message = textView.text ?? ""
isSending = true
sendBarButton.isEnabled = false
SendManager.shared.send(message: message, onSaved: { [weak self] in
// 端末への保存成功後にだけ消す。ネットワーク応答は待たない。
self?.performSendAnimation { [weak self] in
guard let self = self else { return }
// アニメーション中に編集された入力は残す。
if self.textView.text == message {
self.clearTextView()
}
PerformanceLogger.shared.endSendToReset()
}
}) { [weak self] result in
self?.isSending = false
self?.updateSendButtonState()
switch result {
case .success:
PerformanceLogger.shared.logEvent(name: "SendSuccess")
case .failure(let error):
self?.handleSendError(error)
case .queued:
self?.showQueuedFeedback()
}
}
}
この簡略例では、onSavedを端末への保存成功時だけ呼び出し、そこからアニメーションと入力消去を行う。保存に失敗した場合は入力を残す。completionは送信結果やキューへの登録を知らせる別の通知であり、UIクリアはネットワーク応答を待たない。
Send-to-Resetの目標は待ちを感じない短さ。アニメーション込みでも0.25秒。ユーザーは送信ボタンをタップした直後に、次のメモを書き始められる。これがCaptioスタイルのUXだ。
よくある質問
Q. オフラインで送信したメモはどうなりますか?
Outboxに暗号化して保存され、回線の復帰(NWPathMonitorで検知)・アプリを開いたとき・バックグラウンドの処理をきっかけに、自動で送り直します。自動の再送には回数の上限があり、上限に達したメモは「履歴」で長押しして「再送信」を選びます。
Q. アプリを閉じてもメモは失われませんか?
Outboxは永続ストレージです。アプリを終了しても、iPhoneを再起動しても残ります。次回アプリ起動時、またはバックグラウンドタスクにより自動的にリトライされます。
Q. なぜAES-GCMを選んだのですか?
Apple CryptoKitがネイティブサポートしており、認証付き暗号化(Authenticated Encryption)により改ざん検知も可能です。暗号化の処理に外部ライブラリが要らない点も利点です。
Q. 送信失敗時のリトライ回数は?
自動で送り直します。きっかけは、回線が戻ったとき、アプリを開いたとき、バックグラウンドの処理(BGTaskScheduler。実行の時刻は iOS が決める)です。送れなかったメモは送信待ちから消さずに送り直しますが、自動の再送には回数の上限があります。
関連記事
参考文献
- Apple Developer — AES.GCM (CryptoKit) — Outboxデータの暗号化に使用したAES-GCM認証付き暗号化の公式リファレンス
- Apple Developer — NWPathMonitor (Network Framework) — オフライン検知とネットワーク状態監視に使用
- Apple Developer — BGTaskScheduler — バックグラウンドでの再送処理スケジューリングに使用
- Chris Richardson — Pattern: Transactional outbox (microservices.io) — トランザクショナルOutboxパターンの解説(送るメッセージを同じ処理の中で送信待ちの表に書き、別の処理が送り出す)