Firestoreのリスナー1つがFlutterアプリの請求書を爆発させる理由: 読み取り100万件あたり$0.60の罠

Firebaseコンソールの使用量グラフが、ある日突然階段状に跳ね上がっているのを見たことがあるなら、原因はほぼ常にコードロジックではなくウィジェットのライフサイクルにある。機能を1つ追加したわけでもユーザーが急増したわけでもないのに、Firestoreの読み取り回数が一夜にして10倍になる事故は、Flutterアプリで特によく起こる。理由は単純だ。StreamBuilderと.snapshots()は書きやすい反面、リスナーのライフサイクルを見えにくくしてしまい、ウィジェットが再ビルドされるたびにリスナーが静かに再接続され、すでに読み込み済みのドキュメントを最初から読み直してしまうからだ。
この記事は「Firestoreは高い」という漠然とした警告ではなく、正確な単価で計算したときに、よくあるアンチパターン1つが月の請求書にいくら上乗せするのかを実際の数字で示す記事だ。料金表、Flutterで繰り返し発生する3つのアンチパターン、DAU 5,000/10万規模でのコスト曲線、無料の日次上限が枯渇する閾値の計算方法、そして実践的な対策パターンと代替アーキテクチャまでを扱う。
要点まとめ
- Firestore Blazeの料金(nam5/eur3リージョン基準、公式ドキュメントで確認)は、読み取り10万件あたり$0.06、書き込み10万件あたり$0.18、削除10万件あたり$0.02だ。 読み取り100万件なら$0.60 — 数字だけ見ると小さく見えるが、リスナーの再接続が重なるとこの単価が恐ろしい勢いで掛け算されていく。
- 無料の日次上限は読み取り5万件・書き込み2万件・削除2万件・ストレージ1GiBであり、この上限はプロジェクトごとにデフォルトデータベース1つにのみ適用される。 アプリ1つではなく、プロジェクト全体のトラフィックがこの上限を共有する。
- リアルタイムリスナーは最初の接続時に、結果セット内のすべてのドキュメントを1回ずつ読み取りとして課金し、それ以降は変更されたドキュメントのみが課金される。 問題は、オフラインパーシステンスがオフになっていると、リスナーが切断されて再接続するたびに完全に新しいクエリとして扱われ、ドキュメント全体を読み直してしまう点だ。Flutterではこの再接続が、ウィジェットの再ビルド1回だけで簡単に発生する。
- リスト画面で項目ごとに別々のドキュメントを取得するN+1パターン、ウィジェットの再ビルドのたびに新しいストリームを生成するリスナー再接続、リアルタイムリスナーの代わりに
.get()を繰り返し呼び出すポーリングパターン、この3つがFlutter+Firestoreの組み合わせで請求書を爆発させる最も一般的な原因だ。 - 対策は、リスナーインスタンスのキャッシュ、カーソルベースのページネーション、非正規化によるN+1の排除、そして読み取り中心で変動の少ないデータをCloudflare D1/KVのようなはるかに安価なバックエンドへ分離するハイブリッド構成だ。
Firestore Blazeの正確な単価をまず押さえておく
まず、大まかな印象ではなく正確な数字から始める。以下の表は、Firebase/Google Cloudの公式料金ページ(nam5・eur3などデフォルトのマルチリージョン基準)で直接確認した値だ。
| 項目 | 無料の日次上限 | 超過時の単価 |
|---|---|---|
| ドキュメント読み取り(Document Reads) | 50,000件/日 | $0.06 / 10万件(100万件あたり$0.60) |
| ドキュメント書き込み(Document Writes) | 20,000件/日 | $0.18 / 10万件 |
| ドキュメント削除(Document Deletes) | 20,000件/日 | $0.02 / 10万件 |
| ストレージ容量(Stored Data) | 1 GiB | GiBあたり月単位で課金(リージョンにより異なる) |
| ネットワーク送信(Egress) | 10 GiB/月 | リージョン・宛先により異なる |
ここで、実務者が見落としがちな細かいルールを3つ確認しておく必要がある。
- クエリは結果が0件であっても、最低1回の読み取りとして課金される。 「存在しないデータを問い合わせれば無料」という思い込みは誤りだ。
- 複雑なクエリは、スキャンしたインデックスエントリ1,000件あたり1回の読み取りとして追加課金される。
count()のような集計クエリも同じルールに従い、インデックスエントリを一切読まなくても最低1回は課金される。 - 無料枠はプロジェクトのデフォルト(default)データベース1つにのみ適用される。 同じプロジェクトに追加でデータベースを作成すると、そのデータベースには無料枠がなく、最初から課金対象になる。
そして、この記事の核心に最も直結するルールがもう1つある。公式ドキュメントはリアルタイムリスナーの課金方式をこう説明している。
クエリの結果をリスニングする場合、結果セットにドキュメントが追加または更新されるたびに読み取りとして課金されます。それ以降、アクティブなリスナーは変更が検出されたドキュメントに対してのみ課金されます。ただし、オフラインパーシステンスが無効になっている状態でリスナーが切断され再接続されると、まったく新しいクエリを実行したかのように、ドキュメントとインデックスエントリの読み取りとして課金されます。
つまり.snapshots()リスナーは「一度接続すれば、以降は変更分だけを安く受け取れる」効率的な仕組みであることは間違いない。問題は、その効率が成立するのはリスナーが「接続し続けている」場合に限られるという点であり、Flutterのウィジェットツリーは、リスナーを接続し続けることをデフォルトで保証してはくれない。
Flutterアプリで繰り返し見られる3つのアンチパターン
Firebaseコミュニティと実際のプロダクションインシデントで繰り返し観察されるパターンは、大きく3つある。3つとも、コード自体は「動作」してしまうため、レビューでは見逃されやすく、請求書が跳ね上がって初めて発見されることが多い。
アンチパターン1: リスト画面で項目ごとに別々のドキュメントを取得する(N+1)
チャット一覧、フィード、注文履歴のように、リストの各項目が別のコレクションのドキュメントを参照する画面で最もよく見られる。
// 안티패턴: 아이템 하나마다 별도 get() 호출
ListView.builder(
itemCount: chats.length,
itemBuilder: (context, index) {
final chat = chats[index];
return FutureBuilder<DocumentSnapshot>(
future: FirebaseFirestore.instance
.collection('users')
.doc(chat.otherUserId)
.get(), // 리스트 50개면 읽기 50건 추가, 스크롤·재빌드마다 반복
builder: (context, userSnap) {
final name = userSnap.data?.get('displayName') ?? '...';
return ListTile(title: Text(name));
},
);
},
)
リストクエリ自体は1件(またはページネーション時はページサイズの分)で済むが、項目ごとに相手のプロフィールを取得するせいで、画面を描画するたびにN個の追加読み取りが発生する。スクロールでウィジェットが再構成されたり、画面を再訪問するたびにキャッシュなしでこの取得が繰り返されると、実質的な読み取り数はあっという間にNの何倍にも膨れ上がる。
解決策は2つある。
- バッチ取得でラウンドトリップ回数を減らす。 Firestoreの
whereInは一度に最大30個の値しかサポートしないため、30個単位でチャンクに分けて取得し、結果は必ずMapにキャッシュして再利用する。
final userIds = chats.map((c) => c.otherUserId).toSet().toList();
final chunks = [
for (var i = 0; i < userIds.length; i += 30)
userIds.sublist(i, (i + 30).clamp(0, userIds.length)),
];
final userDocs = await Future.wait(chunks.map((chunk) =>
FirebaseFirestore.instance
.collection('users')
.where(FieldPath.documentId, whereIn: chunk)
.get(),
));
- より根本的には、非正規化(denormalization)によって取得そのものをなくす。
otherUserName、otherUserAvatarUrlのようによく使うフィールドをchats/{chatId}ドキュメントの中にあらかじめ一緒に保存しておき、プロフィールが変わったときだけCloud Functionsのトリガーで関連ドキュメントを更新する。プロフィールの変更は稀で一覧の取得は頻繁なので、書き込みを数件増やす代わりに読み取りN件を完全になくす、典型的なNoSQLのトレードオフだ。
アンチパターン2: ウィジェットの再ビルドのたびにStreamBuilderリスナーが再接続される
これが最も発見しにくく、最もコストの高いパターンだ。.snapshots()の呼び出しをbuild()メソッドの中にインラインで書くと、buildが呼ばれるたびに新しいストリームインスタンスが作られ、それまでのリスナーは破棄される。 先ほど引用した公式ルールの通り、リスナーが新しく接続されると結果セット全体が再び読み取りとして課金される。
// 안티패턴: build()마다 새 스트림 생성 → 매번 리스너 재연결
class ChatRoomScreen extends StatelessWidget {
final String roomId;
const ChatRoomScreen({required this.roomId});
@override
Widget build(BuildContext context) {
return StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('rooms/$roomId/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.snapshots(), // 부모가 setState 할 때마다 이 스트림도 새로 생성됨
builder: (context, snapshot) { /* ... */ },
);
}
}
タイピングインジケーター、バッジカウント、アニメーションコントローラーのように、チャット画面とは無関係な状態変化が親ウィジェットを再ビルドさせるたびに、このStreamBuilderも一緒に再生成され、すでに読み込んでいた直近のメッセージ50件を毎回最初から読み直してしまう。
解決策は、ストリームインスタンスをウィジェットのライフサイクルから切り離し、1回だけ生成するようにすることだ。
class ChatRoomScreen extends StatefulWidget {
final String roomId;
const ChatRoomScreen({required this.roomId});
@override
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
}
class _ChatRoomScreenState extends State<ChatRoomScreen> {
late final Stream<QuerySnapshot> _messagesStream;
@override
void initState() {
super.initState();
// initState는 위젯 생명주기 동안 단 한 번만 호출된다.
_messagesStream = FirebaseFirestore.instance
.collection('rooms/${widget.roomId}/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.snapshots();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<QuerySnapshot>(
stream: _messagesStream, // rebuild되어도 같은 스트림 재사용
builder: (context, snapshot) { /* ... */ },
);
}
}
Riverpodを使っているなら、StreamProvider.familyで同じ効果をより明示的に得ることができる。プロバイダーがroomIdごとにストリームをキャッシュし、ウィジェットが再ビルドされてもref.watchだけではストリームは再生成されない。
final messagesProvider =
StreamProvider.family<QuerySnapshot, String>((ref, roomId) {
return FirebaseFirestore.instance
.collection('rooms/$roomId/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.snapshots();
});
autoDisposeを付けるかどうかはトレードオフだ。チャットルームを退出したらすぐリスナーを切りたいのであればautoDisposeが適切だが、タブ切り替えのように画面から一瞬離れてすぐ戻ってくるパターンが多いなら、autoDispose + keepAlive()の組み合わせで短い猶予時間を設けないと、タブを行き来するたびに再接続コストが発生してしまう。
アンチパターン3: リアルタイムリスナーの代わりにget()をポーリングのように繰り返し呼び出す
一部のチームは、.snapshots()のリアルタイム性が不要だと判断し、Timer.periodicで数秒ごとに.get()を呼び出す方式を使う。一見「リスナーを使わないから安く済むはずだ」と考えがちだが、実際には逆だ。
// 안티패턴: 5초마다 폴링 — 변경 여부와 무관하게 매번 전체 재조회
Timer.periodic(const Duration(seconds: 5), (_) async {
final snap = await FirebaseFirestore.instance
.collection('rooms/$roomId/messages')
.orderBy('timestamp', descending: true)
.limit(50)
.get();
updateUi(snap.docs);
});
.snapshots()リスナーは最初の接続以降、変更されたドキュメントのみが読み取りとして課金されるが、.get()ポーリングは変更の有無にかかわらず呼び出すたびに結果セット全体を読み直す。 1分間に12回(5秒間隔)ポーリングし、結果が50件だとすると、何の変化がなくても分あたり600件、時間あたり36,000件の読み取りが発生する。リアルタイム性が必要な画面なら、最初から.snapshots()を使う方が構造的に安く、本当にポーリングが必要な場合(例: バッチ的な同期)は、間隔を広げてlimitを最小化するか、updated_atカーソルベースで変更分だけを取得するようにクエリを絞り込む必要がある。
実際の計算例: DAU 5,000のチャットアプリがアンチパターン1つで膨らむコスト
3つのアンチパターンの中で最も影響が大きい**アンチパターン2(リスナー再接続)**を基準に計算してみる。以下の前提は実際のチャットアプリのトラフィックパターンを単純化したモデルであり、厳密なベンチマークではなく、コストがどれくらいの倍率で膨らむのかの感覚をつかむための例であることを先に断っておく。
前提
- DAU(日間アクティブユーザー)5,000人
- ユーザー1人あたり1日平均1回のチャットルームセッション、セッション中に親ウィジェットの再ビルドによるリスナー再接続が平均10回発生(タイピングインジケーター、既読表示などチャットと無関係な状態変化が原因)
- 再接続のたびに直近のメッセージ50件(
limit(50))を最初から読み直す
計算
ユーザー1人あたりの1日の追加読み取り = 再接続10回 × 50件 = 500件
DAU 5,000人 × 500件 = 1日あたり2,500,000件の追加読み取り
月間(30日)の追加読み取り = 2,500,000 × 30 = 75,000,000件(7,500万件)
追加コスト = 75,000,000 / 100,000 × $0.06 = 750 × $0.06 = $45/月
このアプリがDAU 10万にまで成長したらどうなるか。 同じアンチパターン、同じユーザーあたりの読み取り倍率(500件/日)を維持すると仮定すると:
DAU 100,000人 × 500件 = 1日あたり50,000,000件の追加読み取り
月間の追加読み取り = 50,000,000 × 30 = 1,500,000,000件(15億件)
追加コスト = 1,500,000,000 / 100,000 × $0.06 = 15,000 × $0.06 = $900/月
DAUが20倍に増えると、このアンチパターンによる追加コストも正確に20倍(月$45 → $900)になる。 リスナー再接続による無駄はユーザー数に対して線形に比例するため、「ユーザーが少ないうちは気づかず、スケールが大きくなると突然恐ろしく見える」という典型的な曲線を描く。しかも、実際のアプリでアンチパターンが1つだけ存在するケースは稀だ。N+1クエリとリスナー再接続が同時に存在すると、2つの無駄が掛け算的に重なり合い、上記の計算よりもはるかに急な請求書を作り出す — リスト画面の項目数(N)、項目ごとのリスナー数(L)、再接続の頻度(F)がすべて掛け合わされる構造だからだ。
無料の日次上限(5万件)が枯渇する閾値を逆算する
DAU 5,000程度の「まだそれほどスケールが大きくない」アプリであっても、無料枠の5万件は思ったよりずっと早く底をつく。次の式で逆算できる。
同時接続者数(C) × セッションあたりの平均リスナー数(L) × リスナーあたりの平均初期読み取りドキュメント数(R) × 再接続倍率(F)
= 1日あたりの総読み取り数
無料枠を超えない最大同時接続者数 C_max = 50,000 / (L × R × F)
例1: 再接続アンチパターンがない「健全な」状態 — チャット一覧(リスナー1個、結果20件)+現在のチャットルーム(リスナー1個、結果50件)程度でL=2、平均R=35、F=1(再接続なし)だとすると:
C_max = 50,000 / (2 × 35 × 1) = 50,000 / 70 ≈ 714人
同時接続者約714人までは無料枠内で持ちこたえられる。
例2: 先ほど計算した再接続アンチパターンが混ざった状態 — 同じくL=2、R=35だが、セッション中に平均5回の再接続が発生(F=5)するとすると:
C_max = 50,000 / (2 × 35 × 5) = 50,000 / 350 ≈ 142人
リスナー再接続がたった1つ存在するだけで、無料枠で持ちこたえられる同時接続者数は714人から142人へ、5分の1にまで減ってしまう。 「うちのアプリはまだユーザーが数百人しかいないから無料枠で十分だ」という前提が、実際にはアンチパターン1つのせいで正午にもならないうちに崩れる理由がここにある。そして、無料枠はプロジェクトのデフォルトデータベース全体で共有されるという点も覚えておく必要がある — ステージング環境と本番環境が同じプロジェクトを使っている場合、開発者のホットリロードのテストですらこの上限を消費してしまう。
コストを削減する実践パターン
これまで扱った個別のコード修正に加えて、アーキテクチャレベルで適用できるパターンをまとめる。
- クライアントキャッシュを明示的に有効化する。
cloud_firestoreはデフォルトでオフラインパーシステンスがオンになっているが(モバイル基準)、キャッシュサイズとポリシーを明示的に確認し、リアルタイム性が不要な画面ではGetOptions(source: Source.cache)でサーバーへの往復自体をスキップする。
final cached = await docRef.get(const GetOptions(source: Source.cache));
- リスト画面には例外なくカーソルベースのページネーションを適用する。
limit()なしでコレクション全体を購読するコードは、コレクションが大きくなるほど初期読み取りコストも一緒に膨らんでいく。
Query query = FirebaseFirestore.instance
.collection('posts')
.orderBy('createdAt', descending: true)
.limit(20);
if (lastDocument != null) {
query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();
- 部分更新と非正規化でN+1を根本的に減らす。
.set()でドキュメント全体を上書きする代わりに.update()で変更されたフィールドだけを書き込み、頻繁に参照される関連データは前述の通り上位ドキュメントに一緒に保存する。 - リスナーのライフサイクルをウィジェットツリーではなく状態管理レイヤーに持たせる。
initStateでストリームを1回だけ生成するか、Riverpod/Blocのプロバイダーレベルでストリームをキャッシュして、再ビルドとリスナー再接続を切り離す。これが、この記事で扱ったアンチパターンの中で最もインパクトの大きい部分を根本から断つ方法だ。 - 読み取り中心・低変動なデータはFirestoreの外へ出す。 本当にリアルタイム同期が必要なデータ(チャットメッセージ、プレゼンス、共同編集状態)だけをFirestoreに残し、静的な設定値・商品カタログ・リーダーボードのように頻繁に読まれるがリアルタイムである必要のないデータは、Cloudflare Workers + D1(またはKV)のようなはるかに安価なバックエンドへ移すハイブリッド構成を検討する。Flutterアプリは、こうしたデータをFirestore SDKの代わりにREST呼び出しでWorkerのエンドポイントにリクエストすればよい。
Firestoreの代替サービスの料金比較: Cloudflare D1、Supabase Postgres
最後に、「これほど読み取りが多いなら、そもそも別のバックエンドの方が適しているのではないか」という問いに答えるため、代替候補2つの公式料金を確認した。
| 項目 | Firestore(Blaze) | Cloudflare D1 | Supabase Postgres |
|---|---|---|---|
| 無料読み取り上限 | 1日50,000件 | 1日500万行(Workers Freeプラン) | APIリクエスト自体は無制限(容量・性能の制限のみ存在) |
| 無料ストレージ容量 | 1 GiB | 5GB(アカウント全体) | 500MB |
| 有料読み取りの超過単価 | 10万件あたり$0.06 | Workers Paid($5/月)プランで月250億行まで込み、超過分は100万行あたり$0.001 | プランのアップグレードでストレージ・接続数を拡張(リクエスト単価での課金なし) |
| 有料書き込みの超過単価 | 10万件あたり$0.18 | Workers Paidプランで月5,000万行まで込み、超過分は100万行あたり$1.00 | 同上 |
| ネイティブのリアルタイムリスナー | サポートあり(WebSocketベースのストリーム、この記事で扱ったコスト構造の原因でもある) | 標準では非対応(Durable Objectsで自前実装は可能) | Realtime機能を提供(Postgresの論理レプリケーションベース、別途上限あり) |
数字だけ見るとD1の有料超過単価がFirestoreより圧倒的に安く見えるが(100万行あたり$0.001 vs 100万件あたり$0.60)、これは同一製品の比較ではないという点を押さえておく必要がある。D1はSQLの行(row)単位の課金であり、月$5のWorkers Paidプランへの加入が前提で、Firestoreが標準で提供するリアルタイムリスナー・オフライン同期・自動スケーリングをそのまま代替できるわけではない。Supabaseも同様にRealtime機能はあるものの、別途同時接続数・メッセージ数の上限が設けられており、Firestoreの.snapshots()と1対1で置き換えられるものではない。
実務上、合理的な結論はこうだ。チャットメッセージや共同編集のように本当にリアルタイム同期が必要な部分だけをFirestore(または同等のリアルタイムDB)に残し、読み取りは多いがリアルタイムである必要のない残りのデータをD1/Supabaseのような安価なバックエンドへ分離することだ。この分離1つだけでも、先ほど計算した「アンチパターンによる月$45〜$900」規模の無駄の大半を、そもそも発生させずに済ませられる。
最終チェックリスト
Flutter+Firestoreの組み合わせで請求書を管理するには、次の項目を順番にチェックすることを勧める。
-
.snapshots()の呼び出しがbuild()メソッドの中にインラインで書かれていないか(initStateまたはプロバイダーレベルに移したか) - リスト画面で項目ごとに別々の
get()を呼び出していないか(バッチ取得または非正規化で置き換えたか) -
Timer.periodic+.get()のポーリングが残っていないか(リアルタイムリスナーで代替可能か) - すべてのリスト/コレクション購読に
limit()とページネーションカーソルが適用されているか - ステージングと本番環境が同じFirebaseプロジェクト(=同じ無料枠)を共有していないか
- リアルタイム性が不要な読み取り中心のデータをFirestoreの外に分離する余地があるか
この6項目だけを確認しても、「コードを変えていないのに請求書が突然跳ね上がる」事故の大半は事前に防げる。