1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NULLを「とりあえず許容」にしてはいけない理由とDB設計の判断軸

1
Posted at

はじめに

この記事では、NULLをカラムに許容するかどうかの設計判断を整理します。

「とりあえずNULL許容にしておけば後で困らない」という考えは、設計としては危険です。
NULLの意味が曖昧なまま積み重なると、クエリのバグやアプリ側の防御コードが増え、テーブルの読み解きが難しくなります。

NULLが持つ意味は1つではない

NULLは「値がない」という状態を表しますが、「値がない理由」は複数あります。

  • 未入力:まだ値を入力していない
  • 不明:値があるはずだが把握できていない
  • 適用外:そのカラムの概念自体がこのレコードに当てはまらない
  • 削除済み:参照先が消えた

同じカラムにこれらの意味が混在すると、クエリを書く側は何を意味するNULLなのかを判断できません。

例として、次のようなカラムを考えます。

CREATE TABLE orders (
  id          INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  shipped_at  DATETIME NULL,
  canceled_at DATETIME NULL
);

shipped_at が NULL のとき、「まだ発送していない」のか「発送記録が欠損している」のかが区別できません。
クエリを書くたびにコードのコメントや口頭説明に頼ることになります。

NULL許容にすることで起きる問題

IS NULL / IS NOT NULL が複雑化する

NULL許容カラムが増えると、クエリに IS NULL / IS NOT NULL の条件が積み重なります。

-- 発送済みかつキャンセルされていない注文を取得する
SELECT *
FROM orders
WHERE shipped_at IS NOT NULL
  AND canceled_at IS NULL;

この程度なら読めますが、NULLの意味が複数あるカラムが3つ以上重なると、意図を読み解くのが難しくなります。

JOIN時にレコードが意図せず消える

SELECT o.id, u.name
FROM orders o
JOIN users u ON o.user_id = u.id;

user_id がNULLのレコードはJOINの結果から消えます。
INNER JOINでNULLが混じっているとレコード件数が合わず、調査に時間がかかります。

アプリ側の防御コードが増える

// NULL チェックを随所に書くことになる
if (order.getShippedAt() != null) {
    // 発送済みの処理
}

NULLが「未入力」なのか「適用外」なのかで処理が変わるはずですが、区別できないため != null という条件だけで判断することになります。

集計クエリでの落とし穴

-- NULL は COUNT(*) には含まれるが、COUNT(カラム) には含まれない
SELECT COUNT(*), COUNT(shipped_at)
FROM orders;

COUNT(*)COUNT(shipped_at) の結果が違っていても、なぜ違うのかをすぐに把握できないことがあります。

NULL許容の設計判断軸

NULL許容にするかどうかは、次の基準で判断します。

NULL許容にしてよいケース

値がない状態が業務上の正当な状態として存在し、その意味が1つに限定できる場合です。

-- 発送日時は「まだ発送していない」という状態を表すためにNULLを使う
-- 意味が1つに限定されているため許容できる
shipped_at DATETIME NULL
-- オプションの備考欄。入力しないことが普通にある
note TEXT NULL

NULL許容を避けるべきケース

次のような場合はNULLを避け、別の表現を検討します。

値の意味が複数になりそうなとき:

-- NULL が「未確認」なのか「適用外」なのか混在しやすい
approved_at DATETIME NULL

この場合、ステータスカラムを持つ方が意図を明確にできます。

-- NULLで状態を表現するのではなく、ステータスで管理する
approval_status ENUM('pending', 'approved', 'rejected') NOT NULL DEFAULT 'pending'

外部キーでNULLを使って「任意の紐付け」を表現するとき:

-- NULL で「紐付けなし」を表現している
assigned_user_id INT NULL

紐付けの有無を中間テーブルで管理すると、NULLを使わずに表現できます。

-- 担当者の割り当てを別テーブルで管理する
CREATE TABLE task_assignments (
  task_id  INT NOT NULL,
  user_id  INT NOT NULL,
  PRIMARY KEY (task_id, user_id),
  FOREIGN KEY (task_id) REFERENCES tasks(id),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

担当者が未割り当てのタスクは task_assignments にレコードが存在しない状態で表現できます。
担当者が複数になる要件にも、カラムを変えずに対応できます。

日付のNULLは「隠れたイベント」を見逃しているサインかもしれない

NULLを使いたくなる場面として、日付カラムが特に多いです。

shipped_at   DATETIME NULL,  -- まだ発送していない
approved_at  DATETIME NULL,  -- まだ承認されていない
canceled_at  DATETIME NULL   -- まだキャンセルされていない

一見わかりやすく見えますが、この設計には見落としがあります。
「発送した」「承認した」「キャンセルした」はそれぞれ業務上の出来事、つまりイベントです。
日付NULLで「まだ起きていない」を表現しているということは、そのイベントをテーブル構造として認識できていない状態です。

イベントが1つ見えてきたとき、だいたい複数になります。

例えば「発送した」だけを記録していたテーブルが、要件の追加によって次のように変わっていきます。

-- 最初はこれで十分だった
shipped_at DATETIME NULL

-- 「再発送した」「一部だけ発送した」という要件が加わる
first_shipped_at  DATETIME NULL,
reshipped_at      DATETIME NULL,
partial_shipped_at DATETIME NULL

カラムが増えるたびに、クエリの条件も複雑になります。

この問題の根本は、「発送」というイベントを orders テーブルのカラムとして表現しようとしていることです。
イベントを独立したテーブルとして切り出すと、カラムが増える問題を回避できます。

-- イベントを別テーブルに切り出す
CREATE TABLE order_shipments (
  id         INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  order_id   INT NOT NULL,
  shipped_at DATETIME NOT NULL,
  note       TEXT NULL,
  FOREIGN KEY (order_id) REFERENCES orders(id)
);

この設計では orders テーブルに shipped_at は不要になります。
「発送済みかどうか」は order_shipments にレコードが存在するかで判断できます。

-- 発送済みの注文を取得する
SELECT o.*
FROM orders o
WHERE EXISTS (
  SELECT 1 FROM order_shipments s WHERE s.order_id = o.id
);

再発送・部分発送・複数回の発送にも、カラムを追加せずに対応できます。

イミュータブルデータモデルの考え方も同じ発想です。
状態をUPDATEで上書くのではなく、「何が起きたか」を追記していく設計にすることで、NULLで「まだ起きていない」を表現する必要がなくなります。

日付カラムをNULL許容にしたくなったとき、それは設計に切り出すべきイベントが隠れているサインかもしれません。

NOT NULL + DEFAULT の活用

NULLを使わずに「値がない状態」を表現するには、NOT NULL + DEFAULT の組み合わせが有効です。

-- ステータスで状態を管理し、NULL を使わない
status ENUM('draft', 'active', 'closed') NOT NULL DEFAULT 'draft'

状態の種類が固定できる場合は、ENUMとDEFAULTの組み合わせでNULLを排除できます。
クエリに IS NULL / IS NOT NULL が現れなくなり、条件の意図が読みやすくなります。

設計時に問いかけること

カラムをNULL許容にする前に、次を確認するとよいです。

  • このカラムがNULLになる理由は何か
  • その理由は1種類に限定できるか
  • NULLの代わりにステータスやフラグで表現できないか
  • NULLの意味をチームメンバーが見てすぐ理解できるか
  • 日付カラムをNULLにしたいなら、それは業務上のイベントではないか。イベントとして切り出せるか

「とりあえずNULL許容」は後から制約を追加することが難しいため、設計段階で判断しておくことが重要です。

まとめ

NULLを許容するかどうかは、次の考え方で判断します。

  • NULLの意味が1つに限定できるなら許容できる
  • 意味が複数になりそうなら、ステータスやフラグで代替する
  • 「とりあえずNULL許容」はクエリの複雑化とバグの温床になる
  • 日付カラムのNULLは、設計に切り出すべきイベントが隠れているサインのことがある
  • イベントは1つ見えてきたら複数になる。最初からイベントテーブルに切り出す方が後から楽になる

テーブルを見ただけで「このNULLは何を意味するか」がわかる設計が、運用を楽にします。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?