0
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?

【DB】JSON型に逃げるな。逃げるなら、規律まで捨てるな

0
Posted at

はじめに

最近、JSONデータ型を知った。「カラムを増やさなくていい。何でも入れられる」。

ただ、調べていくと「正規化できないからアンチパターン」という意見が多い。
本当にそうなのか。メリットとデメリットを整理してみた。


1. DBにJSONを詰め込んだ

まず、JSONデータ型とは何かを簡単に説明する。

データベースは普通、テーブルの各カラムに「このカラムには数字だけ」「この列には文字列だけ」という型を決めて使う。JSONデータ型は、その制約を外して「{ "name": "田中", "age": 30 }」のような自由な形式のデータをそのまま入れられるカラムだ。MySQL 5.7以降やPostgreSQLで使える。

-- ユーザー情報を全部 properties という1つのカラムに詰め込む
CREATE TABLE users (
  id         serial primary key,
  properties jsonb not null  -- ここに何でも入れる
);

名前も住所も電話番号も、全部 properties に放り込む。**カラム追加はもう要らない。もう何も怖くない。

本当に・・・?


2. DBはJSONの中身を守らない

「同じ電話番号を持つユーザーを一覧にしてほしい」。

普通のカラムなら、こう書けば終わる。

-- 普通のカラムなら1行
SELECT * FROM users WHERE phone = '090-1234-5678';

でも電話番号がJSONの中に埋まっていると、こうなる。

-- JSONの中を検索しようとすると途端に複雑になる
SELECT u1.id, u2.id
FROM users AS u1, users AS u2
WHERE u1.properties->'電話番号' = u2.properties->'電話番号'
  AND u1.id != u2.id;

さらに「複数の電話番号を持つユーザーを比較したい」となった瞬間、SQLはこうなった。

-- 複数電話番号を持つユーザー同士で、同じ番号を持つペアを探す
SELECT u1.id, u2.id, phone1->>'number' AS 共通番号
FROM users AS u1, users AS u2
CROSS JOIN LATERAL jsonb_array_elements(u1.properties->'電話番号') AS phone1
CROSS JOIN LATERAL jsonb_array_elements(u2.properties->'電話番号') AS phone2
WHERE phone1->>'number' = phone2->>'number'
  AND u1.id < u2.id;

CROSS JOIN LATERALjsonb_array_elements->>->の違い。初めて見るキーワードが並ぶ。初見だと読めない。

それぞれ何をしているか、簡単に説明する。

  • jsonb_array_elements(...) — JSONの中の配列を「1行ずつバラす」関数。["090-xxxx", "080-yyyy"] という配列を、2行のレコードとして扱えるようにする。
  • CROSS JOIN LATERAL — 「各行ごとにサブクエリを実行して結果を横に並べる」結合。上の jsonb_array_elements と組み合わせて、配列の中身を行として展開するのに使う。
  • ->->>の違い — どちらもJSONの中の値を取り出す演算子だが、-> はJSON型のまま返し、->> は文字列として返す。-> で取り出した "090-xxxx"090-xxxx は型が違うため = で比較できない。->> で統一しないとバグる。

普通のSQLなら絶対に出てこないキーワードばかりだ。

なぜこうなるのか。普通のカラムなら、DBは「このカラムには何が入っているか」を最初から知っている。だから WHERE phone = '090-xxxx' と書けば最適化してくれる。でもJSONカラムの場合、DBは中身の構造を知らない。「このJSONの中に電話番号という配列があって、そのnumberフィールドを取り出して比較しろ」と、クエリのたびに全部説明しなければならない。

しかも、JSONの中のデータ型はDBが守ってくれない。普通のカラムなら「数字」の列に「文字列」を入れようとするとエラーが出る。普通のカラムなら型違いはDBが弾いてくれる。

-- 普通のINTカラムに文字列を入れるとエラーになる
INSERT INTO users (age) VALUES ('三十');  -- → ERROR: invalid input syntax for type integer

でもJSONカラムの中身は、何を入れても通る。

-- JSONの中身はDBが一切チェックしない
INSERT INTO users (properties) VALUES ('{"age": "三十"}');  -- → 成功してしまう

アプリ側でこのデータを読んで数値として扱おうとした瞬間、クラッシュする。
外部キー制約もCHECK制約もJSONの中身には届かない。データが壊れても、DBは気づかない。


3. DBとJSONが噛み合う、3つの条件

ただ、JSONデータ型を全否定するつもりはない。使いどころがある。

外部サービスのAPIレスポンスをそのまま保存したいとき。

X(旧Twitter)のAPIのように、仕様が予告なく変わる外部サービスと連携する場合。受け取ったデータをとりあえず丸ごと保存しておきたい、というケース。カラムを事前に決められないなら、JSONが合う。

設定値など、一度保存したらほぼ読むだけのとき。

数百件程度のデータで、検索もせず、丸ごと取得して使うだけ。この場合はパフォーマンスへの影響も小さい。

カテゴリによって必要な属性がバラバラなとき。

「本」にはISBNが必要だが、「服」にはサイズが必要。「PC」にはCPUのスペックが必要。これを普通のカラムで管理しようとすると、こうなる。

CREATE TABLE products (
  id        serial primary key,
  name      text not null,
  isbn      text,      -- 本用。服やPCにはNULL
  size      text,      -- 服用。本やPCにはNULL
  cpu       text,      -- PC用。本や服にはNULL
  memory_gb int        -- PC用。本や服にはNULL
);

ほとんどの行でカラムの半分以上がNULL。カテゴリが増えるたびにALTER TABLEが走る。こういうケースではJSONが現実的な選択肢になる。

CREATE TABLE products (
  id         serial primary key,
  name       text not null,
  category   text not null,       -- "book" / "clothes" / "pc"
  attributes jsonb not null       -- カテゴリごとの属性をここに入れる
);

共通点がある。どれも「どんなデータが来るか事前に決められない」か「検索しない」かのどちらかだ。

逆に、次の3つに当てはまるならJSONを使うべきじゃない。

条件 なぜダメか
テーブルを分けて正規化できる 普通のカラムで持てば、検索も並べ替えも自由にできる
頻繁に値を更新したい JSONは「一部だけ更新」が苦手で毎回全体を書き直す
その値で検索・絞り込みをしたい データが増えると全件スキャンになり遅くなる

JSONデータ型はデータベースの便利な機能を捨てる代わりに柔軟性を得る、最後の切り札だ。


4. DBが諦めた分、コードが守れ

それでもJSONを選んだとしよう。ならば、次の罠が待っている。

例を挙げてみる。

// フロントエンドから受け取ったJSONをそのままDBに保存
var data map[string]interface{}  // 「何でも入れられる箱」
json.NewDecoder(r.Body).Decode(&data)

jsonBytes, _ := json.Marshal(data)
db.Exec("INSERT INTO user_settings (setting_json) VALUES (?)", jsonBytes)

map[string]interface{} は「キーも値も型もなんでもOK」という箱だ。フロントから来たものをそのまま受け取って、そのままDBに流している。

これの何が問題か。

DBは何も守ってくれない。 { "theme": "dark", "retry_count": "無限" } が送られてきても、そのままDBに保存される。数値のフィールドに文字列が入っても、誰も気づかない。

アプリも守っていない。 map で受けてスルーしているから、コードのどこにも「何が正しいデータか」が書かれていない。後でこのカラムを読もうとした人は「ここに何が入ってるんだ?」と首をかしげることになる。

壊れたデータはいつか必ずやってくる。 フロントエンドのバグ、APIの直叩き、テストデータの混入。「フロントでバリデーションしているから大丈夫」は危険だ。壊れたデータが入ったとき、アプリが突然落ちる。

DBがデータを守ってくれない分、アプリ側が全部引き受けなければならない。だから、構造体でスキーマを定義する。

// 「user_settingsのJSONには何が入るか」を明示する
type UserSetting struct {
    Theme       string `json:"theme" validate:"required,oneof=light dark system"`
    ShowProfile bool   `json:"show_profile"`
}

これで「themeにはlight/dark/systemのどれかが入る」「show_profileはtrue/false」だとわかる。コードが仕様書になる。

さらに、保存する前にバリデーション(検証)を通す。

func SaveUserSetting(db *sql.DB, rawJSON []byte) error {
    var setting UserSetting

    // ① 構造体に当てはめる(型が合わなければここで失敗)
    if err := json.Unmarshal(rawJSON, &setting); err != nil {
        return fmt.Errorf("データの形式が不正です: %w", err)
    }

    // ② 値の内容を検証する("無限"のような不正値を弾く)
    if err := validate.Struct(setting); err != nil {
        return fmt.Errorf("バリデーションエラー: %w", err)
    }

    // ③ 構造体から再度JSONに変換してDBへ保存
    //    (定義にないフィールドはここで自動的に除去される)
    saveBytes, _ := json.Marshal(setting)
    _, err := db.Exec("INSERT INTO user_settings (setting_json) VALUES (?)", saveBytes)
    return err
}

一度構造体に変換してから再びJSONにすることで、「定義にない余計なフィールド」が混入するのも防げる。柔軟性はDBに任せる。規律はアプリが持つ。 それがJSONを使うときの正しい分担だ。


5. ALTER TABLEの呪いは、DBを変えても消えない

JSONを選んだ代償は、まだある。

「やっぱり theme じゃなくて color_mode という名前にしたい」。そう思ったとき、普通のカラムなら1行で終わる。

-- 普通のカラムなら1行
ALTER TABLE user_settings RENAME COLUMN theme TO color_mode;

でもJSONの中身は ALTER TABLE で変えられない。

-- JSONの場合、中身を直接リネームする手段がない
-- 古いデータには "theme"、新しいデータには "color_mode" が混在する

すでにDBに入っている古いデータには theme が入っている。新しいデータには color_mode が入っている。読み出すたびに「古いデータかどうか」を確認して処理を分岐させる必要が出てくる。

ALTER TABLEが怖くてJSONに逃げたのに、データの移行作業をコードで全部書くことになる。 具体的にはこうなる。

// 読み出すたびに、古いデータか新しいデータかを判定する
func GetColorMode(setting UserSetting) string {
    if setting.Version == 1 {
        return setting.Theme      // v1は "theme" という名前だった
    }
    return setting.ColorMode      // v2以降は "color_mode"
}
// → バージョンが増えるたびに、この分岐が伸び続ける

対策として、最初からバージョン番号を持たせておく方法がある。

type UserSetting struct {
    Version int            `json:"v"`     // 1, 2, 3 ...と増やす
    Theme   string         `json:"theme"` // v1の時代のフィールド
}

バージョンを見て処理を分岐させれば、古いデータも新しいデータも両方扱える。ただ、このロジックをずっと書き続けることになる。これがJSONを選んだときの本当のコストだ。


おわりに

JSONに逃げた。でも逃げた先には、複雑なクエリと、守られないデータと、コードに書かれない仕様が待っていた。

テーブルを分けて正規化できるなら、素直にカラムを切り出す。

それでもJSONを選ぶなら、覚悟を持って選ぶ。構造体を定義する。バリデーションを通す。データ移行の複雑さをアプリ側で引き受ける。DBが捨てた責任を、アプリが全部拾い上げる。

JSONに逃げるな。逃げるなら、規律まで捨てるな。


参考

0
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
0
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?