こんにちは!株式会社Schoo(以後スクー) 新卒2年目の @hiroto_0411です!
私の所属するチームでは現行システムのリプレイスによる「次世代プラットフォームの構築」を行っています。
今回の記事では、弊社がシステムリプレイスにおいて積極的に導入しているDynamoDBについての基礎、テーブル設計における考え方、DynamoDBを使った実装の統合テスト戦略を整理してみました!
この記事で分かること
- DynamoDBの概要
- 特徴
- PK,SK,GSI
- DynamoDBのテーブルを設計するときの基本的な考え方
- DynamoDBを使ったAPIの統合テスト戦略(Testcontainersを利用した統合テスト)
今回の記事内容は「ARI Tech Summit LT祭り」で登壇した内容をテックブログにしたものになります。
DynamoDBとは
DynamoDB(Amazon DynamoDB)は、AWS が提供するフルマネージドな NoSQL データベースサービスです。
特徴
公式サイトで挙げられている特徴は以下になります。
-
Key-Value + ドキュメントモデル
- さまざまなユースケースをサポートするために、key-value モデルとドキュメントデータモデルの両方をサポートしている
-
サーバーレス
- サーバーレスであり、ソフトウェアの管理、インストール、保守、運用は必要なく、バージョンも気にしなくて良い
-
1桁ミリ秒のパフォーマンス
- あらゆる規模で 1 桁ミリ秒のパフォーマンスを実現することを目的として構築されており、100 ユーザーの場合でも 1 億ユーザーの場合でも、アプリケーションで一貫した 1 桁ミリ秒のパフォーマンスを出せる
主な注意点
上記で書いたように強力ですが、苦手な部分もあるため実際に導入する際には考慮が必要になります。
-
クエリの自由度が低い
- SQLのような複雑なクエリ(JOIN、GROUP BY、IN、サブクエリなど)は使えない
- 事前にアクセスパターンを予測して、GSIやLSIを適切に設計する必要がある
-
テーブル設計の考え方がRDBとは違うため、慣れる必要がある
- アクセスパターンに基づいた考え方が必要
-
トランザクションは不得意
- トランザクションAPI(TransactWriteItemsなど)はありトランザクションを実現することはできるが、制限が多く処理も高コスト
これらの特徴から柔軟性と高パフォーマンスを必要とするデータを入れたいときの選択肢になります。
DynamoDBのテーブル設計
ここからは、DynamoDBのテーブル設計をする際に考えるポイントをまとめてみます。大きく以下の点を意識して設計することが多いです。
- アクセスパターンを整理しPK,SK,GSIを設計する
- 単一テーブル設計にするか、テーブルを分割するかを考える
なぜこれらを意識することが大切なのか説明していこうと思います!
PK, SK, GSIの役割
DynamoDBのテーブル設計を実践するには、PK (パーティションキー)、SK (ソートキー)、そして GSI (グローバルセカンダリインデックス) の3つの要素を理解することが不可欠です。これらは、RDBのテーブルやインデックスとは異なる、DynamoDB独自のアクセスパターンを実現するために重要となっています。
LSIなどもありますが、これら3つに比べると使うケースも限られると思うので、今回は省略しています。
PKとSK
この2つを合わせて、テーブル内の各項目を一意に識別することができます。
- PK (パーティションキー): データの物理的な保存場所を決定するキーです。DynamoDBはPKのハッシュ値に基づいてデータを複数のパーティション(サーバー群)に分散させます。
- SK (ソートキー): 同じパーティションキーを持つデータ群の中での並び順を決定するキーです。これにより、特定のPKを持つデータに対して、範囲指定(例:「Aで始まるもの」「2025-07-01以降」など)が可能になります。
住所と部屋番号を例に少し抽象的な例を考えてみます。
PK: 建物の「住所」に例えられます。DynamoDBはこの住所を頼りに、どのサーバーにデータがあるかを瞬時に特定します。
SK: その建物の中の「部屋番号」に例えられます。
Query操作は、「この住所(PK)の建物に行って、この部屋番号(SK)の情報をください」という命令をするイメージです。
PKとSKの具体例
今度はより具体的な例を見てみます。特定の授業(Course)に関するデータ(授業情報と、誰がブックマークしたか)をまとめて管理するケースを考えます。
PK: 授業ID
SK: データの種類
や ユーザーID
を使って、並び順や識別に使います。
PK (授業ID) |
SK (項目種別#識別子) |
データの内容 |
---|---|---|
COURSE#101 | METADATA |
{title: "DynamoDB入門", instructor: "Sato"} |
COURSE#101 | BOOKMARK#U001 |
{userName: "Taro Yamada", createdAt: "..."} |
COURSE#101 | BOOKMARK#U777 |
{userName: "Hanako Sato", createdAt: "..."} |
COURSE#101 | BOOKMARK#U999 |
{userName: "Jiro Suzuki", createdAt: "..."} |
COURSE#202 | METADATA |
{title: "Go言語入門", instructor: "Tanaka"} |
PKとSKの役割 📂
-
PK:
PK
=COURSE#101
を指定すると、DynamoDBは「DynamoDB入門という授業」を直接見つけに行きます。このパーティションには、授業自体の情報と、3人のユーザーによるブックマーク情報がまとめて保存されています。 -
SK: フォルダの中のデータは、SKによって順に並んでいます。
BOOKMARK#U001
BOOKMARK#U777
BOOKMARK#U999
METADATA
この設計で可能になるクエリ例 🔎
この設計にすることで、以下のような問い合わせが非常に高速に実行できます。
-
授業に関する全情報(授業情報+全ブックマーク)を取得する:
PK
=COURSE#DDB101
でクエリ
→ 授業ページにタイトルやブックマークしたユーザー一覧などをまとめて表示する際に使える -
授業情報だけを取得する:
PK
=COURSE#DDB101
かつSK
=METADATA
でクエリ
→ 授業のタイトルなど、基本情報だけが欲しい場合にピンポイントで取得できる -
特定のユーザー(U777さん)がこの授業をブックマークしているか確認する:
PK
=COURSE#DDB101
かつSK
=BOOKMARK#U777
でクエリ
→ ユーザーがページを開いた時に「ブックマーク済み」かどうかを判断できる
GSIの追加 🔎
ここまでの設計では、「特定の授業」を起点にした検索(PK
= COURSE#...
)は非常に高速です。
しかし、もし「特定のユーザーがブックマークした授業の一覧を、新しい順に取得したい」という新しいアクセスパターンが出てきたらどうでしょう?
現在のPKでは、この検索は効率的に行えません。テーブル全体をスキャン(Scan
)する必要があり、データ量が増えると遅く、高コストになります。
そこで登場するのが GSI です。
GSIは、元のテーブルとは異なるPKとSKを持つ、いわばもう一つの検索帳を仮想的に作成する機能です。DynamoDBでは実際に射影が作成されます。
今回の新しい要求に応えるため、以下のようなGSIを設計します。
-
GSIの設計:
-
GSIのPK:
user_id
(ユーザーで検索したいため) -
GSIのSK:
createdAt
(ブックマークした日時で並べ替えたいため)
-
GSIのPK:
GSIについても抽象的な例を見てみましょう。
PK,SKのみでのQuery操作は「住所録」を使って、住所が分かっている場合に特定の部屋を探す方法でした。
(「この住所(PK)の建物に行って、この部屋番号(SK)の情報をください」)
しかし、もし「『田中さん』という契約者が、どの住所の何号室に住んでいるか知りたい」という、全く逆の探し方がしたくなったらどうでしょう?
「住所録」は住所順に並んでいるため、「田中さん」を探すには全ページをめくる(= Scan)しかありません。
そこで登場するのがGSIです。これは、「契約者名簿」という、別のPK,SKを持つ台帳をもう1冊用意するようなものです。
元のテーブル(住所録):
PK: 住所
SK: 部屋番号
GSI(契約者名簿):
GSIのPK: 契約者名
GSIのSK: 契約日
この「契約者名簿(GSI)」を使えば、「田中さん(GSIのPK)はどこに住んでいるか?」というQueryが可能になります。DynamoDBは「契約者名簿」を引いて、「田中さん」が住んでいる「住所」と「部屋番号」を瞬時に見つけ出してくれます。
このようにGSIは、元の探し方(住所→部屋)を維持したまま、全く新しい探し方(契約者名→部屋)を追加するための仕組みです。
GSIの具体例
GSIについてもより具体的な例を見てみましょう。
GSIで利用するために、Bookmark
項目にuser_id
とcreatedAt
属性を追加します。
PK (授業ID) |
SK (項目種別#識別子) |
user_id (GSI-PK) |
createdAt (GSI-SK) |
データの内容 |
---|---|---|---|---|
COURSE#DDB101 | METADATA |
(null) | (null) | {title: "DynamoDB入門"} |
COURSE#DDB101 | BOOKMARK#U001 |
U001 | 2025-07-29T10:00:00Z | {userName: "Taro Yamada"} |
COURSE#DDB101 | BOOKMARK#U777 |
U777 | 2025-07-30T11:00:00Z | {userName: "Hanako Sato"} |
COURSE#GOL202 | METADATA |
(null) | (null) | {title: "Go言語入門"} |
COURSE#GOL202 | BOOKMARK#U001 |
U001 | 2025-07-30T12:00:00Z | {userName: "Taro Yamada"} |
GSIで可能になるクエリ例 🔎
このGSIを使うと、以前は不可能だった検索が非常に高速に実行できます。
-
要求: ユーザー
U001
がブックマークした授業を、新しい順に取得したい。 -
クエリ方法:
GSIをターゲットに、user_id
=U001
でクエリを実行し、降順ソートを指定します。 -
結果:
-
COURSE#GOL202
のブックマーク情報 (createdAt
: 2025-07-30...) -
COURSE#DDB101
のブックマーク情報 (createdAt
: 2025-07-29...)
-
このように、GSIを追加することで、元のテーブルのキー構造を維持したまま、全く新しい検索を効率的に実現できます。
単一テーブル設計とは
次に、スキーマ設計について考えてみます。
DynamoDBでは、すべてのデータを1つのテーブルに格納する「単一テーブル設計」がベストプラクティスとして知られています。(1アプリに対して1テーブル)
この設計のポイントは以下になります。
- データ構造よりも「アクセスパターン」を中心に考える
- NoSQL データベースでは、SQL のようなクエリの柔軟性が低いため、スキーマを構築する前に、アプリケーションが必要とするデータアクセスパターン(読み取り、書き込み、集計など)を明確に特定することが重要です
- 1テーブルに異なる種類のエンティティ(例:授業、ユーザー、ランキング)を共存させ、RDBのようにエンティティごとにテーブルを分けるのではなく、「必要なアクセスが効率的にできる構造か?」を最優先する
単一テーブル設計のメリット
- 関連する複数の種類のデータを一度のQuery操作で効率的に取得できる
- 先程注意点として挙げた通り、DynamoDBでは複数のテーブルをJOINすることなどができないですが、単一テーブル設計とし、同じパーテーショーンキーを付与することで1回のQuery操作で取得することができます。
ex.)授業とお気に入り数をDynamoDBで管理しているケース。これが2つのテーブルに分かれていると2回Queryを実行することになります。
PK (Partition Key) |
SK (Sort Key) |
属性1 | 属性2 | 属性3 |
---|---|---|---|---|
course#123 |
meta |
course | DynamoDB入門 | |
course#123 |
bookmark_count |
count | 10 | |
course#456 |
meta |
course | AWS入門 | |
course#456 |
bookmark_count |
count | 5 |
- オペレーション負担が軽減される
- 複数のDynamoDBテーブルがあると、設定、監視、バックアップなどをそれぞれのテーブルに対して行うことになり複雑になってしまいます。
複数テーブル設計をした方が良い場合
- 単一テーブル設計のメリットが適応しない場合
- 大量のデータを効率的にスキャンして計算を行うような分析を行いたい場合
- データをエクスポートして分析する場合などは、エンティティごとに別のテーブルに記録しテーブル全体をエクスポートした方が良い場合が多いと思います。
- 「読み込みは多いが書き込みは少ないデータ」と「読み書きが頻繁なデータ」のようにアクセスパターンが全く違う場合
- テーブルを分けることで、それぞれに合わせたスループット設定の最適化やコスト管理がしやすくなります。
ユースケースを考え単一テーブル設計と複数テーブル設計を選択するのが大切になります!
弊社でのユースケース
スクーでは、特にRDSに対してクエリを実行するとレイテンシが大きくなりそうなデータを、バッチ処理などで事前に集計しDynamoDBへ保存するユースケースが多いです。また、揮発性の有無でDynamoDBとRedisを使い分けています。
DynamoDBを使うケース
分析に使われる可能性があったり、整合性を保ちたい永続的なデータ(ランキング集計データ、ブックマーク)を記録しています。
また、生放送授業のコメントなど増えやすいデータもDynamoDBを使っています。
ex.) ブックマーク数をDynamoDBで保存する場合のイメージ
RDSのテーブル設計
カラム名 | 型 | 説明 |
---|---|---|
id |
INT (PK) | 主キー |
user_id |
INT | ブックマークしたユーザーID |
course_id |
INT | 対象の授業(course)のID |
created_at |
DATETIME | ブックマーク登録日時 |
deleted_at (nullable) |
DATETIME | ブックマーク解除時刻(論理削除) |
このテーブルからアクセスごとにブックマーク数の集計を行うとレイテンシが大きくなってしまうので、ブックマーク数の集計データを以下の形でDynamoDBに保存しておきます。
DynamoDBのテーブル設計
属性名 | 値の例 | 役割 |
---|---|---|
id |
courseBookmark#123 |
PK (パーティションキー) |
key1 |
"courseBookmark" |
GSI1-PK / GSI2-PK |
key2 |
`` | 属性 (Attribute) |
num |
58 |
GSI1-SK |
timestamp |
1753896806000 |
GSI2-SK |
※他のデータも記録するため、属性名が汎用的なものになっています。
Redisを使うケース
揮発性があって問題ないデータはRedisに記録しています。
- ランキング生成する際に必要な一時的なデータ
- Sorted Setなどで集計を効率的に進めたい時など
- ユーザーが通知を開いた日時
など
テスト戦略
スクーではDynamoDBを使った実装のテスト方針は以下のようにしています。
テストの目的に合わせて、使うツールやライブラリを使い分けています。
infra層の単体テスト
以下の実装を参考にして、mockを用意してテストします。
interface層の単体テスト
アプリケーションでの一連の流れを確認したいので、Testcontainersを使用してテストプロセスごとにDynamoDBのコンテナを起動して、実際のデータのやり取りをしてテストします。
テストコンテナについての説明は以下でしているので参考にしてみてください。
Testcontainersを使った実装例
func InitTestDynamoDB(ctx context.Context, tableName string) (*dynamodb.Client, *tcdynamodb.DynamoDBContainer) {
ctr, err := tcdynamodb.Run(ctx, "amazon/dynamodb-local:2.2.1")
if err != nil {
panic(fmt.Errorf("failed to start container: %s", err))
}
host, err := ctr.ConnectionString(ctx)
if err != nil {
panic(err)
}
log.Printf("dynamohost: %v", host)
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
panic(fmt.Errorf("load default config: %w", err))
}
client := dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) {
o.BaseEndpoint = aws.String(fmt.Sprintf("http://%v", host))
o.Credentials = &credentials.StaticCredentialsProvider{
Value: aws.Credentials{
AccessKeyID: "dummy",
SecretAccessKey: "dummy",
},
}
})
// DynamoDBコンテナが完全に準備されるまで待機
if err := waitForDynamoDBReady(ctx, client); err != nil {
panic(fmt.Errorf("failed to wait for DynamoDB ready: %v", err))
}
if err := createTable(ctx, ctr, client, tableName); err != nil {
panic(fmt.Errorf("failed to create table: %v", err))
}
return client, ctr
}
まとめ
今回はDynamoDBについてまとめてみました!DynamoDBを使用する場合は以下を意識することが大切だと思います。
- データ構造よりもアクセスパターンを中心に考える
- アクセスパターンを整理したらPK,SK,GSIとテーブル分割を、それぞれのデータの特徴をもとに決める
- テストは目的に合わせてライブラリを使い分ける
皆さんも特徴とポイントを意識しながら、高パフォーマンスなDynamoDBの導入を検討してみてはいかがでしょうか!
参考
Schooでは一緒に働く仲間を募集しています!