そのテーブル、全部入ってるやつじゃない?😬

そのテーブル、全部入ってるやつじゃない?😬

これ絶対あるじゃん??これ絶対あるじゃん??

  • ordersテーブルに顧客名・メアド・住所・支払い方法・配送業者名・クーポンコードが全部カラムとして入ってる
  • NULLだらけのカラムが20個以上あって「このカラム、BtoB案件のときだけ使うやつだから」って説明された
  • 新機能追加のたびに「念のためこっちにも入れとく」が続いて、気づいたらテーブルが150列になってた
  • 「JOINが遅くなるから全部1テーブルに突っ込んだ方が速い」って言ってた先輩がいた

これがDenormalization(正規化違反)、以降「神テーブル」って呼ぶね、が原因なんだよね。


てかシンプルに言うとねてかシンプルに言うとね

神テーブルとは、「本来は別のまとまりなのに、1つのテーブルがぜんぶ丸抱えしちゃってる状態」っつーこと。

それの何がまずいの?ってなる気持ち、わかる!データが1箇所にあった方が取り出しやすいし、JOINも書かなくていいし。でも実際は、そのテーブルを触るたびに「どこまで壊れる?」が誰にもわかんなくなるの。触るたびに地雷を踏む感じ。「速くしたかっただけなのに」って言いながら深夜に謝罪メール送るはめになるんだよね。


ちょっと待って、先に全然関係ない話していい?🏠ちょっと待って、先に全然関係ない話していい?🏠

田中さんは中堅食品メーカーの経理部に勤めてたんだけどさ。

その会社、マジでずっと1枚のExcelシートで全社の情報を管理してたの。シート名は「全社管理マスター.xlsx」。開くと、社員名・部署・給与・取引先名・取引先住所・受発注履歴・在庫数・商品単価・クレーム履歴・配送業者名・銀行口座番号…全部が横に延々と並んでた。

ある日、営業の鈴木くんが青い顔で走ってきた。

「田中さん、このExcel、株式会社山田商事の住所が行によって全部バラバラなんだけど」

「あ、これ去年の引越し情報を一部だけ更新して、残りは更新し忘れてるやつだ…」

田中さんは静かに絶望した。「株式会社山田商事」という名前がこのシートに37行ある。請求書のやつ、発注のやつ、クレーム対応のやつで全部バラバラに入力されてたの。住所を直すなら? 37行全部を手で探して確認しなきゃいけない。

「田中さん、今日中に請求書出さないといけないから、37行全部チェックして正しい住所に直してもらえる?」

その日の夜11時。田中さんは机でExcelと格闘しながら思った。

「なんで1社の住所を直すだけでこんなことになってるんだろう…」

翌朝、請求書に古い住所が1行だけ残ってることが発覚した。直し漏れた。

「先方から電話が来て、『住所が違う』って言われちゃって」と経理部長が暗い顔で言った。

これが本番で起きてるやつが、神テーブルってこと。


じゃあコードで見てみよっか👀じゃあコードで見てみよっか👀

これが神テーブルの典型パターン:

-- 最悪パターン:ordersテーブルが全概念を丸ごと背負っている
CREATE TABLE orders (
    order_id       INTEGER PRIMARY KEY,
    order_date     DATE,

    -- 顧客情報(本来は別テーブルの責任)
    customer_name  TEXT,
    customer_email TEXT,
    customer_phone TEXT,
    customer_addr  TEXT,   -- 同じ顧客が100件注文したら100行に同じ住所が入る

    -- 商品情報(本来は別テーブルの責任)
    product_name   TEXT,
    product_price  INTEGER,
    product_sku    TEXT,

    -- 配送情報(本来は別テーブルの責任)
    carrier_name   TEXT,
    tracking_code  TEXT,

    -- BtoB専用カラム(それ以外の注文はずっとNULL)
    billing_dept   TEXT,
    po_number      TEXT,

    -- 特定キャンペーン期間だけ使ってたやつ(誰も消せていない)
    campaign_2022  TEXT
);

さて、この状態で「顧客のメールアドレスを変更してほしい」というリクエストが来たとする:

-- メアド変更のつもりで書いたUPDATE
UPDATE orders
SET    customer_email = 'new@example.com'
WHERE  customer_name  = '山田太郎';

-- 起きること①:山田太郎の注文が過去100件あったら100行まとめてUPDATE
-- 起きること②:名前に表記ゆれがあったら(「山田 太郎」vs「山田太郎」)一部だけ変わる
-- 起きること③:同姓同名が別に存在したら、そっちのメアドも書き換わる

「え、これ山田さんから『自分の注文履歴が他人のメアドになってる』ってクレームが来たやつでは…」

わたしも昔これやったから言えるんだけど、このUPDATEを走らせた瞬間はエラーが1つも出ない。夜中に「注文確認メールが届かない」ってカスタマーサポートに問い合わせが来て初めて気づく。しかも原因の特定に2時間かかる。


やばいのまだあんだけど、バグに気づけない問題😭やばいのまだあんだけど、バグに気づけない問題😭

「壊れやすい」だけじゃなくて、「壊れてても気づけない」のが神テーブルの本当にやばいとこ。

田中さんのExcelに戻ると、山田商事の行が37行あって、そのうちいくつかが古い住所のまま。でも発注書はなぜか正しい住所に届いてた。最近の注文の行だけたまたま更新されてたから。

「じゃあ問題ないじゃん」ってなる? ならないんだよね。古い行のデータをたまたま読みにいったとき、初めてバグが出るの。しかも「古い行が読まれる条件」が複雑になるほど、テストで再現するのがムリになっていくんだよね。

コードで見るとこういうこと:

# ordersテーブルで「顧客の現在の住所」を取り出そうとすると
def get_current_address(customer_name):
    rows = db.query(
        "SELECT DISTINCT customer_addr FROM orders WHERE customer_name = ?",
        customer_name
    )
    return rows
    # 返ってくる可能性があるもの:
    # ['東京都新宿区1-1']                                → 引越ししていない顧客
    # ['東京都新宿区1-1', '東京都渋谷区2-2']              → 引越しして一部更新済み
    # ['東京都新宿区1-1', '東京都渋谷区2-2', '東京都港区'] → 入力ゆれが混入している
    # どれが「正しい今の住所」なのか、テーブルを見ても判断できない

# テストケース、何通り書けばいい?
# - 住所が1パターンの顧客(引越しなし)
# - 住所が2パターンの顧客(引越しして一部更新済み)
# - 名前に表記ゆれがある顧客
# - 同姓同名が存在する顧客
# これを全配送業者 × 全キャンペーンフラグ × 全支払い方法で掛け算していく

ordersテーブルが顧客・商品・配送の情報を全部持ってる限り、テストはこの組み合わせを全部カバーしようとし始めるじゃん。カラムが増えるたびに、テストすべき組み合わせも爆発するんだよね。

神テーブルは壊れやすいだけじゃなく、バグを隠す設計でもあるんだよね。


分けてた会社の話もするね〜✨分けてた会社の話もするね〜✨

同じ食品メーカー。でもこっちは情報を「何のまとまりか」でちゃんと分けてた。

「田中さん、山田商事の住所が変わったらしいんだけど」
「あ、じゃあ取引先マスターの1行直せばOKだよ」

「1行…?それだけ…?」

鈴木くんは拍子抜けした。前の会社では、住所変更のたびに半日以上かかってたから。

「請求書も発注書も全部そこを参照してるから、1行直したら全部に反映されるよ」と田中さんは言った。定時に帰りながら。

前の会社で起きてたこと:

  • 37行を手で確認する作業 → 発生しない
  • 1行直し漏れて先方からクレーム → 発生しない
  • 同姓同名と混ざって別顧客のデータが書き換わる → 発生しない

なぜか。それがこれ:

-- 正しい設計:概念ごとにテーブルを分ける
CREATE TABLE customers (
    customer_id  INTEGER PRIMARY KEY,
    name         TEXT NOT NULL,
    email        TEXT,
    phone        TEXT
    -- 顧客そのものの情報だけを持つ
);

CREATE TABLE customer_addresses (
    address_id   INTEGER PRIMARY KEY,
    customer_id  INTEGER REFERENCES customers(customer_id),
    addr         TEXT NOT NULL,
    is_current   BOOLEAN DEFAULT FALSE  -- 「今の住所」がどれかを明示できる
);

CREATE TABLE orders (
    order_id     INTEGER PRIMARY KEY,
    customer_id  INTEGER REFERENCES customers(customer_id),  -- 顧客はIDで参照するだけ
    order_date   DATE,
    total_amount INTEGER
    -- 注文そのものの情報だけを持つ。顧客の名前も住所も持たない
);

CREATE TABLE order_items (
    item_id      INTEGER PRIMARY KEY,
    order_id     INTEGER REFERENCES orders(order_id),
    product_id   INTEGER REFERENCES products(product_id),
    quantity     INTEGER,
    unit_price   INTEGER
    -- 明細そのものの情報だけを持つ
);

住所を変えるとき:

-- 古い住所を「現在ではない」にして、新しい住所を1行追加するだけ
UPDATE customer_addresses SET is_current = FALSE WHERE customer_id = 123;
INSERT INTO customer_addresses (customer_id, addr, is_current)
VALUES (123, '東京都港区新住所1-1', TRUE);

-- ordersテーブルはcustomer_idを参照しているだけなので一切触らなくていい
-- 過去の注文履歴も「注文当時の顧客ID」として正しく残る
-- 同姓同名と混ざる余地がない(IDで管理しているから)

直すのは2行。他は何も触らなくていい。ordersテーブルは「注文の情報」だけを持っていて、顧客の住所については「customer_idを通じて聞きに行く」設計になってるから。


ちゃんとした名前もあるから一応言うね📚ちゃんとした名前もあるから一応言うね📚

神テーブルの正式名称は**正規化違反(Normalization Violation)**っつーこと。

正しい設計は**正規化(Normalization)**って呼ばれてて、Edgar F. Coddって人が1970年に最初に言ったやつ。第一〜第三正規形(1NF〜3NF)っていうルールがあって、これを守ると「同じデータがバラバラにならない状態」になる。

マイクロサービスやDDD(ドメイン駆動設計)の世界だと「集約設計(Aggregate Design)」とも呼ばれてて、「どのデータが一緒に変わるか」でテーブルの区切りを決める考え方ね。

難しそうに聞こえるけど、要は「このデータ、本当にこのテーブルにいる理由ある?」を問い続けるだけなんだよね。


てかこれ、第1回と同じ話じゃない?🫶

そう、気づいた人はえぐい。

第1回で話した神クラス(ゴッドクラス)、覚えてる? OrderManager が注文・税計算・メール・在庫・PDFを全部やってて、税計算を1行直したら4箇所壊れた話。あれと今回の話、構造が完全に一緒なんだよね。

レイヤーアンチパターン症状正しい設計の考え方
コード(クラス)神クラスメソッド1個直したら関係ない処理が壊れる変更理由が1つになるようにクラスを分ける(SRP)
データ(テーブル)神テーブルカラム1個更新したら別の行・別の処理が壊れる変更理由が1つになるようにテーブルを分ける(正規化)

田中さんのたとえも実は一緒。第1回の田中さんは「なんでもできる社員1人に全仕事を投げた会社」の話で、今回の田中さんは「1枚のExcelに全社の情報を詰め込んだ会社」の話。どちらも根っこにある問題は「1つのものが、書き直すきっかけをいくつも抱えてる」こと。

この問いはコードでもテーブルでも使えるの:

  • クラスを設計するとき → 「このクラスのロジックが変わる出来事は何種類ある?」
  • テーブルを設計するとき → 「このカラムの値が変わる出来事は何種類ある?」
  • (ついでに言うと)マイクロサービスの境界を決めるときも、APIのエンドポイントを設計するときも、同じ問いが使える

「責任を1つに絞る」という原則は、コードにもデータにも、どのレイヤーにも適用できるんだよね。

これだけ覚えて帰って👌これだけ覚えて帰って👌

合言葉はこれ。mzk(混ぜるな危険)

コードを書くとき・レビューするとき、この問いを使って:

このカラムの値が変わるとしたら、どんな出来事が起きたとき?

ピザを頼んだとき、注文票に書くのは「マルゲリータ1枚・Lサイズ」。

この内容が変わる出来事は「やっぱりSサイズに変えたい」って電話したとき。

でも配送先の住所が変わる出来事は「引越しした」とき。

ピザを変えたからって住所は変わらないし、引越ししたからってピザのサイズは変わらない。出来事が別々なら、テーブルも別々ってこと。

さっきの神テーブルordersで同じ問いを当ててみると:

  • quantity(注文数)が変わる → 「この注文の内容を修正したとき」
  • unit_price(注文時の単価)が変わる → 「この注文の内容を修正したとき」
  • customer_emailが変わる → 「顧客がメアドを変更したとき」(注文と別の出来事)
  • customer_addrが変わる → 「顧客が引越ししたとき」(注文と別の出来事)

quantityunit_priceは「同じ出来事」でしか変わらない。だからこの2つはorder_itemsテーブルに一緒にいていい。でもcustomer_emailcustomer_addrは「別の出来事」で変わる。だからordersテーブルから出て行かないといけない、っつーこと。


まとめるとこういうことねまとめるとこういうことね

観点神テーブル(正規化違反)正規化・集約設計
テーブルの責任複数の概念をひとつのテーブルが担当している1テーブル1概念、責任が明確
データ更新同じデータが複数行に分散し、更新漏れが起きる1箇所を変えれば全体に反映される
NULLの扱い特定条件でしか使わないカラムがNULL列として残る条件付きデータは別テーブルに切り出す
テストの複雑さカラムが増えるたびに組み合わせが爆発するテーブルごとに独立してテストできる
変更への耐性どこを変えると何が壊れるか追えない変更の影響範囲がテーブルの境界で止まる

「JOINが増えるのが嫌だから1テーブルに全部入れた」の結果が、37行の住所修正と深夜のクレーム対応になる。パフォーマンスの最適化は「設計が正しくなってから」でいいっつーこと。


沼りたい人はこっちも読んで📖沼りたい人はこっちも読んで📖

書籍

  • 『Database Design for Mere Mortals』Michael J. Hernandez(第4版 25th Anniversary Edition 2020)― 正規化の概念をエンジニア以外にも伝わる言葉で解説している定番書。1NF〜3NFを丁寧に追える
  • 『達人に学ぶDB設計徹底指南書』ミック著(翔泳社, 初版2012 / 第2版2024)― 日本語で読める正規化とアンチパターンの解説書として現場での評価が高い。第2版ではクラウド対応も追加
  • 『Domain-Driven Design』Eric Evans(Addison-Wesley, 2003)― 集約設計の原点。テーブル設計をビジネスの概念境界から捉え直す視点が得られる

一次資料

無料で読める入門記事


Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第4回