データベース設計とは?テーブルの分け方と関連付けを具体例で解説

学習記録を100件登録した人の名前が変わったら、100件すべてを書き直す必要があるのでしょうか。利用者の情報と学習記録を分けて保存しておけば、現在の名前は利用者の1行を直し、記録は利用者IDで結び付けたまま扱えます。
データベース設計は、単に画面の項目を表へ写す作業ではありません。何を1件として保存するか、どの情報を分けるか、分けた情報をどう結び付けるかを決める作業です。この記事では学習記録アプリを例に、データ入りの図とテーブル定義書の記入例で説明します。
本記事は、Schoo「Java入門 中級」第9回「Webアプリケーション設計演習」を学んだあとに理解を深めるステップアップ学習記事です。受講や配布ファイルを前提とせず、初めてDB設計に取り組む方にも読めるようにしています。今回は接続方法やSQLの実行手順ではなく、保存するデータの形を扱います。
データベース設計とは?あとで正しく取り出せる保存方法を決める
ここでは、表形式のテーブルを使うリレーショナルデータベースを扱います。テーブルは同じ種類のデータを入れる表、行は1件分のデータ、列は名前や学習時間などの項目です。
| 最初に決めること | 学習記録アプリの例 |
|---|---|
| 保存する情報 | 利用者情報と、学習日・内容・時間などの学習記録。 |
| 1行が表すもの | 利用者の表は1人、記録の表は1回の学習記録。 |
| 列と値のルール | 時間は分単位の整数。メモは任意。記録IDは重複させない。 |
| 表同士の関係 | 各学習記録に、誰が登録したかを表す利用者IDを持たせる。 |
設計の良し悪しは、列名がきれいかだけでは判断できません。「同じ名前の利用者がいても区別できるか」「記録0件の利用者を登録できるか」「名前を変えたときに矛盾しないか」と、実際の操作で確かめます。
1.画面の情報を、保存するもの・しないものに分ける
学習記録の登録画面には、学習日、カテゴリ、内容、時間、任意のメモがあります。一覧にはログイン中の利用者名も表示します。ただし、画面に見えるものを全部、登録のたびに保存するわけではありません。
| 情報 | 保存先・扱い方 |
|---|---|
| 利用者ID・利用者名 | 利用者情報として保存する。記録ごとに名前を複製しない。 |
| ログイン用のパスワード | 入力された文字列をそのまま保存しない。認証に使うハッシュ値を利用者情報に保存する。 |
| 学習日・カテゴリ・内容・時間・メモ | 1回の学習記録として保存する。 |
| 記録の所有者 | 学習記録に利用者IDを保存する。入力欄を設けず、ログイン中の利用者から決める。 |
| 「登録しました」というメッセージ | 保存結果に応じて表示する。この例では学習記録の列にしない。 |
| 「メモはありません」という表示 | 未入力の状態から表示時に作る。この文章をメモの本文として保存しない。 |
| 登録日時 | 画面に入力欄がなくても、同じ学習日の記録を登録順に並べるために保存する。 |
登録日時は、前回決めた「同じ学習日なら登録の新しい順」を具体化するため、今回の定義例に加える項目です。授業資料の表をそのまま写すのではなく、要件から必要な情報を確かめます。前提となる条件は、要件定義とは?Webアプリの具体例で学ぶ、要求の整理と要件の書き方と画面設計とは?Webアプリの画面設計書・画面遷移図の書き方を具体例で解説で扱っています。
2.テーブルを分ける前に「1行は何1件か」を決める
次の二つを言葉で決めると、列をどちらへ置くか判断しやすくなります。
users:1行で利用者1人を表す。利用者IDと名前などを持つ。study_logs:1行で1回の学習記録を表す。学習日・内容・時間などと、その記録の所有者を持つ。
田中さんが同じ日にJavaとSQLを学んで別々に登録したら、利用者は1行のまま、学習記録が2行になります。画面の数ではなく、何についての情報かで分けます。
| 避けたい考え方 | この例での整理 |
|---|---|
| 利用者の表に「学習内容1」「学習内容2」「学習内容3」を作る。 | 学習記録の表に行を追加する。記録が増えるたびに列を増やさない。 |
| 田中さん用、佐藤さん用という別々のテーブルを作る。 | 同じstudy_logsに保存し、user_idで所有者を区別する。 |
| 登録画面の表、一覧画面の表をそれぞれ作る。 | 登録した同じ記録を一覧で取得する。画面ごとに複製しない。 |
図解:一つの表に名前も繰り返すと、変更漏れが起きる
利用者ID1001の現在の名前を「田中」から「鈴木」へ変更したとします。記録の各行に利用者名まで保存していると、1行だけ書き換えて、もう1行を古い名前のまま残せてしまいます。

利用者情報を別の表にまとめれば、現在の名前は利用者の1行を変更するだけです。学習記録が0件でも利用者を登録でき、最後の記録を消しても利用者情報は残せます。このように、同じ事実の重複を減らし、変更時の食い違いを防ぐために整理する考え方は、正規化につながります。
ただし「登録した当時の名前を、あとから変更せず残したい」という要件なら、過去時点の名前を別途保存する設計もあります。今回は現在の利用者名を表示する例です。何でも分割すればよいのではなく、何を保存したいのかで判断します。
3.主キーで「この1件」を区別する
名前は同じ人が複数いるかもしれませんし、あとで変わることもあります。そこで、この例では利用者にidを付け、1人を特定します。テーブル内の1行を一意に識別するために選ぶ列、または列の組み合わせを主キー(PRIMARY KEY)と呼びます。今回は1列の整数IDです。
| 列 | 何を区別するか |
|---|---|
| users.id | 利用者1人。例:1001は田中さん、1002は佐藤さん。 |
| study_logs.id | 学習記録1件。例:101はJava基礎、102はSQL復習の記録。 |
| study_logs.user_id | その記録が誰のものか。記録101も102も田中さんなら、両方に1001を保存する。 |
記録のidと、所有者を表すuser_idは別です。同じ人が2件登録しても、記録のidは別々になります。一方、user_idは同じ値で構いません。user_idまで重複禁止にすると、利用者1人につき1件しか記録できなくなります。
IDの重複を判断する範囲にも注意します。users.idとstudy_logs.idに同じ数値があっても、それだけで問題にはなりません。別のテーブルで、別のものを識別しているからです。数字が偶然同じという理由で結び付けてはいけません。
図解:利用者IDで、分けたテーブルを関連付ける
次の図は、名前を変える前のデータを、二つの表に分けた状態です。田中さんの利用者ID1001が、記録101と102のuser_idに入っています。関係に注目できるよう、日付・時間・メモなどの列は図では省略しています。

表を読むときは、次の順にたどります。
- usersで、田中さんのidが1001だと確認する。
- study_logsのuser_idが1001の行を探す。
- 記録101「Java基礎」と記録102「SQL復習」が、田中さんの記録だと分かる。
- user_idが1002の記録103「例外処理」は佐藤さんの記録なので、田中さんの一覧には含めない。
この関係を1対多と呼びます。利用者1人に対し、学習記録は複数件あります。記録前の利用者もいるので、正確には利用者1人に対して記録は0件以上、各記録の所有者は1人です。
分けたら画面に名前を出せなくなるわけではありません。関連するIDを使って取得すれば、名前と記録を一緒に表示できます。SQLで表を結び付けて取得する処理をJOINと呼びます。このアプリの自分用一覧なら、ログイン中の利用者名と、その利用者IDで絞った記録を画面側で組み合わせる構成も考えられます。
4.外部キーで、存在しない利用者の記録を防ぐ
study_logs.user_idに9999を入れたのに、usersにid9999の利用者がいなければ、誰の記録か分からなくなります。こうした参照先との矛盾を防ぐために、外部キー(FOREIGN KEY)制約を使います。
| 関係の定義 | この例での意味 |
|---|---|
| 参照する列:study_logs.user_id | 各記録が所有者の利用者IDを持つ。 |
| 参照される列:users.id | そのIDを持つ利用者が存在することを要求する。 |
| user_idは必須 | NOT NULLも指定し、所有者なしの記録を保存しない。 |
| 所有者に記録が残っている場合の削除 | この設計例では、利用者の削除を拒否する方針にする。記録を自動削除する設定にはしない。 |
user_idという列名だけでは、外部キー制約は働きません。テーブルを作成するときに参照先を定義し、DB側で制約が有効な状態にします。SQLiteでは、接続ごとに外部キー制約を有効にしているか確認が必要です。図に線を描くこと、DBに制約を設定することは別の作業です。
また、外部キーが確認するのは「その利用者が存在するか」であって、「いま操作している人がその記録を見てよいか」ではありません。ログイン中の利用者IDで取得対象を絞り、登録時の所有者もサーバー側で決めます。
5.テーブル定義書に、列・データ型・制約を書く
テーブル定義書は、表にどんな列があり、どんな値を保存できるかを記録する文書です。名称だけでなく、1行の意味・主キー・参照先・単位・未入力時の扱いまで書きます。以下はSQLiteを想定した設計例です。他のDBへそのまま型名を当てはめるとは限りません。
users:利用者情報。1行は利用者1人。この例では管理担当者が利用者を事前登録し、整数のidで識別します。
| 列・保存する情報 | データ型・制約・扱い方 |
|---|---|
| id 利用者ID | INTEGER。主キー。1001など、利用者ごとに異なる値を管理する。 |
| name 利用者名 | TEXT、NOT NULL。空文字・空白だけを受け付けない条件はアプリ側でも確認する。同姓同名を許すので、この列は重複禁止にしない。 |
| password_hash 認証用のハッシュ値 | TEXT、NOT NULL。パスワード用の安全なハッシュ方式で生成した値を保存する。パスワードそのものを平文で保存しない。 |
パスワードのハッシュ化は、単に文字を置き換えたり、元へ戻せる暗号文を作ったりすることとは異なります。この記事では保存対象が入力されたパスワードそのものではないことを押さえ、認証処理の実装は扱いません。
study_logs:学習記録。1行は1回の学習記録。所有者は必須ですが、同じ所有者の記録を何件でも保存できます。
| 列・保存する情報 | データ型・制約・扱い方 |
|---|---|
| id 学習記録ID | INTEGER。主キー。記録の登録時にDBで採番する。同じuser_idの行でもidは別々にする。 |
| user_id 所有者の利用者ID | INTEGER、NOT NULL。外部キーでusers.idを参照。同じ値を複数行に持てる。 |
| study_date 学習日 | TEXT、NOT NULL。YYYY-MM-DD形式で保存する。実在する日付か、操作日以前かをアプリ側で確認する。 |
| category カテゴリ | TEXT、NOT NULL。「Java」「SQL」「その他」のいずれか。選択肢以外を拒否する条件をDBのCHECK制約にも定義する。 |
| content 学習内容 | TEXT、NOT NULL。1〜200文字、空白だけは不可。アプリで確認し、長さの条件はDBのCHECK制約でも守る。 |
| study_minutes 学習時間 | INTEGER、NOT NULL。単位は分。整数かつ1〜480という条件をアプリとDBのCHECK制約で確認する。 |
| memo メモ | TEXT。任意、500文字以内。未入力は保存前にNULLへそろえる。この方針を登録処理と表示処理で共有する。長さの上限はDBのCHECK制約でも守る。 |
| created_at 登録日時 | TEXT、NOT NULL。サーバー側で登録時刻を入れる。この例はUTCのYYYY-MM-DD HH:MM:SS形式に統一する。利用者の入力値を使わない。 |
列名に使う英語は一例ですが、study_dateとcreated_atは意味が異なります。9月27日の学習を9月28日に入力すれば、学習日と登録日は別です。名称だけでなく、何の日付か、誰が設定するかも記録します。
一覧は学習日の降順、同日なら登録日時の降順で取得します。同じ秒の登録がある場合の順序も安定させるため、この例では最後に記録IDの降順を使います。テーブルに見えている行の並び順が、そのまま検索結果の順序になるとは考えず、取得時に並び順を指定します。
上の表は設計書の記入例であり、列名を書くだけで制約が実装されたことにはなりません。DB作成時に定義へ反映し、不正なデータが拒否されることも確認します。登録日時、文字数の上限、外部キー・CHECK制約、未入力メモの扱いは、この記事の要件から具体化した設計上の選択です。
データ型と必須の指定だけでは、入力条件を全部守れない
「必須にした」「数値型にした」だけで、画面のルールをすべて守れるわけではありません。
| 指定・値 | できることと、別に必要な確認 |
|---|---|
| NOT NULL | NULL(値がない状態)を拒否する。空文字や空白だけの文字列まで拒否する指定ではない。 |
| INTEGER | 整数を扱う列として設計する。ただしSQLiteの通常のテーブルでは型名だけで保存値を厳密に制限できないため、整数の確認も制約へ含める。 |
| CHECK | 1〜480など、保存値が満たす条件をDBに定義する。NULLを拒否したい列ではNOT NULLも併用する。 |
| TEXTの日付 | 形式をそろえるための設計を決める。TEXTだけでは「2026-02-30」のような実在しない日付を拒否できない。 |
| メモのNULLと「メモはありません」 | NULLは未入力の保存状態。「メモはありません」は画面で作る案内。保存値と表示用の文章を区別する。 |
アプリ側の確認は、利用者へ項目と理由を分かりやすく伝えるためにも必要です。DBの制約は、別の処理経路から保存された場合にもデータの矛盾を防ぐ役割があります。どちらか一方を書けば完了ではありません。
6.具体的な登録・変更・取得で、設計を確かめる
空のテーブル定義だけを眺めるより、図と同じデータを入れたつもりで操作すると、設計の間違いが見つかります。
| 操作・条件 | この設計で期待すること |
|---|---|
| 田中さんが2件登録する | usersの1001は1行のまま。study_logsにuser_id1001の行が2行でき、それぞれの記録IDは異なる。 |
| 田中さんの現在の名前を鈴木へ変える | users.id1001のnameを1か所変更する。study_logsの所有者は1001のまま。改めて取得する名前は鈴木になる。 |
| 田中さんの一覧を開く | ログイン中の利用者ID1001で絞り、記録101・102を取得する。利用者1002の記録103を含めない。 |
| 利用者だけを先に登録する | study_logsが0件でもusersの利用者は存在できる。 |
| 同じ日・同じ内容を2回登録する | この例は別々の記録IDで保持できる。二重送信による意図しない重複を防ぐ話とは分けて検討する。 |
| 存在しない利用者ID9999で記録を保存する | 外部キー制約が有効なら拒否される。 |
| 時間0分、または481分を保存しようとする | 入力確認で拒否する。DBにも条件を定義し、範囲外を保存しない。 |
| 任意のメモを入力しない | この例ではNULLで保存し、一覧で「メモはありません」と表示する。 |
| 記録が残る利用者を削除しようとする | 今回の方針では拒否し、所有者のいない記録を残さない。 |
主キーが重複していなくても、同じ操作を2回送れば別のIDで2件登録される場合があります。IDによる識別と、二重送信の防止は別の問題です。前回の要件で未決として残した再送時の扱いも、実装前に決定します。
また、このアプリの初期範囲には、利用者自身による退会や学習記録の削除画面は含めていません。削除に関する行は、管理操作も含めてデータをどう守るかという方針です。将来、退会機能を追加するなら、記録を残すか、匿名化するか、削除するかを要件から見直します。
テーブルを増やす判断も、要件から考える
この例のカテゴリは、固定の「Java」「SQL」「その他」を文字列で保存します。管理担当者がカテゴリを追加・改名する、カテゴリの説明や表示順を管理する、といった要件があるなら、カテゴリ専用のテーブルとIDで関連付ける設計を検討します。
一覧用の画面が増えるたびに表を増やすのではなく、「独立して管理する情報があるか」「重複した情報を変更すると矛盾しないか」を確かめます。DB設計の目的はテーブル数を増やすことではなく、必要なデータを無理なく保ち、取り出せるようにすることです。
確認問題
- 学習記録の各行に利用者名も保存しています。利用者1001の名前を変更したところ、記録101だけ新しい名前になり、記録102は古い名前のままでした。どの情報を別のテーブルに分け、何で関連付けるとよいですか。
- 田中さん(利用者ID1001)が学習記録を3件登録します。study_logs.idとstudy_logs.user_idには、それぞれどのような値を入れますか。例を挙げてください。
- 列名をuser_idにすれば、存在しない利用者の記録も、他人による閲覧も防げると考えました。それぞれ、何が別に必要ですか。
- 学習内容にNOT NULL、学習時間にINTEGERを指定しました。空文字の学習内容や、0分の学習時間を必ず拒否できるでしょうか。このSQLiteの設計例で必要な確認を答えてください。
- 画面には「メモはありません」と表示したいので、未入力時にその文章をDBへ保存する案が出ました。この記事の設計では、何を保存し、いつ文章を作りますか。
演習後の確認:こちらのページを使って、自分で考えた内容を見直せます。
まとめ
- まず1行が何1件を表すかを決め、利用者と学習記録の情報を分ける。
- 主キーは1件を識別する。記録のidと所有者のuser_idを混同しない。
- 利用者1人に記録0件以上を対応させ、分けた表をIDで関連付ける。
- テーブル定義書には型・制約・単位・未入力・日時の扱いまで書く。
- 外部キーによる整合性、利用者のアクセス制御、二重送信対策は別に考える。