この記事は GMOインターネットグループ Advent Calendar 2025 11日目の記事です。こんにちは。GMOペパボ株式会社のyumuです。今回、リワード広告システムの開発で、スタンプデータの管理にDynamoDBを採用しました。「DynamoDBを使ってみたい!」という興味本位な気持ちもあって採用を決めたのですが、RDBの設計経験しかなかった私は、いくつかの落とし穴にハマることになりました。この記事では、DynamoDB設計で実際に遭遇した課題と、そこから学んだ設計のポイントを共有します。
なぜDynamoDBを選んだのか
私たちはハンドメイドECサービス「minne byGMOペパボ」を運営しており、今回リワード広告機能を追加することになりました。ユーザーがアプリ内で広告を閲覧すると、スタンプがもらえる仕組みです。
このスタンプデータの保存先として、DynamoDBを選択しました。理由は以下の通りです。
スタンプデータはユーザーIDに紐づくシンプルな構造で、NoSQLとの相性が良さそう読み書きのパフォーマンスが重要DynamoDBを実戦で使ってみたい
しかし、この「軽い気持ち」が後々、設計の甘さとして跳ね返ってくることになります。
RDB脳による設計ミス
DynamoDBのテーブルを作成する際、私はRDBと同じ感覚で設計を進めていました。
「とりあえずユーザーIDとスタンプ情報を保存できるテーブルを作って、必要になったらクエリすればいいか」
この考え方が大きな間違いでした。
クエリできない!
実装を進めて、「特定期間に獲得されたスタンプの一覧を取得する」というクエリを書こうとした時、気づきました。
DynamoDBでは、パーティションキーを指定せずに範囲検索することができない。
RDBであれば SELECT * FROM stamps WHERE created_at BETWEEN '2025-12-01' AND '2025-12-31' と書けば済む話ですが、DynamoDBではパーティションキーの指定が必須です。パーティションキーをuser_idに設定していたため、特定ユーザーのスタンプは取得できても、全ユーザーを横断して期間指定で取得することができませんでした。
意図せずScanになっていて、めちゃくちゃ遅い!
インデックスを設定して実装したつもりだったのですが、実際に動かしてみるとクエリの実行時間が著しく長いという問題に直面しました。
データ量が少ないうちは気にならなかったのですが、テストデータを増やすと、どんどん遅くなっていきます。ログを見てみると、意図に反してScanが実行されていたのです。Scanはテーブル全体をフルスキャンするため、データ量に比例して時間がかかります。
原因は、クエリ時のインデックス指定ミスでした。Queryのつもりで書いたコードが、実際にはScanになっていたのです。
結局、設計し直し
幸い実装を開始した直後に気づいたので大事には至りませんでしたが、アクセスパターンを洗い出して、カラムやLSI、GSIを設計し直すことになりました。
DynamoDB設計とRDB設計の決定的な違い
この失敗を通して、ようやく「DynamoDBはRDBとは根本的に設計思想が違う」ということに気づきました。具体的にどう違うのか、整理してみます。
正規化ではなく、アクセスパターン優先
RDBでは、データの重複を避けるために正規化を行い、テーブルを分割します。そして必要に応じてJOINで結合します。
しかしDynamoDBにはJOINという概念がありません。そのため、以下のような設計が求められます。
アクセスパターンを事前に洗い出すそのパターンに最適化した形でデータを配置する場合によってはデータの重複を許容する
つまり、「どんなデータを保存するか」ではなく、「どうやってデータを取得するか」から設計を始める必要があるのです。私が最初にやってしまった「とりあえず必要なデータを入れるテーブルを作る」というアプローチでは上手くいきませんでした。
QueryとScanの違い
DynamoDBには、データ取得の方法が2つあります。
⭐️ Query
パーティションキー(とソートキー)を指定して取得効率的で高速
⭐️ Scan
テーブル全体をスキャンして条件に合うものを取得非効率で遅いデータ量に比例して時間がかかる
私が実際に遭遇したのは、インデックス指定を誤ったことで意図せずScanが実行され、クエリの実行時間が著しく伸びてしまったというケースでした。
効率的なQueryを使うためには、適切なインデックス設計が不可欠です。
インデックス設計の制約
RDBでは、後からでも比較的自由にインデックスを追加できます。しかしDynamoDBでは、インデックスの種類ごとに厳格な制約があります。
⭐️ プライマリキー(パーティションキー + ソートキー)
テーブル作成時に決定変更不可
⭐️ LSI(ローカルセカンダリインデックス)
テーブル作成時にしか追加できないパーティションキーはテーブルのプライマリキーと同じで、ソートキーだけを変えたクエリに使用テーブルあたり最大5個
⭐️ GSI(グローバルセカンダリインデックス)
後から追加可能パーティションキーもソートキーも自由に設定できるテーブルあたり最大20個
この制約があるため、「後で必要になったらインデックスを追加すればいい」という考え方は通用しません。
実践:失敗から学んだ設計プロセス
失敗を経て、私が学んだDynamoDB設計のプロセスは以下の通りです。
Step 1:アクセスパターンを徹底的に洗い出す
RDB脳での考え方🙅♀️:「ユーザーとスタンプのデータを保存しよう。クエリは後で考えればいいや」
DynamoDB的な考え方🙆♀️:「どんな場面で、どんなクエリが必要になるか?を最初に列挙しよう」
リワード広告システムでは、以下のようなアクセスパターンを洗い出しました。
ユーザーIDをもとに、ユーザーが獲得したスタンプの一覧を取得ユーザーIDをもとに、ユーザーが特定期間に獲得したスタンプ数を取得ユーザーIDと広告IDをもとに、特定の広告のスタンプ獲得状況を取得スタンプ獲得日をもとに、特定期間に獲得されたスタンプの一覧を取得
この洗い出しが甘いと、後で「このクエリができない!」となってしまいます。
Step 2:プライマリキーの決定
洗い出したアクセスパターンから、最も頻繁に使われるクエリを基準に決定します。
パーティションキー:user_id(ユーザーごとにデータが分散される)ソートキー:timestamp(時系列でのソート・範囲検索が可能)
この設計により、「ユーザーIDで取得」「特定期間のスタンプを取得」といったクエリが効率的に実行できます。
Step 3:LSI/GSIの設計
プライマリキーだけでカバーできないアクセスパターンには、LSIやGSIを使います。
例えば、「スタンプ獲得日をもとに、特定期間に獲得されたスタンプの一覧を取得したい」というパターンがある場合は以下のようなGSIが必要です。
GSI:パーティションキーをdate、ソートキーをuser_idに設定
特にLSIはテーブル作成時にしか追加できないため、慎重に検討します。「後で必要になるかも?」と思ったら、最初に設定しておく方が安全です。
ただし、インデックスが増えると読み込み/書き込みキャパシティの消費量も増えるため注意が重要です。
実際の設計例
最終的に、以下のような設計になりました。
Column NameData TypePrimary KeyLSI#1GSI#1user_idNumberPartition KeyPartition KeySort Keyad_idStringSort KeydateStringPartition KeytimestampStringSort Key
この設計により、主要なアクセスパターンをカバーできるようになりました。
運用で気づいた落とし穴
GSIを設定しているのにインデックスが使われない
minneではRubyを使っており、DynamoDBのORMとしてDynamoidを採用しました。
Dynamoidでは、GSIを定義する際にprojected_attributes: :keys_onlyを指定している場合、DynamoDB側でGSIが設定されていても、Queryで使ってくれないことがありました。
class UserStamp
# (省略)
# NG
global_secondary_index hash_key: :date,
range_key: :user_id,
projected_attributes: :keys_only,
name: :date_user_gsi
# OK
global_secondary_index hash_key: :date,
range_key: :user_id,
projected_attributes: :all,
name: :date_user_gsi
end
この設定を:keys_onlyにしていたことが原因で、Scanが実行され、クエリの実行時間が異常に長くなってしまっていました。
パーティションキーなしでの範囲検索ができない
DynamoDBでは、パーティションキーを指定せずに範囲検索することができません。
例えば、「今日獲得されたスタンプを全ユーザー横断で取得したい」というケースを考えてみます。timestampをソートキーに設定していても、パーティションキー(user_id)を指定しない限り、Queryは使えません。
この問題に対して、私たちは日付用のカラムを追加し、それをパーティションキーとするGSIを作成するという対策を取りました。
# timestampとは別に、date(YYYY-MM-DD形式)カラムを追加
# GSIでdateをパーティションキー、user_idをソートキーに設定
UserStamp.where(date: '2025-12-15')
このように、範囲検索が必要な場合は、検索に使う粒度に合わせたカラムを用意し、それをパーティションキーとするGSIを設計時に用意しておくことが必要です。
開発中はこまめにログを確認する
開発中は、意図通りQueryが使われているのか、Scanになっているのかをログで確認する癖をつけましょう。
# ログレベルを設定
Dynamoid.config.logger.level = :debug
これにより、実行されたDynamoDBのAPIコール(Query/Scan)が確認できます。
まとめ:DynamoDB設計の心得
DynamoDBを使ってみて、痛い目に遭いながら学んだことをまとめます。
1. 設計は「データ」ではなく「クエリ」から始める
RDBの正規化思考を一旦忘れて、「どうデータを取得するか」を最優先に考えましょう。アクセスパターンの洗い出しが甘いと、後で必ず後悔します。
2. インデックス設計は慎重に、そして早めに
特にLSIはテーブル作成時にしか追加できません。「後で必要になるかも」と少しでも思ったら、最初から設定しておくことをお勧めします。
3. QueryとScanの違いを意識する
インデックスを適切に設定し、Queryで取得できるようにすることが、パフォーマンスとコストの両面で重要です。ORMを使う場合は、意図せずScanになっていないか、ログで確認する習慣をつけましょう。
おわりに
「使ってみたい」と軽い気持ちで始めたDynamoDBでしたが、RDBとは全く異なる設計思想に戸惑いました。しかし、その特性を理解して適切に設計すれば、非常に強力なデータベースです。
今回の経験が、これからDynamoDBを使おうとしている方の参考になれば幸いです。そして、もし同じような失敗をしている方がいたら、「自分だけじゃなかった」と安心してもらえればと思います。
最後まで読んでいただき、ありがとうございました!