
はじめに
2025年に新卒でSansanに入社し、技術本部 Sansan Engineering Unit Mobile Application GroupでiOSエンジニアとして開発に携わっている松山(@akidon0000)です。
今回は、Mobileチーム「技術負債返済」をテーマとしたTech Blogリレー企画の第五弾となります。
- 技術負債解消に向けた継続的運用の試み(2025-09-01)
- 10年もののSansan Mobileで負債・リスクに向き合う(2025-10-29)
- Android Edge-to-Edge対応 大規模アプリですべての画面を更新するための道のり (2025-11-13)
- SansanのAndroid View→Jetpack Composeへの移行計画 (2025-11-17)
Sansan Mobileアプリは10年以上の継続的な機能追加と運用の中で、設計・実装の前提やライブラリの常識が少しずつ変わってきました。その結果として溜まりがちな「技術的負債」に対し、私たちは継続的に対応する取り組みを強化しています。
本記事では、入社後に私が技術負債と向き合いながら学んだこと、特にiOSにおけるデータ永続化方法の1つであるRealmライブラリを使用した「スレッド安全性の改善」をテーマに、その背景・方針・移行・効果を紹介します。
直面していた問題
Sansan Mobileアプリは長年の開発の中で、チームや実装スタイルが変遷してきました。その過程で、Realmの「スレッド境界を越えたオブジェクトの取り扱い」について暗黙知や例外対応が増え、トラブルが起こりやすい状況になっていました。
実際、Firebase Crashlytics上では、Realm関連のクラッシュが多く発生し、ユーザーに影響を与えていました。
Realmは「スレッド拘束(thread confinement)」の考え方に基づき、オブジェクトは作成・取得されたスレッドにて独立して使うことで安全に扱える仕様です。しかし実際には、異なるスレッド間でRealmオブジェクトを受け渡す可能性がいくつか存在していました。
具体的なコードの問題
以下に、既存実装の問題点を示します。
import RealmSwift final class User: Object { @objc dynamic var id = "" @objc dynamic var name = "" } // NG: Repositoryの公開APIにてRealmObject露出 // 別スレッドで使用されると「incorrect thread」クラッシュリスク protocol UserRepository { func add(_ user: User) func fetchUsers() -> [User] } final class UserDataStore: UserRepository { func add(_ user: User) { let realm = try! Realm() try! realm.write { realm.add(user) } } func fetchUsers() -> [User] { let realm = try! Realm() return Array(realm.objects(User.self)) } }
公開APIでRealmObjectを露出していたため、異なるスレッドで誤って利用されるリスクが高い状態でした。加えて、Realmの扱いに統一性がなく、ある箇所ではRealmObjectをそのまま返却し、別の箇所では値型へ変換してから扱うなど実装が混在していました。その結果、スレッド安全の担保方法が実装者ごとにばらつき、暗黙知に依存する状況となり、クラッシュや不整合の発生だけでなく、保守性も損なわれていました。
これらの課題を踏まえ、まずは永続化基盤の選択から再検討を行いました。
技術選定の検討
まず、上記の課題を解決するために、以下の3つの選択肢を検討しました。
- Realm読み書き部分の記述を改善しながら継続利用する
- SwiftDataへ移行する
- KMPでRoomを採用しiOS・Androidを共通化する
なお、上記以外のサードパーティー製の永続化ライブラリは、メンテナンス面の懸念から比較対象に含めていません。具体的には、開発継続性、セキュリティ対応速度、iOS/Swiftの破壊的変更への追従速度といった点で、中長期の運用リスクが相対的に高いと判断しました。そのため、現実的な選択肢を Realm/SwiftData/Room の三択に絞っています。
そして、最終的にRealmの継続利用を決定しました。その理由を、短期と中長期の両面から説明します。
短期的な視点
Sansan MobileアプリにおけるRealmの実装・改修は、2〜3ヶ月に1回程度と使用頻度が低いことが分かりました。過去半年間でRealm関連のファイルに加えられた変更は、エラー改善を除くとわずか3件のみでした。
この低頻度の改修に対し、SwiftDataやRoomへの移行には400〜600時間/人 以上のコストが見込まれます。一方、Realm継続での改善は200時間/人 で済む工数見積りであるため、Realm以外の選択肢は短期的なROIが見合わないと判断しました。
中長期的な視点
中長期的に見ても、現時点での他ライブラリへの移行は過剰対応と考えました。
Realmの安定性 Realmは2014年から継続的に開発されており、MongoDB社による買収後も活発にメンテナンスが続いています。2025年現在でも定期的なアップデートが継続しており、短期的にサポートが終了する兆候は見られません。
SwiftDataの制約 SwiftDataは魅力的な選択肢ですが、UIKit主体の現コードベースでは運用しづらい面があります。SwiftDataの最大の強みであるSwiftUIとの高い親和性を享受できない状況で導入すると、新たな技術的負債を生み出すリスクがあると判断しました。
KMP Roomの知見不足 KMPでRoomを使ったiOS実装に精通しているメンバーがおらず、他社での導入実績も乏しく、知見が不足しています。このため、導入時に予期しない問題が発生するリスクがあると判断しました。
これらの理由から、現時点ではRealmの継続利用が最も合理的であると判断しました。
解決へのアプローチ
スレッド安全性の問題を根本的に解決するため、以下の3つの柱からなる実装方針を策定しました。
- RealmObjectの命名を変更し記述方法をモダンにする
- DataStoreのActor化
- モジュール分離によるRealm実装の隠蔽
1. RealmObjectの命名を変更し記述方法をモダンにする
- 命名を
{エンティティ名}RealmObjectに統一し、層境界での意味を明確化 @objc dynamicから@Persistedへ移行。最新のRealm推奨記法に統一- 外部公開は値型を原則とし、Realm依存を極力閉じ込める
命名による役割の明確化で、実装ミスを低減させることが狙いです。 また、データ受け渡しを値型(Entity)に限定することで、Realm特有のスレッド制約を意識する必要がなくなり、安全かつ柔軟にデータを扱えるようになります。
// Before: 命名/記法が混在し、外部境界にも漏れている final class User: Object { @objc dynamic var id = "" @objc dynamic var name = "" }
// After: 命名統一 + @Persisted 記法。外部には値型 User を公開 final class UserRealmObject: Object { @Persisted(primaryKey: true) var id: String @Persisted var name: String func toEntity() -> User { return .init(id: id, name: name) } } public struct User: Sendable { public let id: String public let name: String }
2. DataStoreのActor化
Sansanでは、VIPERを導入しており、Clean Architectureをベースとした以下の層構造を採用しています。
Presentation層 (Presenter)
↓
Domain層 (Interactor/UseCase)
↓
CoreLayer層 (Repository/DataStore)
CoreLayer層のDataStoreをActor化することで、Realm操作におけるスレッドセーフをコンパイルレベルで担保することが狙いです。 従来の手動によるスレッド管理をActorに委譲することで、並列アクセス時の競合リスクを排除します。 これにより、Runtime Errorを未然に防ぎつつ、開発者は複雑なスレッド管理を意識することなく、ビジネスロジックの実装に集中できるようになります。
Before / After
// Before: classで実装しスレッド安全性が担保されていない import RealmSwift final class UserDataStore { func add(_ entity: UserRealmObject) { let realm = try! Realm() try! realm.write { realm.add(entity) } } }
// After: actor化 + Realm.open + asyncWrite でスレッド安全に import RealmSwift actor UserDataStore { func add(_ entity: UserRealmObject) async throws { let realm = try await Realm.open() try await realm.asyncWrite { let obj = UserRealmObject() obj.id = entity.id obj.name = entity.name realm.add(obj) } } }
3. モジュール分離によるRealm実装の隠蔽
モジュール境界を利用して、Realmへの直接依存を物理的に断ち切ることが最大の狙いです。
RealmObjectや具体的なDB操作をモジュール内に internal として閉じ込め、外部にはプロトコル(Repository)と Sendableな値型(Entity)のみを公開します。 これにより、利用側が誤ってRealmの制約に触れることを不可能にし、アーキテクチャレベルでスレッド安全性を強制します。また、具象クラスへの依存を排除することで、疎結合でテスト容易性の高い構成を実現します。
Before / After
// Before: アプリ本体がRealmの詳細(Object)に依存している import RealmSwift final class UserService { func all() -> [UserRealmObject] { // ← 外部へRealmObjectを露出 let realm = try! Realm() return Array(realm.objects(UserRealmObject.self)) } }
// After: Realm依存はRealmTargetModuleに隠蔽、外部は値型とRepositoryのみに依存 // RealmTargetModule 側 import RealmSwift public enum RealmDataStoreFactory { public static func createUserRepository() -> some UserRepository { return UserDataStore() } } public protocol UserRepository { func all() async throws -> [User] } actor UserDataStore { private func fetchAllUsers() async throws -> [User] { let realm = try await Realm.open(configuration: .init()) return realm.objects(UserRealmObject.self).map { User(id: $0.id, name: $0.name) } } } extension UserDataStore: UserRepository { public func all() async throws -> [User] { try await fetchAllUsers() } }
SansanMainTargetModule (アプリ本体) ├── Presentation層、Domain層 └── 依存 → EntityModule, RealmTargetModule EntityModule └── Sendable準拠のStruct定義 RealmTargetModule (新設) ├── Repository (プロトコル定義) ├── DataStore (actor実装) ├── RealmObject (internal、外部非公開) └── RealmDataStoreFactory
プロジェクトを通じて学んだこと
今回の取り組みを通じて、技術的にもチーム運営的にも多くの学びがありました。
- 小さく進めることで「安全に変える」ことができる
大きな改善テーマほど、一気に進めようとするとリスクや不安が大きくなります。今回のように、既存機能を壊さず、段階的に移行していくアプローチをとることで、チーム全体が安心して改善を進められると感じました。 RealmのActor化も、まず1つのDataStoreから試験的に導入し、段階的に横展開していくことで、想定外の挙動を早期に検知できました。
- 責務を明確にすることが保守性を高める
「DataStoreはデータアクセスに専念し、UseCaseがビジネスロジックを持つ」というシンプルなルールを徹底することで、実装の見通しが格段に良くなりました。 また、層ごとの依存方向が明確になると、チーム内でのコードレビューや議論もスムーズになります。これは、単なる設計改善にとどまらず、チーム開発の効率化にもつながる大きな効果だと感じました。
- 「技術負債返済」は学びの宝庫
技術負債という言葉にはネガティブな印象がつきがちですが、実際に向き合ってみると、過去の設計思想や技術選定の背景を学ぶ機会でもあると感じました。 特に、過去の実装に「なぜそうなっているのか」という意図を見つけ出す過程は、設計力や読解力を鍛える絶好のトレーニングでした。
最後に
新卒として大規模プロダクトに携わることは、最初こそ不安もありましたが、同時に「手を動かしながら学べる最高の環境」でもありました。 特に、既存のコードベースを理解し、改善を積み重ねていく過程は、“コードを読む力”と“チームで改善する力”を身につける貴重な経験になりました。
また、今回の取り組みでは「技術負債を減らす」だけでなく、将来の自分たちが開発しやすくなる環境を整えるという意識が強まりました。 目の前のクラッシュを減らすことももちろん大切ですが、それ以上に、設計や責務分離といった“構造的な改善”を通じて、未来の開発速度を上げることこそが本質的な負債返済だと感じています。
SansanのMobileチームでは、こうした「小さな改善を継続して積み上げる文化」を大切にしています。 この記事が、同じように技術負債に向き合う方の一助になれば嬉しいです。
Sansan技術本部ではカジュアル面談を実施しています

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。