メインコンテンツまでスキップ

第18章 データベース正規化演習問題集

この章で学ぶこと

  • 与えられた未正規化テーブルから第 3 正規形まで自力で分解できる
  • 第 11 章で扱った各正規形の判定を、複数の事例で確実に適用できる
  • 「正規化すべきでない場面」を見極められる
前提知識

第 11 章 データベース正規化 を理解していること。本章は演習中心の章で、すべての問題に解答例を付けています。まず自力で考えてから解答を確認してください。

演習問題集

演習 1: 子供情報の正規化 (繰り返しグループ)

次のテーブルは社員と子供の情報を 1 行で持っています。第 1 〜 第 3 正規形まで分解してください。

employees_with_children
+---------+----------+----------+-------+----------+-------+----------+-------+
| 社員ID | 社員名 | 子供名1 | 年齢1 | 子供名2 | 年齢2 | 子供名3 | 年齢3 |
+---------+----------+----------+-------+----------+-------+----------+-------+
| 1001 | 山田 太郎 | 一郎 | 8 | 二郎 | 5 | NULL | NULL |
| 1002 | 鈴木 花子 | 美咲 | 3 | NULL | NULL | NULL | NULL |
| 1003 | 佐藤 次郎 | 健太 | 12 | 美穂 | 10 | 翔太 | 7 |
+---------+----------+----------+-------+----------+-------+----------+-------+
解答例

問題点: 「子供」が繰り返しグループ (子供名1, 年齢1, 子供名2, 年齢2, ...) になっている → 第 1 正規形違反。

第 1 正規形に分解:

CREATE TABLE employee_children_1nf (
employee_id INTEGER NOT NULL,
employee_name VARCHAR(100) NOT NULL,
child_name VARCHAR(100) NOT NULL,
child_age INTEGER NOT NULL,
PRIMARY KEY (employee_id, child_name)
);

各子供を別の行にし、主キーを (employee_id, child_name) に。

第 2 正規形: 主キーが複合 (employee_id, child_name) で、employee_nameemployee_id だけで決まる (部分関数従属) → 分割が必要。

CREATE TABLE employees_2nf (
id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL
);

CREATE TABLE children_2nf (
employee_id INTEGER NOT NULL REFERENCES employees_2nf(id),
name VARCHAR(100) NOT NULL,
age INTEGER NOT NULL CHECK (age >= 0),
PRIMARY KEY (employee_id, name)
);

第 3 正規形: 推移的関数従属がない (employees_2nfchildren_2nf も主キー以外に従属関係なし) → そのまま第 3 正規形を満たす。

ありがちな間違い:

  • 「子供名」を主キーに含めると、同姓の子供 (例: 双子で同じ名前) が登録できない: 実務では children.id SERIAL PRIMARY KEY を代理キーにし、(employee_id, name) には UNIQUE 制約を付けない (or 付けるが業務要件次第)。本演習は教育目的で複合主キーを採用
  • 「年齢」を保存する: 第 1 章でも触れたが、年齢は時間で変わる。実務では birth_date を保存して都度計算
  • 「子供 3 人まで」のような制約を入れる: 業務要件にない人数制限は付けない

演習 2: 複数カテゴリの正規化 (多対多)

次のテーブルは商品とカテゴリの関係を 1 行で持っています。複数カテゴリに属する商品があります。

products_with_categories
+---------+-----------+--------------+
| 商品ID | 商品名 | カテゴリ |
+---------+-----------+--------------+
| 1 | ノートPC | 家電,PC |
| 2 | コーヒー | 食品,飲料 |
| 3 | マウス | 家電,PC,周辺機器 |
+---------+-----------+--------------+
解答例

問題点: カテゴリが CSV (カンマ区切り) で 1 セルに入っている → 第 1 正規形違反。「家電」で検索しようとしても LIKE '%家電%' のようなパフォーマンスの悪いクエリしか書けない。

正規化後 (多対多なので中間テーブル必要):

CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL
);

CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL
);

CREATE TABLE product_categories (
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
PRIMARY KEY (product_id, category_id)
);

ありがちな間違い:

  • カンマ区切り文字列のまま LIKE '%家電%' で検索: パフォーマンスが悪く、「家電製品」のような部分一致も拾ってしまう
  • products テーブルに category_id カラムを追加 (1 対多にしてしまう): 「マウスは家電 + PC + 周辺機器」のような複数カテゴリが表現できない
  • categories テーブルを作らず、product_categories.category_name で文字列保存: カテゴリ名の表記揺れ (例: 「家電」「家電製品」) が起き、整合性が崩れる

別解: PostgreSQL の text[] 配列型を使う方法もある (products.categories text[]) が、これは厳密には第 1 正規形違反。検索 / JOIN が複雑になるため、正攻法の中間テーブルを推奨。

演習 3: 導出項目の正規化 (計算で導ける値)

次のテーブルは「保存すべきでない」値を持っています。何が問題か指摘し、修正してください。

orders_with_calc
+---------+---------+---------+--------+--------+--------+----------+
| 注文ID | 顧客ID | 商品ID | 数量 | 単価 | 小計 | 注文日 |
+---------+---------+---------+--------+--------+--------+----------+
| 1 | 100 | 5 | 3 | 1500 | 4500 | 2026-01-15 |
| 2 | 101 | 8 | 2 | 800 | 1600 | 2026-01-16 |
+---------+---------+---------+--------+--------+--------+----------+
解答例

問題点: 小計 = 数量 × 単価 で計算できる 導出項目 (derived attribute) を保存している。

  • 数量 を更新したのに 小計 を更新し忘れると不整合になる
  • アプリ層で 数量 × 単価 を計算してから INSERT する手間とミスリスク

修正後: 小計 カラムを削除し、必要時に SELECT で計算する。

CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10, 2) NOT NULL, -- スナップショット
order_date DATE NOT NULL DEFAULT CURRENT_DATE
);

-- 小計を取り出すクエリ
SELECT id, quantity, unit_price, (quantity * unit_price) AS subtotal
FROM orders;

例外 (保存しても良いケース):

  • 集計レポート用テーブル: 性能要求が明確で、元データから再計算可能 (バッチで日次同期など)
  • 過去スナップショット: 「過去の時点の合計を後から見たい」場合、計算結果を凍結して保存
  • データウェアハウス: BI / OLAP 用途では非正規化が通常

業務システムの本番テーブルでは、原則として導出項目を持たないのが安全です。

ありがちな間違い:

  • 「速度のために subtotal を保存」と早期最適化: ほとんどの業務システムでは quantity * unit_price の計算は十分高速。実測してから判断
  • subtotal を生成列 (computed column) で持つ: PostgreSQL の GENERATED ALWAYS AS (quantity * unit_price) STORED を使えば、保存しつつ自動計算できる選択肢もある。これは妥協案として妥当

演習 4: 社員情報の総合演習

次のテーブルから第 1 〜 第 3 正規形まで分解してください。

employee_full
+---------+----------+----------+----------+----------+-----------+
| 社員ID | 社員名 | 部署ID | 部署名 | 部署所在地| 入社日 |
+---------+----------+----------+----------+----------+-----------+
| 1 | 山田 | 10 | 営業部 | 東京 | 2020-04-01 |
| 2 | 鈴木 | 20 | 開発部 | 大阪 | 2021-04-01 |
| 3 | 佐藤 | 10 | 営業部 | 東京 | 2022-04-01 |
+---------+----------+----------+----------+----------+-----------+
解答例

第 1 正規形 ◯ (繰り返しグループなし、各セル原子値、主キーあり)

第 2 正規形 ◯ (主キーが単独 社員ID のため部分関数従属は発生しない)

第 3 正規形 ✕ — 推移的関数従属あり: 社員ID → 部署ID → 部署名社員ID → 部署ID → 部署所在地

分解後:

CREATE TABLE departments (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
location VARCHAR(100) NOT NULL
);

CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
department_id INTEGER NOT NULL REFERENCES departments(id),
hire_date DATE NOT NULL
);

「部署名」「部署所在地」を departments に移動。employees には外部キー department_id だけを残す。

ありがちな間違い:

  • 「部署名と所在地を別テーブルに分ける」(過度な正規化): 「所在地」は「部署」の属性なので、departments テーブルに統合するのが自然。独立した locations テーブルが必要なのは、複数の部署が同じ拠点を共有する/拠点に固有属性を持たせる場合
  • 「人事異動を扱うため employees に異動履歴を持つ」: 「異動履歴」が要件にあるなら別途 employee_assignments テーブルを作るのが正解。本演習の要件にはないので追加しない (YAGNI 原則)

演習 5: 「正規化すべきでない」場面を見極める

次のうち、非正規化が許容される / 推奨されるケースを選び、その理由を答えてください。

  1. ブログ記事の published_at (公開日時) と、別カラムの published_year, published_month (公開年・月)
  2. 注文明細の unit_price (注文時の単価をスナップショット)
  3. EC サイトの users.full_address カラムに、users.postal_code + users.address_line1 + users.address_line2 を結合した文字列を入れる
  4. 商品マスタの products.category_name (カテゴリ名)、products.category_id (カテゴリ ID) の両方を持つ
解答例
番号正規化 / 非正規化理由
1. 公開年月非正規化 NG (削除すべき)EXTRACT(YEAR FROM published_at) / EXTRACT(MONTH FROM published_at) で都度計算可能。インデックスは EXPR INDEX で対応
2. 単価スナップショット非正規化 OK (推奨)商品マスタの将来価格変更から過去の注文金額を守る。これは正規化違反ではなくスナップショットの概念
3. full_addressNGpostal_code / address_line1 / address_line2 から結合できる。冗長で更新時の整合性管理が必要
4. category_name 重複NG (古典的非正規化アンチパターン)category マスタを更新したときに products 側を全件更新する必要がある (第 11 章「更新異常」の典型)

正規化と非正規化の区別:

  • 計算で導ける値 (公開年月、合計、フルアドレス) → 保存せず計算
  • 時系列で変わる外部キーの値 (注文時の単価、注文時の住所) → スナップショット保存

今の値を見たいか、当時の値を見たいか」が判断軸です。

ありがちな間違い:

  • 「カテゴリ名を JOIN するのが面倒だから products に持つ」: 整合性のコストが将来大きくなる。JOIN は通常十分高速
  • 「全部スナップショットすれば安心」: マスタ更新が反映されない (例: 顧客名がスナップショットされていると、顧客が改姓しても古い名前のまま)

まとめ

この章で学んだことを整理します。

  • 第 11 章で学んだ正規化の理論を、5 つの実践演習で確認した
  • 繰り返しグループ・カンマ区切り CSV は第 1 正規形違反
  • 推移的関数従属 (社員 → 部署 → 部署名) は第 3 正規形で解消
  • 計算で導ける値は保存せず、SELECT 時に計算する
  • スナップショットは「時系列の正確性」を取る意図的な非正規化

総合演習はここまでです。発展章 (インデックス設計) に進むか、ガイド全体を通じて以下のリファレンスを活用してください。

次の章

第 19 章 インデックス設計入門 — 検索を速くするインデックスの張り方と EXPLAIN の読み方

関連リファレンス