Photoruction工事中!

Photoructionの開発ブログです!

ローカルDBを Realm から Room へ移行します 🔜

Androidエンジニアの斎藤です 👨‍💻

まずはじめにタイトルを見て「Realm を? 2025年に?」と思った方、私も思います。

とはいえ私自身 Realm を触ったのがこの会社が初めてだったのですが Realm の第一印象は特に悪くなく、Roomが出る前の SQLiteDatabase に比べたら選択肢として間違ってなさそうな気もします。

しかし Realm 特色などを理解せずに利用することで予期せぬバグを生んでしまうこともわかりました。

そんな困りごととどのように解決したか(まだしていない)を共有できればと思います。

はじめに

今や Realm を使っているプロダクトはAndroidでは珍しいかと思います。現在でも大半は Realm を使っており一部新しい機能は Room で実装されています。

フォトラクションでは2017年から開発されモバイルアプリとしては歴史が長く、それに加えてさまざまな理由から未だにRealmは残り続けています。

しかし、とある問題が発覚してからようやく重い腰を上げて全てRoomに置き換えようということになりました。

今回はそのとある問題について紹介しRealmをより深く掘り下げていければと思います。

前提条件として既存アプリの現状の設計は過去に公開した記事の通りになっています。

(一部異なるところもありますが大体はこのようになっています)

https://kojichu.photoruction.com/entry/adcale20221209

https://kojichu.photoruction.com/entry/adcale20211215

とある問題について

まずは発生した現象から。

ある Realm からデータを取得する時に、古いデータが取得されることがありました。

しばらく原因不明でしたが、根気よく調査してみると Realm の読み込みはスナップショットベースであることがわかりました。そしてデータ読み込み時に明示的に最新のデータを取得する場合は realm.refresh() を行うことが必要だと。

しかし、そもそもスナップショットベースってなんでしょうか。Realmは他のDBと何が違うのでしょうか。

Realmの設計哲学

Realmは内部的に MVCC(Multi-Version Concurrency Control) を採用しています。

これは一言で言うと:

  • Realmインスタンスは「ある時点のDB状態(スナップショット)」を表す
  • 書き込みは新しいバージョンを作り、読み込みは各スレッドのスナップショットを使ってロックなしで並行処理可能
  • Realmインスタンスはスレッドごとに独立し、最新の状態を使いたければ明示的にrefresh()する必要がある

つまりRealmは「時点固定の読み込みと書き込み分離」が原則で、データは常に「瞬間的な静止画」を見ているイメージです。

ということは refresh を呼ぶだけでいいのですが、これを実装するにあたって更なる疑問が出てきました。

一方でアーキテクチャの常識:関心分離と状態隠蔽

フォトラクションでは記事にある通りRepository層を設けて関心分離と状態隠蔽を行っています。

元となったクリーンアーキテクチャやDDDの考え方では、

  • Repositoryはデータソース(DB)からのデータ取得を抽象化し、変更の検知や状態管理は上位層に任せる
  • Repository内部の状態(キャッシュやタイミング)を外に漏らさず、いつでも「最新の状態を返す」ことが理想
  • さらに複数Repositoryの連携でトランザクションや状態整合性を保ちたい

このため、Repositoryは単純な「CRUD」以上の責務を持たず、状態更新の管理はControllerやUseCaseに委ねることが多いです。

フォトラクションでも「CRUD」以上の責務は ViewModel に委ねています。

しかし、この最新状態保証とRealmのスナップショット設計の相性が悪いのです。

衝突点:Realmのスナップショット設計は「最新状態保証」と相性が悪い

Realmは「スナップショット=状態固定」なので、

  • Repository単位で呼び出されると、その時点の状態を返すだけになりやすい
  • 他スレッドで更新されても、そのRepositoryのRealmインスタンスは更新されない(refresh()しない限り)
  • Repository単体では「最新状態が保証されているか」がわからず、どこでrefresh()すべきかの判断ができない

つまり、

RealmのMVCCは「明示的な更新管理」が必須である一方、Repositoryはそれを隠蔽・分離したいというアーキテクチャの要求と相反する

という構造的な問題を生みます。

今回のケースでは書き込みによって新しいバージョンを作成したが、読み込みは古いバージョンのデータを見ていた。そしてこれを解決するには明示的に読み込み時に refresh() することです。

では RealmObject を取得する Repository クラスの関数全てに refresh() を記述すればいいのでしょうか。

Realmのrefresh()問題とそのコスト

refresh()Realmインスタンスのスナップショットを最新に切り替える処理です。

  • これを呼ばないと他スレッドの変更は反映されない
  • しかし頻繁に呼ぶとパフォーマンスに影響する可能性がある(特に大量の更新がある場合)

このため、

  • 「どこで」「どのタイミングで」「誰が」refresh()を呼ぶかはアプリ設計の重要課題
  • Repository単位で呼ぶと責務が混ざり、呼ばないと最新を見られず、どちらもデメリット大

一般に Realm における refresh() のコストは小さいですが、以下の条件によって変動します:

コスト要因 説明
差分の大きさ 書き込み後の変更量が大きいほど、バージョン間の更新にかかる処理は重くなる
保持中の RealmObject の数 オブジェクトが多いほど、参照整合性のための更新コストが発生
古い Realm インスタンスを長く保持している場合 トランザクションログのサイズが大きくなり、GCや refresh の負荷が上がる

フォトラクションの場合、差分同期という仕組みを利用していますが、建設BPOサービスも行っておりこれによって大量の納品データが格納させると突然巨大なデータが降ってくることがあります。

この時 refresh() のコストが著しく高くなる可能性があります。

以上が発生した問題です。

対策の方向性

  1. 上位レイヤーでrefreshを管理する

    UseCaseやViewModel層でrefresh()を一括管理し、Repositoryは純粋にデータアクセスのみを担当する。

  2. リアクティブ設計を採用する

    RealmのasFlow()や通知機能を使い、データの変化を監視して自動で最新化を反映する。

    → これで明示的refreshが不要になり、MVCCの設計を活かせる。

  3. RealmProviderやラッパーを設ける

    Realmインスタンス生成とrefresh管理を一括する抽象レイヤーを設けて、複数Repositoryでの状態管理を一元化する。

我々の決断としては 4. の Realm を諦める。になりました。

理由としてはどれも修正コストが高く、このコストを払うならRoom移行した方がいいのではという結論でした。

細かいところでは、フォトラクションはリレーションが多いプロダクトなのでSQLテーブル結合を View テーブルで表現した方がシンプルであったり、Realmの部分一致ロジックに不満があったり、あるのですが大きな理由としては今回の問題が一番大きな理由です。

終わりに

Realmは強力で高速なDBであることは間違いないと思います。

しかし、スナップショットベースのMVCC設計は、一般的な関心分離アーキテクチャとぶつかりやすいことを理解しておく必要がありそうです。

今回は Android と Realm について言及しましたが、 他のスナップショットベースのMVCC設計データベースと何かの組み合わせでも起こりうることなので頭の隅に入れておくといいかなと思いました。

株式会社フォトラクションでは一緒に働く仲間を募集しています

www.wantedly.com