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

最終更新日

Schoo Java入門 中級 第9回 Webアプリケーション設計演習

学習記録を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行を古い名前のまま残せてしまいます。

利用者ID1001の記録101だけ名前を鈴木へ変更し、記録102は田中のままになっている。記録103は利用者ID1002の佐藤。同じ利用者の現在の名前が行によって食い違う例。
変更漏れを示す例です。利用者ID1001に対する現在の名前が、記録101と102で食い違っています。

利用者情報を別の表にまとめれば、現在の名前は利用者の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の田中id1001から、study_logsの記録101と102のuser_id1001へ矢印がつながる。佐藤id1002からは記録103のuser_id1002へつながる。記録のid101・102・103とは結ばない。
線はIDによる対応関係です。処理の実行順や、名前を学習記録へコピーする動きを表しているわけではありません。

表を読むときは、次の順にたどります。

  1. usersで、田中さんのidが1001だと確認する。
  2. study_logsのuser_idが1001の行を探す。
  3. 記録101「Java基礎」と記録102「SQL復習」が、田中さんの記録だと分かる。
  4. 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 NULLNULL(値がない状態)を拒否する。空文字や空白だけの文字列まで拒否する指定ではない。
INTEGER整数を扱う列として設計する。ただしSQLiteの通常のテーブルでは型名だけで保存値を厳密に制限できないため、整数の確認も制約へ含める。
CHECK1〜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設計の目的はテーブル数を増やすことではなく、必要なデータを無理なく保ち、取り出せるようにすることです。

確認問題

  1. 学習記録の各行に利用者名も保存しています。利用者1001の名前を変更したところ、記録101だけ新しい名前になり、記録102は古い名前のままでした。どの情報を別のテーブルに分け、何で関連付けるとよいですか。
  2. 田中さん(利用者ID1001)が学習記録を3件登録します。study_logs.idとstudy_logs.user_idには、それぞれどのような値を入れますか。例を挙げてください。
  3. 列名をuser_idにすれば、存在しない利用者の記録も、他人による閲覧も防げると考えました。それぞれ、何が別に必要ですか。
  4. 学習内容にNOT NULL、学習時間にINTEGERを指定しました。空文字の学習内容や、0分の学習時間を必ず拒否できるでしょうか。このSQLiteの設計例で必要な確認を答えてください。
  5. 画面には「メモはありません」と表示したいので、未入力時にその文章をDBへ保存する案が出ました。この記事の設計では、何を保存し、いつ文章を作りますか。

演習後の確認:こちらのページを使って、自分で考えた内容を見直せます。

まとめ

  • まず1行が何1件を表すかを決め、利用者と学習記録の情報を分ける。
  • 主キーは1件を識別する。記録のidと所有者のuser_idを混同しない。
  • 利用者1人に記録0件以上を対応させ、分けた表をIDで関連付ける。
  • テーブル定義書には型・制約・単位・未入力・日時の扱いまで書く。
  • 外部キーによる整合性、利用者のアクセス制御、二重送信対策は別に考える。

関連記事

シェアする