DynamoDBはアクセスパターンから設計する:シングルテーブルとGSIの決め方
RDB思考のままDynamoDBを設計すると必ず詰まる。アクセスパターン起点のシングルテーブル設計、PK/SKとGSIの決め方、コスト判断をClaude Codeと一緒に進める手順。
「とりあえずユーザーテーブル作って、あとはJOINで何とかしよう」
RDBの感覚でDynamoDBを触り始めた僕は、この一言で半日溶かしました。users テーブルを作り、orders テーブルを作り、いざ「ユーザーごとの注文一覧」を出そうとして手が止まる。JOINがない。仕方なく Scan で全件読んで JavaScript 側で絞ったら、ローカルでは一瞬なのに、ダミーデータを1万件入れた瞬間にもっさりし始めました。
DynamoDBは「スキーマを考えなくていいDB」だと思われがちです。でも実際は逆で、最初に決めるキー設計がそのまま設計書になります。後から自由な条件で検索する、という逃げ道がほぼない。だから設計の順番を間違えると、コストもパフォーマンスも一気に崩れます。
この記事では、僕がやらかした失敗を踏まえつつ、アクセスパターンから設計を起こすやり方を書きます。Claude Code には「テーブル作って」ではなく「アクセスパターンを表にして」と頼む。この順番にするだけで、出てくる設計の質がはっきり変わりました。
この記事の要点
- DynamoDBは先にアクセスパターン(アプリが実際にやる読み書き)を洗い出し、それに合わせてキーを決める。RDBのように「テーブルを正規化してから考える」は逆順。
Queryはパーティションキー(PK)の等価条件が必須。「好きな列で検索」はできないので、PKとSK(ソートキー)で読み方を先に固定する。- PKでは読めない切り口が出たら GSI(グローバルセカンダリインデックス) で別の読み口を足す。ただし結果整合で、後付けの万能薬ではない。
- 1テーブルに複数の種類を詰めるシングルテーブル設計は強力だが難度も高い。小さく始めるなら用途別に分ける設計でいい。
- 課金は新規ならオンデマンドが始めやすい。定常負荷が読めるならプロビジョンドでコストを抑える。Claude Codeには読み書き量とピークを渡して判断させる。
Claude Codeに「テーブル作って」と言ってはいけない
DynamoDBで最初に覚える言葉は「アクセスパターン」です。難しい話ではなくて、アプリが画面やAPIから実際にやる読み書きの形のこと。たとえばこんな粒度です。
- プロジェクトのタスク一覧を開く
- タスクIDで1件だけ完了にする
- ユーザーのログインセッションを7日で消す
- Webhookの同じイベントを二重処理しない
このリストが設計の出発点です。RDBなら「まずテーブルを正規化して、あとはクエリで何とでもなる」で済みます。DynamoDBは逆。読み方を先に決めて、その読み方が成立するようにキーを置く。
だからClaude Codeへの最初の一手は、コード生成ではありません。設計の棚卸しです。
このアプリのDynamoDB設計をレビューしてください。まだコードは要りません。
アクセスパターン:
- プロジェクトごとにタスクを一覧表示する
- タスクIDで1件更新する
- ユーザーセッションは7日で期限切れにする
- Webhookイベントは同じeventIdを二重処理しない
出力してほしいもの:
1. アクセスパターン表(操作 / 頻度 / 必要な読み書き)
2. PK/SK案
3. Queryで読めるもの・読めないもの
4. GSIが必要になる箇所と、その理由
5. 条件付き書き込みが必要な箇所
6. ホットパーティションとコストのリスク
ここでいきなりCDKやLambdaを書かせないのがコツです。出力に Scan が何度も出てきたら、それは「設計がまだ固まっていません」というサインだと思ってください。Scan はテーブル全件を舐める操作で、データが増えるほど遅く、高くなります。
PKとSKの決め方:読み方からキーを逆算する
Query のルールはひとつだけ覚えれば十分です。PKは必ず等価指定(PK = 〇〇)、SKは範囲や前方一致で絞れる。これだけ。
例として、さっきのアクセスパターンを「単一テーブル寄り」で1枚に並べてみます。テーブルは1つですが、扱う種類はプロジェクト・タスク・セッション・Webhookに限定します。
ClaudeCodeLabDemo
PK SK entityType
PROJECT#alpha META Project
PROJECT#alpha TASK#task-001 Task
PROJECT#alpha TASK#task-002 Task
USER#u-001 SESSION#s-001 Session
WEBHOOK#stripe EVENT#evt_001 WebhookEvent
読み方:
- プロジェクトのタスク一覧 : PK = PROJECT#alpha AND begins_with(SK, "TASK#")
- ユーザーのセッション確認 : PK = USER#u-001 AND begins_with(SK, "SESSION#")
- Webhook重複防止 : attribute_not_exists(PK) の条件付きPut
ポイントは、SK に TASK# や SESSION# といった**接頭辞(プレフィックス)**を付けていること。これで同じPKの中に種類の違うアイテムを混ぜても、begins_with(SK, "TASK#") でタスクだけを取り出せます。プロジェクトのメタ情報(SK = META)とタスク一覧を、必要なら1回の Query でまとめて読むこともできる。これがシングルテーブル設計の効きどころです。
RDBの正規化に慣れているほど、ここで タスクテーブル と プロジェクトテーブル を分けたくなります。でもDynamoDBでは、よく一緒に読むものは同じPKに寄せるのが基本方針になります。
GSIは「別の読み口」を増やす窓口
ここまでの設計には穴があります。「担当者ごとに、自分のタスクを横断で見たい」という要求が来たら詰まる。タスクは PROJECT#alpha の下にぶら下がっているので、ownerId を起点に読む手段がないんです。
RDB脳だと、つい Scan して FilterExpression で ownerId を絞りたくなります。これは罠です。FilterExpression は読み取った後に捨てるフィルタなので、課金対象は読んだ全量。WHERE句の代わりにはなりません。
ここで出番なのが GSI(グローバルセカンダリインデックス)です。AWS公式のUsing Global Secondary Indexesでは、GSIを「ベーステーブルとは別のプライマリキーでデータを並べ替えたもの」と説明しています。つまり、同じデータに対してもう一つ別の読み口を開ける機能です。
担当者軸で読みたいなら、こういうGSIを足します。
| 項目 | ベーステーブル | GSI(OwnerIndex) |
|---|---|---|
| パーティションキー | PK(例: PROJECT#alpha) | ownerId(例: masa) |
| ソートキー | SK(例: TASK#task-001) | updatedAt |
| 読める切り口 | プロジェクト配下の一覧 | 担当者ごとの最新順 |
GSIで知っておくべき性質を、公式ドキュメントから3つだけ押さえます。
- 結果整合(eventually consistent)。ベーステーブルへの書き込みはGSIへ非同期で反映されます。普通は1秒未満ですが、書いた直後にGSIを読むと古い結果が返ることがある。「書いてすぐ厳密に読む」用途には向きません。
- 射影した属性しか取れない。GSIをQueryしても、ベーステーブルの非キー属性は自動では引けません。必要な列は射影タイプ(
KEYS_ONLY/INCLUDE/ALL)で明示します。全部欲しいならALLですが、その分ストレージと書き込みコストが増えます。 - 疎なインデックス(sparse index)になる。GSIのキー属性を持たないアイテムはGSIに載りません。これは逆手に取れて、「未処理のものだけ」にフラグ属性を付ければ、GSIに未処理分だけが並ぶキューのように使えます。
GSIは便利ですが、「後から何でもGSIで解決できる」と考えると設計が崩れます。読み口が増えるたびに書き込みコストが乗る、という前提で足してください。
コピペで動く最小実装(DynamoDB Localで完結)
説明より動かすほうが早いです。本物のAWSアカウントは不要で、DynamoDB Local だけで一通り試せます。まずローカルを起動します。
# docker-compose.yml
services:
dynamodb-local:
image: "amazon/dynamodb-local:latest"
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
ports:
- "8000:8000"
volumes:
- "./docker/dynamodb:/home/dynamodblocal/data"
working_dir: /home/dynamodblocal
起動とダミー認証情報の設定、テーブル作成までをまとめます。ローカルでも SDK/CLI はリージョンと認証情報を要求するので、ダミー値を入れます。本番で叩く前に --endpoint-url http://localhost:8000 が付いているかを必ず確認してください。これを付け忘れて本番に作りかけた、というのは僕の実話です。
docker compose up -d
export AWS_ACCESS_KEY_ID=fakeMyKeyId
export AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey
export AWS_REGION=us-west-2
# ベーステーブル + 担当者軸のGSIを同時に作る
aws dynamodb create-table \
--table-name ClaudeCodeLabDemo \
--attribute-definitions \
AttributeName=PK,AttributeType=S \
AttributeName=SK,AttributeType=S \
AttributeName=ownerId,AttributeType=S \
AttributeName=updatedAt,AttributeType=S \
--key-schema AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE \
--global-secondary-indexes \
'IndexName=OwnerIndex,KeySchema=[{AttributeName=ownerId,KeyType=HASH},{AttributeName=updatedAt,KeyType=RANGE}],Projection={ProjectionType=ALL}' \
--billing-mode PAY_PER_REQUEST \
--endpoint-url http://localhost:8000 \
--region us-west-2
# セッションの自動失効に使うTTL属性を有効化
aws dynamodb update-time-to-live \
--table-name ClaudeCodeLabDemo \
--time-to-live-specification "Enabled=true,AttributeName=expiresAt" \
--endpoint-url http://localhost:8000 \
--region us-west-2
次にNode.jsの依存を入れて、本体を書きます。
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
下の app.mjs は、タスク作成・プロジェクト配下の一覧・GSI経由の担当者横断一覧・条件付き更新・TTL付きセッション作成を全部入れた最小例です。Claude Codeに改善を頼むときも、まずこのサイズで動かしてからLambdaやAPIへ移すほうが安全でした。
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
QueryCommand,
UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
const TABLE_NAME = process.env.TABLE_NAME ?? "ClaudeCodeLabDemo";
const isLocal = process.env.DDB_LOCAL !== "0";
const client = new DynamoDBClient({
region: process.env.AWS_REGION ?? "us-west-2",
// ローカルのときだけendpointとダミー認証を差し込む。本番では空にする
...(isLocal
? {
endpoint: "http://localhost:8000",
credentials: {
accessKeyId: "fakeMyKeyId",
secretAccessKey: "fakeSecretAccessKey",
},
}
: {}),
});
const ddb = DynamoDBDocumentClient.from(client, {
marshallOptions: { removeUndefinedValues: true },
});
const nowIso = () => new Date().toISOString();
const ttlAfterDays = (days) => Math.floor(Date.now() / 1000) + days * 86400;
const taskKey = (projectId, taskId) => ({
PK: `PROJECT#${projectId}`,
SK: `TASK#${taskId}`,
});
// 1) タスク作成(同じPK/SKの二重作成を条件で防ぐ)
async function createTask({ projectId, taskId, title, ownerId }) {
const item = {
...taskKey(projectId, taskId),
entityType: "Task",
title,
ownerId, // ← これがGSI(OwnerIndex)のパーティションキーになる
status: "OPEN",
createdAt: nowIso(),
updatedAt: nowIso(),
};
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: item,
ConditionExpression:
"attribute_not_exists(PK) AND attribute_not_exists(SK)",
}),
);
return item;
}
// 2) プロジェクト配下のタスク一覧(PK等価 + SK前方一致)
async function listProjectTasks(projectId) {
const result = await ddb.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: "PK = :pk AND begins_with(SK, :taskPrefix)",
ExpressionAttributeValues: {
":pk": `PROJECT#${projectId}`,
":taskPrefix": "TASK#",
},
ReturnConsumedCapacity: "TOTAL",
}),
);
console.log("base table consumed:", result.ConsumedCapacity);
return result.Items ?? [];
}
// 3) 担当者横断の一覧(GSIを使う。IndexNameを指定するのがポイント)
async function listTasksByOwner(ownerId) {
const result = await ddb.send(
new QueryCommand({
TableName: TABLE_NAME,
IndexName: "OwnerIndex",
KeyConditionExpression: "ownerId = :owner",
ExpressionAttributeValues: { ":owner": ownerId },
ScanIndexForward: false, // updatedAtの新しい順
ReturnConsumedCapacity: "INDEXES",
}),
);
console.log("gsi consumed:", result.ConsumedCapacity);
return result.Items ?? [];
}
// 4) 条件付き更新(持ち主かつ未完了のときだけDONEにする)
async function completeTask({ projectId, taskId, expectedOwnerId }) {
const result = await ddb.send(
new UpdateCommand({
TableName: TABLE_NAME,
Key: taskKey(projectId, taskId),
UpdateExpression: "SET #status = :done, updatedAt = :now",
ConditionExpression: "ownerId = :ownerId AND #status <> :done",
ExpressionAttributeNames: { "#status": "status" },
ExpressionAttributeValues: {
":done": "DONE",
":ownerId": expectedOwnerId,
":now": nowIso(),
},
ReturnValues: "ALL_NEW",
}),
);
return result.Attributes;
}
// 5) TTL付きセッション(expiresAtはUnix epoch秒のNumber)
async function createSession({ userId, sessionId }) {
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
PK: `USER#${userId}`,
SK: `SESSION#${sessionId}`,
entityType: "Session",
createdAt: nowIso(),
expiresAt: ttlAfterDays(7),
},
ConditionExpression:
"attribute_not_exists(PK) AND attribute_not_exists(SK)",
}),
);
}
async function main() {
const projectId = "alpha";
const taskId = `task-${Date.now()}`;
await createTask({
projectId,
taskId,
title: "Review DynamoDB key design",
ownerId: "masa",
});
await createSession({ userId: "masa", sessionId: `session-${Date.now()}` });
console.log("by project:", await listProjectTasks(projectId));
console.log("by owner :", await listTasksByOwner("masa"));
console.log(
"completed :",
await completeTask({ projectId, taskId, expectedOwnerId: "masa" }),
);
}
main().catch((error) => {
if (error.name === "ConditionalCheckFailedException") {
console.error("条件に合わず更新しませんでした:", error.message);
process.exit(2);
}
console.error(error);
process.exit(1);
});
実行はこれだけです。
DDB_LOCAL=1 node app.mjs
GSI経由の listTasksByOwner だけ IndexName: "OwnerIndex" を指定している点に注目してください。同じデータでも、PKを変えれば別の角度で読める。これがGSIの実感です。Claude Codeに追加実装を頼むなら、こう制約を添えます。
app.mjsをLambdaハンドラーに分割してください。
守ってほしい条件:
- PutCommandのConditionExpressionを消さない
- QueryはPKの等価条件から始める。Scanを足すなら理由と代替案を併記する
- GSIのOwnerIndexを使う読み取りは結果整合である前提でコメントを残す
- TTLのexpiresAtはUnix epoch秒のNumberのままにする
RDB思考でやりがちな失敗と、コストの判断
DynamoDBで僕が踏んだ地雷は、だいたいRDBの癖から来ていました。よくあるパターンを並べます。
Scan+FilterExpressionをWHERE句の代わりにする … 読んだ全量に課金される。一覧が欲しいならQueryかGSIで設計し直す。- 正規化しすぎてテーブルを分割する … JOINがないので、よく一緒に読むものは同じPKに寄せたほうが速くて安い。
- ホットパーティションを作る …
PK = GLOBALやPK = TODAYのように全員が同じキーに集まる設計は避ける。AWS公式のpartition key best practicesでも、PK全体へアクセスを均等に分散するよう書かれています。 - TTLを「指定時刻にきっちり消えるジョブ」と思い込む … TTLは期限後しばらく残ることがある。アプリ側でも期限を確認する。
- GSIを後付けの万能薬にする … 読み口が増えるたびに書き込みコストが乗る。結果整合な点も忘れがち。
- 条件付き書き込みを省く … Webhookや注文処理が二重実行される。
attribute_not_existsで最初の1回だけ通す。
課金モードの判断もRDBとは勘所が違います。新規で読み書き量が読めないうちはオンデマンド(リクエストごとの従量課金)が始めやすい。キャパシティを事前に見積もらなくていいからです。逆に、定常的で予測できる負荷ならプロビジョンドのほうがコストを抑えやすい。AWS公式のcapacity modeに判断材料があります。
ここでもClaude Codeに「安くして」とだけ頼んでも意味がありません。読み取り回数・アイテムサイズ・ピークの出方を渡して、はじめて妥当な判断が返ってきます。GSIを足すなら、その読み書き量も計算に入れる必要があります。レート制限のような高頻度・集中アクセスは、DynamoDB単体ではなくAPI Gatewayのスロットリングなど手前の層と役割分担するのが現実的です。
IAMもテーブル名だけで終わらせないでください。1テーブルに種類を詰めるほど、「どのパーティションキーまで触ってよいか」が重要になります。dynamodb:LeadingKeys でPK先頭値を絞る考え方は、権限設計をまとめたClaude Code × AWS IAM完全ガイドが参考になります。Lambdaに載せるならClaude Code × AWS Lambda完全ガイド、運用監視はCloudWatch活用も合わせて読むと、設計から運用まで線がつながります。
よくある質問
Q. シングルテーブル設計と、用途別にテーブルを分ける設計、どっちがいい?
A. 「複数の種類を1回の Query でまとめて読みたい」が頻発するならシングルテーブル。そうでなければ用途別で十分です。最初のMVPは用途別で始め、まとめ読みの要求が固まってからシングルテーブルへ寄せても遅くありません。難度が一段上がるので、無理に最初から1テーブルへ詰め込まないこと。
Q. GSIはいくつでも足していい? A. テーブルあたりの上限があるので「困ったらGSI」は危険です。それ以上に、GSIは書き込みごとにコストが乗り、結果整合で反映が遅れる前提があります。新しいアクセスパターンが出るたびに足すのではなく、本当にPK/SKで読めないものに限定してください。
Q. Query と Scan の違いを一言で言うと?
A. Query はPKを指定してピンポイントに読む操作、Scan はテーブル全件を舐める操作です。Scan はデータ量に比例して遅く・高くなるので、本番の一覧表示で常用するものではありません。出てきたら設計を疑うサインにしてください。
Q. TTLで消したはずのデータが読めてしまう。バグ?
A. 仕様です。DynamoDB TTLは期限ちょうどに消えるわけではなく、期限後しばらく残ることがあります。セキュリティ上消えていてほしいデータは、アプリ側でも expiresAt を見て期限切れを弾く設計にしてください。詳細はAWS公式のTTLを参照。
Q. Claude Codeにいきなりテーブル定義を書かせてはダメ?
A. ダメではありませんが、精度が落ちます。先にアクセスパターン表を作らせ、PK/SKと「Queryで読めるもの・読めないもの」を合意してからコードに進むほうが、Scan 頼みの実装をかなり減らせます。順番が9割です。
実際に試した結果
検証してみて、いちばん効いたのは「最初にアクセスパターン表を作らせる → 次にPK/SKとGSIの要否をレビューさせる → 最後にローカルDynamoDBで条件付き書き込みを実行する」という順番でした。コードを先に書かせると、Claude CodeはそれらしいNoSQL用語を並べた Scan 混じりの実装を出しがちです。でもアクセスパターン表を起点にすると、「この読み口はPKでは無理なのでGSIが要ります」と自分から指摘してくるようになりました。
冒頭の「JOINで何とかしよう」で半日溶かした件は、結局のところ設計の順番を間違えていただけでした。RDBは正規化してから読み方を考える。DynamoDBは読み方を決めてからキーを置く。この一点を逆にするだけで、Scan も詰まりも激減します。速く書くことより、読めるキー設計と、二重実行しない更新条件を先に固定する。DynamoDBをClaude Codeで扱うなら、ここから始めてください。
チーム標準にするならClaude Code研修・導入相談で既存リポジトリを題材にレビュー観点を整理できます。設計レビュー用のプロンプトやチェックリストをそのまま使いたい場合はClaudeCodeLabの教材一覧も覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。