RemitAid Tech Blog
😓

MySQL の JSON カラム - 運用で感じた「めんどくささ」

に公開

はじめに

こんにちは!株式会社RemitAidのremitaid_wataruです。

RemitAid では「海外ラクヤス振込」という国際送金サービスと、海外展示会向けの「デジタル決済」というクレジットカード決済サービスを提供しています。

開発初期、「柔軟性」を重視していくつかのカラムに JSON カラムを採用しましたが、運用していくうちに「なんか JSON めんどくさいな...」という感覚が積み重なっていきました。

この記事では、理論的な問題というより、実際の運用で感じた JSON カラムの「めんどくささ」 について書きます。

背景: なぜ JSON カラムを使い始めたのか

国際送金や決済のシステムでは、国や地域によって必要な情報が大きく異なります。

例えば、アメリカでは Routing Number が必要だったり、ヨーロッパでは IBAN が必要だったりします。

このような「国によって構造が異なるデータ」を扱うとき、JSON カラムは魅力的に見えました。

-- 公開用に加工しています
CREATE TABLE `accounts` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `merchant_id` bigint unsigned NOT NULL COMMENT '加盟店ID',
  `details` json DEFAULT NULL COMMENT '詳細',  -- ここに国ごとの情報を柔軟に格納
  `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL COMMENT 'ステータス',
  `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);

また、カード決済の設定でも JSON カラムを採用しました。

-- 公開用に加工しています
CREATE TABLE `settings` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '加盟店ルールID',
  `merchant_id` bigint unsigned NOT NULL COMMENT '加盟店ID',
  `card_brands` json DEFAULT NULL COMMENT '利用可能なブランドと手数料率', -- ここにブランドと手数料率を格納
  `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);

card_brands には以下のような JSON を格納していました。

[
  { "brand": "visa", "fee_rate": "0.035" },
  { "brand": "mastercard", "fee_rate": "0.035" },
  { "brand": "amex", "fee_rate": "0.04" }
]

JSON カラムで感じた「めんどくささ」

実際に運用していくと、理論的な問題以前に、日常の操作が地味にめんどくさいという問題が浮上してきました。

1. SQL で見るのがみにくい

MySQL クライアントで確認するとき、JSON カラムは読みにくいです。

mysql> SELECT id, card_brands FROM settings WHERE merchant_id = 123;
+----+----------------------------------------------------------------------+
| id | card_brands                                                          |
+----+----------------------------------------------------------------------+
|  1 | [{"brand": "visa", "fee_rate": "0.035"}, ...]                        |
+----+----------------------------------------------------------------------+

データの中身を確認したいだけなのに、一行に圧縮された JSON を目で追うのは疲れます。JSON_PRETTY() 関数を使えば整形できますが、毎回書くのも面倒です。

2. アプリケーションでパースするのがめんどくさい

Go で JSON カラムを扱うとき、パース処理とエラーハンドリングを毎回書く必要があります。

type CardBrand struct {
    Brand   string `json:"brand"`
    FeeRate string `json:"fee_rate"`
}

type CardBrands []*CardBrand

// null.JSON から CardBrands への変換関数を用意
func NewFromNullJSON(j null.JSON) (CardBrands, error) {
    if !j.Valid {
        return CardBrands{}, nil
    }
    var cardBrands CardBrands
    err := j.Unmarshal(&cardBrands)
    return cardBrands, err
}

// データ取得のたびにパース処理が必要
cardBrands, err := NewFromNullJSON(settings.CardBrandsJSON)
if err != nil {
    return apperr.NewAppErr(apperr.Internal, err)
}

普通のカラムなら型変換が不要で、データベースから取得した値をそのまま使えます。カスタム型の定義、変換関数、エラーハンドリングと、JSON カラムを使うとパース処理の実装コストが積み重なっていきます。

3. 手動オペレーションで事故る

RemitAid では、いくつかの処理がまだシステム化されておらず、手動で SQL を実行して対応することがあります。

カードブランド設定での事故例

「Visa の手数料率を 0.035 から 0.03 に変更する」という作業で、以下のような事故が起こり得ます。

-- 既存のデータを確認
SELECT card_brands FROM settings WHERE merchant_id = 123;
+---------------------------------------------------------------------------------+
| card_brands                                                                     |
+---------------------------------------------------------------------------------+
| [{"brand": "visa", "fee_rate": "0.035"}, {"brand": "mastercard", ...}]         |
+---------------------------------------------------------------------------------+

-- やりたかったこと: Visa の fee_rate だけ変更
UPDATE settings
SET card_brands = '[{"brand": "visa", "fee_rate": "0.03"}]'
WHERE merchant_id = 123;

-- 結果を確認すると...
SELECT card_brands FROM settings WHERE merchant_id = 123;
+------------------------------------------+
| card_brands                              |
+------------------------------------------+
| [{"brand": "visa", "fee_rate": "0.03"}]  |
+------------------------------------------+

Mastercard、Amex等 の設定が全部消えてしまいました。

JSON カラムは「全体の上書き」になるため、配列の中の一部だけ変更するには以下のように書けば可能ではあります。

-- 配列の中から visa を探して更新する必要がある
UPDATE settings
SET card_brands = JSON_SET(
    card_brands,
    -- visa のインデックスを特定する必要がある(ここでは0番目と仮定)
    '$[0].fee_rate',
    '0.03'
)
WHERE merchant_id = 123;

しかし、配列の何番目に visa があるかは事前に確認しないとわかりません。もし visa が 1番目にあれば、0番目の mastercard の手数料が変わってしまいます。

緊急対応中にこんな複雑な SQL を書き、配列のインデックスまで確認するのは現実的ではありません。

変更したい部分だけが変わり、他は触らないのが理想ですが、JSON カラムではそれが難しいのです。

私たちの対応

口座情報: ハイブリッド構成への移行

当初は「どんな項目が必要か分からない」という理由で JSON にしていましたが、運用を続けるうちに:

  • 銀行名、銀行住所、口座番号、受取人名、SWIFT コード などはほぼ必須

ということが分かってきました。

そこで、よく使う項目は個別カラムに移行しました。

CREATE TABLE `accounts` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `merchant_id` bigint unsigned NOT NULL COMMENT '加盟店ID',
  `bank_name` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL DEFAULT '' COMMENT '銀行名',
  `bank_address` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL DEFAULT '' COMMENT '銀行住所',
  `account_number` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL DEFAULT '' COMMENT '口座番号',
  `beneficiary_name` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL DEFAULT '' COMMENT '受取人名',
  `swift` varchar(11) COLLATE utf8mb4_0900_bin DEFAULT NULL COMMENT 'SWIFTコード(8桁もしくは11桁)',
  `others` json DEFAULT NULL COMMENT 'その他',  -- ここにその他の情報を柔軟に格納
  `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL COMMENT 'ステータス',
  `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);

基本的に必須の項目は個別カラム、国によって異なる追加情報だけ JSON というハイブリッド構成にしました。

これにより:

  • 通常のカラムとして扱えてシンプル
  • 柔軟性も失わない

という良いとこ取りができました。

JSON カラムの適切な使いどころ

これまでの経験から、JSON カラムが適している場面・適していない場面が見えてきました。

JSON カラムが適している場面

✅ 本当に不定形なデータ

-- エラーログ、スタックトレースなど
CREATE TABLE error_logs (
  id BIGINT PRIMARY KEY,
  error_type VARCHAR(100) NOT NULL,
  error_details JSON,  -- 毎回構造が違う
  created_at DATETIME NOT NULL
);

エラーの種類によって含まれる情報が大きく異なる場合、JSON が有効です。

✅ 外部 API のレスポンス保存

-- 外部 API からのレスポンスをそのまま保存
CREATE TABLE payment_processor_responses (
  id BIGINT PRIMARY KEY,
  transaction_id BIGINT NOT NULL,
  processor VARCHAR(50) NOT NULL,
  raw_response JSON NOT NULL,  -- デバッグ用に全体を保存
  created_at DATETIME NOT NULL
);

後で「あの時 API は何を返していたか?」を確認する用途には便利です。ただし、業務で使う値は個別カラムに抽出することをお勧めします。

✅ 検索・更新しないメタデータ

-- UI の設定など、読むだけで更新頻度が低いデータ
CREATE TABLE user_preferences (
  user_id BIGINT PRIMARY KEY,
  ui_settings JSON,  -- { "theme": "dark", "language": "ja" }
  notification_settings JSON
);

ユーザーごとに異なり、検索条件にならず、全体を一括で読み書きするだけなら JSON で問題ありません。

JSON カラムが適していない場面

❌ ビジネスロジックの中核となるデータ

-- 手数料計算、残高管理など、金額に関わるデータ
-- ✗ 手動オペレーションでミスしやすく、金額事故のリスク

❌ 頻繁に一部だけ更新するデータ

-- カードブランド設定のように、特定の項目だけ変更することが多いデータ
-- ✗ JSON_SET を使わないと他のデータを消してしまう
-- ✗ 配列の場合はインデックスを特定する必要がある

❌ SQL で頻繁に検索・集計するデータ

-- WHERE 句や JOIN 条件で使うデータ
-- ✗ JSON_EXTRACT が面倒、インデックスも効きにくい

❌ 手動オペレーションが発生するデータ

-- 緊急対応などで SQL を直接実行することがあるデータ
-- ✗ JSON_SET の構文ミスや全体上書きのリスク

まとめ: 「めんどくさい」は技術的負債のシグナル

JSON カラムの問題は、理論的な欠点だけではありません。

  • SQL で見るのがめんどくさい
  • アプリでパースするのがめんどくさい
  • 一部だけ更新するのがめんどくさい
  • 手動オペレーションで事故りやすい

こうした日常的な「めんどくささ」 こそが、JSON カラムが適していないシグナルでした。

「柔軟性のため」だけで JSON を選ぶのではなく、実際の運用シーンを想像してから判断することをお勧めします。

RemitAid では、今後も機能開発を進めつつ、日々技術的負債に向き合いリファクタリングを続けていきます。


告知

Podcast 「RemiTalk」では普段の会社の様子を赤裸々に語っています。
もし良ければ聴いてみてください!
https://podcasts.apple.com/jp/podcast/remitalk/id1826516525

Podcast 文字起こしはこちら
https://note.com/remitaid

またRemitAid では一緒に働く仲間を募集しています。
ご興味をお持ちの方は以下のリンクからご確認ください!
https://youtrust.jp/recruitment_posts/ad655de82471df86af4f19469fe4c0de
https://youtrust.jp/recruitment_posts/7141d690aaa5ed348de45757da069e81

RemitAid Tech Blog
RemitAid Tech Blog

Discussion