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

JSON型カラムを“アンチパターンにしない”ためのGo実装戦略

6
Last updated at Posted at 2025-12-13

この記事は カオナビ 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
}

この実装のメリット

  1. 仕様の明文化: 構造体がそのまま仕様書になります。
  2. サニタイズ効果: 一度 Unmarshal して Marshal し直すことで、構造体に定義されていない「謎のフィールド」がDBに混入するのを防げます。
  3. 後方互換性の担保: 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が許すから「何も考えずに何でも入れて良い」わけではないことを肝に銘じるのが良いと思います。
なんか至極当然な帰結になった気がすると思います。
そう思います。

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