この記事は カオナビ Advent Calendar 2025 シリーズ3の14日目です。
みなさん、リレーショナルデータベースのJSON型カラムは使っていますか?
JSON型カラムは、NoSQLのような柔軟性をRDBMSに持ち込める便利な機能ですが、使い方を一歩間違えると「巨大な技術負債」になりかねない危険性も持ち合わせていると思います。
JSON型カラムとどう付き合うべきかを考え、特にGo言語での実装パターンについて書いてみます。
※この記事では、JSON型カラムに対するインデックスを用いた検索パフォーマンス等の話は扱いません。「データの整合性」と「保守性」にフォーカスします。
1. きっかけ:このカラムには何が入っているんだ?
以下のようなコードをみかけました(簡略化&わかりやすいデータにしています)。
// フロントエンドから送られてきたJSONボディ
// 内容はユーザー設定のような、不定形のデータ
var data map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
// エラーハンドリング
}
// そのままDBへ保存
jsonBytes, _ := json.Marshal(data)
_, err := db.Exec("INSERT INTO user_settings (setting_json) VALUES (?)", jsonBytes)
このコードをみて非常に困りました。
- 中身がわからない: バックエンドのコードを読んでも、setting_json に何が入るかがさっぱりわかりません。
- バリデーションがない: フロントエンドがバグで { "theme": "dark", "retry_count": "無限" } みたいなデータを送ってきても、そのまま保存されます。
これは、将来的に「データが壊れていてReadで落ちる」「何が入っているのが正解かわからない」という地獄を生み出します。
2. JSON型が“悪”ではないケース
誤解しないでいただきたいのは、「JSON型を使うな」と言いたいわけではないということです。
『SQLアンチパターン 第2版』では、属性が動的に増減する要件に対して EAVというアンチパターンが紹介されています。これは attr_name と attr_value といったカラムで属性を管理する方法ですが、クエリが複雑化し、データ整合性を保つのが難しいという欠点があります 。
同書では、このEAVの代替案の一つとして 「半構造化データ(JSONやXML)」 を挙げています 。
JSON型の採用が適しているのは以下のようなケースです。
- 属性の増減が激しい: 頻繁な ALTER TABLE が現実的ではない場合。
- サブタイプが多い: 商品カタログのように、「本」にはISBNが必要だが「服」にはサイズが必要、といった構造の違いがある場合。
- 外部APIのレスポンス保存: 外部サービスのレスポンスをそのまま監査ログとして残したい場合。
つまり、「スキーマレスな柔軟性が必要な箇所」にJSONを使うこと自体は正当な設計判断です。
問題は、「柔軟性にかこつけて、アプリケーション側での規律まで捨ててしまうこと」にあります。
3. アンチパターン:Mapで受けてバリデーションなしで保存
先ほどの map[string]interface{} で受けるパターンの何が問題か、もう少し掘り下げます。
DBは何も守ってくれない
通常のカラムであれば、INT型に文字列を入れようとすれば DB がエラーを吐きます。NOT NULL 制約も効きます。しかし、JSON型の中身に対して DB は(CHECK制約などを駆使しない限り)基本的に無関心です。
『SQLアンチパターン 第2版』でも指摘されていますが、半構造化データやEAVのような設計では、「無効なデータを拒否する方法がない」という問題が発生します。
結果として、データの整合性を担保する責任は、データベースからアプリケーションコードへと委譲されます。
アプリケーションも守っていない
ここでアプリ側も map型 で受けてスルーしてしまうと、誰もデータをチェックしない状態になります。「フロントエンドでバリデーションしているから大丈夫」は危険な妄言です。
4. 改善案:アプリ側でスキーマを持つ + バリデーション
JSON型を使うとしても、「バックエンドのコード上では厳格なスキーマを持つ」 べきです。DBスキーマが緩い分、アプリ層のスキーマでカバーします。
Step 1: Goの構造体で「正解」を定義する
map ではなく、ちゃんと 構造体 を定義します。
// UserSetting はDBのJSONカラムに格納される構造
type UserSetting struct {
Theme string `json:"theme" validate:"required,oneof=light dark system"`
ShowProfile bool `json:"show_profile"`
NotificationConfig NotificationConfig `json:"notification"`
}
type NotificationConfig struct {
EmailEnabled bool `json:"email_enabled"`
PushEnabled bool `json:"push_enabled"`
}
これで、コードを読むだけで「user_setting が持つプロパティ」や「theme には light, dark, system のいずれかが入ること」がわかります。ドキュメントとしての価値が生まれます。
Step 2: 保存前にバリデーションを通す
go-playground/validator などを使って、「DBに保存する前に」不正なデータを弾きます。
import (
"encoding/json"
"github.com/go-playground/validator/v10"
)
var validate = validator.New()
func CreateUserSetting(db *sql.DB, rawJSON []byte) error {
var setting UserSetting
// 1. 構造体へデコード(型チェック)
// 数値フィールドに文字列が来るなどの型レベルの矛盾はここで落ちる
if err := json.Unmarshal(rawJSON, &setting); err != nil {
return fmt.Errorf("invalid json structure: %w", err)
}
// 2. バリデーション(値の整合性チェック)
// 必須項目や、enum的な値の制約をチェック
if err := validate.Struct(setting); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// 3. 正規化されたJSONとして再エンコード
// 入力JSONに余計なフィールドが含まれていても、ここで削ぎ落とされる
saveBytes, err := json.Marshal(setting)
if err != nil {
return err
}
// 4. 保存
_, err = db.Exec("INSERT INTO user_settings (setting_json) VALUES (?)", saveBytes)
return err
}
この実装のメリット
- 仕様の明文化: 構造体がそのまま仕様書になります。
- サニタイズ効果: 一度 Unmarshal して Marshal し直すことで、構造体に定義されていない「謎のフィールド」がDBに混入するのを防げます。
- 後方互換性の担保: DBからReadする際もこの構造体を通すことで、予期せぬデータ形式によるパニックを防ぎやすくなります。
※サンプルコードのため非常に簡易的ですが、実際にはもっとバリデーションが必要です。
5. (プラスアルファ) 運用:JSONのバージョニング
JSONデータ構造を変えたくなった場合(例: theme を廃止して color_mode にしたい)、既存データとの整合性問題が発生するので、JSONのルートに version フィールドを持たせておくと良いと思います。
type UserSetting struct {
Version int `json:"v"` // 1, 2, ...
Data any `json:"data"`
}
あるいは、読み出し時にフィールドの有無で判断するロジックを組むかです。
いずれにせよ、「アプリケーションコード内で、新旧データの差異を吸収するロジック」が必要になります。JSON型を採用するなら、この「マイグレーションの複雑さ」をアプリ側で引き受ける覚悟も必要になると思います。
6. まとめ
JSON型カラムは、うまく使えばEAVの複雑さを回避しつつ、柔軟な設計を実現できると思います。
しかし、DBが許すから「何も考えずに何でも入れて良い」わけではないことを肝に銘じるのが良いと思います。
なんか至極当然な帰結になった気がすると思います。
そう思います。