DynamoDBは地雷原だった|Firebase→AWS移行の理想と現実

IT開発

以前、こんな記事を書きました。

Firebase + Algoliaの限界:個人開発でAWS移行した2理由と体験談
Firebase + Algoliaの限界を実体験で解説!複雑検索の制約と運用コスト増でAWS RDS移行した2理由。個人開発の適材適所とハマりポイントも。

「Firebase + Algoliaは限界!AWSに移行すればCDKでインフラをコード管理できて快適!」と意気揚々と書いた人間です。

あれから実際にAWS(DynamoDB + CDK + AppSync)でクイズSNSアプリのバックエンドを開発してきました。その結果どうなったか。

DynamoDBは地雷原でした。

今回は、DynamoDBのシングルテーブル設計で実際にハマった罠と、「正直Firebaseで良かったんじゃないか」という気持ちが再浮上してきた話を正直に書きます。

NoSQLは「ポストRDB」だと思っていた

DynamoDBを選んだ時、僕はNoSQLをこう理解していました。

「RDBは堅牢だけど堅い。NoSQLはその堅牢さを柔軟性で和らげた、進化系のデータベースだ」と。

完全に間違っていました。

DynamoDBは確かに柔軟です。スキーマレスで、とりあえずデータを突っ込める。でも「開発しやすい土壌」がない

ここで言う土壌とは、テーブル結合(JOIN)、複合クエリ、「WHEREで絞ってからLIMITする」みたいな当たり前の動作のことです。RDBなら何も考えずにできることが、DynamoDBでは「そもそもできない」か「やり方を根本から変える必要がある」。

RDBは厳しいけどレールがある。DynamoDBは自由だけど荒野。自由って聞こえはいいけど、道がないだけでした。

DynamoDB GSI変更の地獄

前の記事で「CDKでインデックスをコード管理できる!Firestoreみたいにコンソールで手動作成しなくていい!」って書きました。

それは事実です。コードで管理できます。ただし、デプロイが地獄です。

「1回のデプロイでGSI変更は1つだけ」制約

DynamoDBには「1回のUpdateTableでGSIの追加・削除は1つだけ」というサービスレベルの制約があります。追加1つと削除1つを同時にやることすらできません。

つまり、GSIを1つ作り替えるだけでこうなります。

  1. 新GSIを追加してデプロイ(1回目)
  2. アプリ側のクエリを新GSIに切り替えてデプロイ
  3. 旧GSIを削除してデプロイ(2回目)

不要なGSIを3つ消したい時は?3回に分けてデプロイです。

実際に僕がやらかしたのは、PRを2つ同時にマージしてデプロイ失敗。コンフリクト解消のミスで削除したはずのGSIが復活。dev環境とprod環境でGSIの状態がズレる。結局、合計約10回のデプロイを実行して復旧しました。

対策:CIでGSI変更数をブロックする

この経験から、GitHub Actionsでcdk diffを実行して、GSIの追加・削除が2つ以上含まれていたらマージをブロックするCIの導入を予定しています。dev環境はローカルからcdk deployで復旧できるからまだいいけど、prod環境で同じ事故が起きたら目も当てられません。

DynamoDBを使うなら、「1回1GSI」を人間が覚えておくのではなく、仕組みで防ぐ必要があります。

Firestoreのインデックスにはこんな制約なかった

ここで思い出してほしいんですが、Firestoreの複合インデックスは個別に追加・削除できます。「1回に1つだけ」なんて制約はありません。単一フィールドインデックスに至っては自動作成です。

「Firestoreのインデックス管理が面倒でAWSに来たのに、DynamoDBのGSI管理の方がよっぽど地獄だった」という、見事なオチがつきました。

FilterExpressionとlimitの罠

App Storeの審査提出直後に発見したバグです。言語指定でフィードを取得すると、8件あるはずの投稿が2件しか表示されない。

原因:FilterExpressionはlimitの「後」に動く

DynamoDBのGSI(LanguageIndex)のパーティションキーがlanguageだけだったため、language="ja"で検索するとUSER・QUIZ・FEED・ROUNDなど全種類のレコードが返ってきます。シングルテーブル設計なので当然です。

FEEDだけ欲しいので、FilterExpressionbegins_with(PK, "FEED#")を指定していました。ここに罠があります。

// DynamoDBの動作
limit: 20で20件取得 → その中にFEEDレコードが2件しかない → 結果2件

// RDB(SQL)なら当然こう
WHERE type = 'FEED' AND language = 'ja' LIMIT 20 → FEEDが20件返る

DynamoDBのFilterExpressionは、limitで取得した後にフィルタする。RDBのWHERE句とは根本的に動作が違います。

レコードが増えれば増えるほどFEEDの割合が下がるので、データが増えるほど結果が少なくなる。成長するほど壊れるバグです。

シングルテーブル設計の落とし穴

DynamoDBでは「シングルテーブル設計」が推奨されています。1つのテーブルにUSER、QUIZ、FEEDなど全種類のレコードを入れて、パーティションキー(PK)とソートキー(SK)で区別する設計です。

この設計自体は理にかなっているんですが、GSIを作ると別種のレコードが混在する問題が発生します。languageというフィールドはFEEDにもUSERにもQUIZにもある。GSIのパーティションキーをlanguageにすると、全部まとめて返ってくる。

そこにさっきのFilterExpression + limitの罠が組み合わさると、「公式推奨の設計パターンに従っているのに、データが増えるほど壊れる」という最悪の状況になります。

解決策:2025年11月の複合パーティションキー

2025年11月にDynamoDBに追加されたMulti-attribute composite keysで解決しました。GSIのパーティションキーに最大4属性を指定できる機能です。

// Before: languageだけ → 全種類混在
GSI PK: language
GSI SK: createdAt

// After: language + PK の複合キー → FEEDだけ返る
GSI PK: [language, PK]
GSI SK: createdAt
→ language="ja" AND PK="FEED#GLOBAL" でFEEDだけが返る
→ FilterExpression不要 → limitがそのまま結果件数に

CDKだとこう書きます。

table.addGlobalSecondaryIndex({
  indexName: 'FeedLanguageIndex',
  partitionKeys: [  // ← partitionKey ではなく partitionKeys(複数形)
    { name: 'language', type: dynamodb.AttributeType.STRING },
    { name: 'PK', type: dynamodb.AttributeType.STRING },
  ],
  sortKey: {
    name: 'createdAt',
    type: dynamodb.AttributeType.STRING,
  },
  projectionType: dynamodb.ProjectionType.ALL,
});

これで解決はしたんですが、2025年11月まではこの機能がなかったんです。つまりそれまでのシングルテーブル設計では、ja#FEEDみたいな文字列を自前で結合してパーティションキーにする必要があった。公式推奨の設計パターンと、提供されている機能が噛み合っていなかった。

そして当然、このGSIの入れ替えにも複数回のデプロイが必要でした。

冷静に考えてFirebaseで良かったんじゃないか説

ここまで読んで「いや、お前が前の記事でAWS推してたじゃん」と思った方、その通りです。

前の記事での主張を振り返ってみます。

「Algoliaの運用が辛い」→ 本当に?

前の記事ではAlgoliaのダッシュボード手動設定が辛い、バージョン管理できないと書きました。その代替としてOpenSearchをCDKで管理する構成を挙げました。

でも冷静に考えると、AlgoliaにもCLIはあります。ちゃんと作り込めば設定をコードで管理できたはずです。そして現時点でOpenSearchはまだ使ってすらいません

移行の重要な理由だったはずの「検索基盤のコード管理」は、移行しなくても実現できた可能性があります。

IAMのごちゃごちゃ問題

Firebaseならfirebase use stagingで環境切り替えが一発です。デプロイもfirebase deployで完了。

AWSでは環境ごとにIAMロール・ポリシーを設定して、dev/staging/prodでアクセス権限を分離して、デプロイ時にどのプロファイルを使うか意識しなければなりません。

これがセキュリティとして意味があるのか、個人開発の規模だとただ面倒なだけなのか、正直まだ整理できていません。「お作法としては正しいけど、一人で開発してるのにこの管理コスト必要?」という気持ちが消えません。

それでもAWSを選んだことは間違いじゃない(と思いたい)

ここまでDynamoDBとAWSの辛さを書いてきましたが、AWSにしかないメリットも確かにあります。

  • CDK + AppSync + Cognitoで認証・API・インフラが一元管理できる
  • AppSyncのVTLリゾルバーでバックエンドロジックをサーバーレスに書ける
  • プロダクトが成長した時のスケーラビリティはFirebaseより上
  • インフラ構成がすべてGitで追跡できるのは、長期運用では確実に効いてくる

Firebaseは「今すぐ楽」、AWSは「将来楽になるはず」。問題は、その「将来」が来る前にDynamoDBの地雷で消耗していることです。

今が辛いだけで、判断自体は間違ってない。そう思いたいです。

まとめ:RDBは先払い、DynamoDBは後払い

今回の経験で強く感じたのは、RDBとDynamoDBの本質的な違いです。

RDB:最初にスキーマ設計やマイグレーション管理が面倒。でもちゃんと作れば、JOIN、複合クエリ、WHERE→LIMITの当たり前の動作が保証される。先に苦労して、後は安心。

DynamoDB:最初はスキーマレスで楽。でもFilterExpressionの罠、GSI変更の地獄、シングルテーブル設計と機能の不一致が後から出てくる。先に楽して、後から地雷。

個人開発で複雑なクエリが必要なら、正直RDB(PostgreSQLやMySQL)の方が幸せかもしれません。DynamoDBの強みはスケーラビリティとレイテンシの安定性であって、開発体験の良さではない。これは使ってみないと分からなかった。

ちなみにAWSにもRDS(PostgreSQL/MySQL)はあります。DynamoDBの罠を考えると最初からRDSにすれば良かったのでは?という話ですが、RDSは最低でも月額$20〜50かかる。DynamoDBはPay-per-requestなら少量利用ではほぼ無料です。個人開発でユーザーがまだ少ない段階だと、このコスト差は地味に効く。安いから選んだDynamoDBで地雷を踏む、という皮肉な構図です。

でも、もう選んじゃったので走り続けます。次にGSIを変更する時は、もう少しうまくやれるはず。たぶん。

コメント

タイトルとURLをコピーしました