第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_name は employee_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_2nf も children_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: 「正規化すべきでない」場面を見極める
次のうち、非正規化が許容される / 推奨されるケースを選び、その理由を答えてください。
- ブログ記事の
published_at(公開日時) と、別カラムのpublished_year,published_month(公開年・月) - 注文明細の
unit_price(注文時の単価をスナップショット) - EC サイトの
users.full_addressカラムに、users.postal_code+users.address_line1+users.address_line2を結合した文字列を入れる - 商品マスタの
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_address | NG | postal_code / address_line1 / address_line2 から結合できる。冗長で更新時の整合性管理が必要 |
| 4. category_name 重複 | NG (古典的非正規化アンチパターン) | category マスタを更新したときに products 側を全件更新する必要がある (第 11 章「更新異常」の典型) |
正規化と非正規化の区別:
- 計算で導ける値 (公開年月、合計、フルアドレス) → 保存せず計算
- 時系列で変わる外部キーの値 (注文時の単価、注文時の住所) → スナップショット保存
「今の値を見たいか、当時の値を見たいか」が判断軸です。
ありがちな間違い:
- 「カテゴリ名を JOIN するのが面倒だから products に持つ」: 整合性のコストが将来大きくなる。JOIN は通常十分高速
- 「全部スナップショットすれば安心」: マスタ更新が反映されない (例: 顧客名がスナップショットされていると、顧客が改姓しても古い名前のまま)
まとめ
この章で学んだことを整理します。
- 第 11 章で学んだ正規化の理論を、5 つの実践演習で確認した
- 繰り返しグループ・カンマ区切り CSV は第 1 正規形違反
- 推移的関数従属 (社員 → 部署 → 部署名) は第 3 正規形で解消
- 計算で導ける値は保存せず、SELECT 時に計算する
- スナップショットは「時系列の正確性」を取る意図的な非正規化
総合演習はここまでです。発展章 (インデックス設計) に進むか、ガイド全体を通じて以下のリファレンスを活用してください。
第 19 章 インデックス設計入門 — 検索を速くするインデックスの張り方と EXPLAIN の読み方