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?

レガシーPHPを壊さずリファクタリング — Claude Codeで神テーブルと激遅クエリを解体した実録

0
Last updated at Posted at 2026-09-29

引き継いだのは8年もののPHPシステム。1つのテーブルにカラムが120個、検索画面は8秒待たされ、売上レポートのクエリは15秒返ってこない。「動いてるから触るな」と言われ続けたコードでした。

これをClaude Codeと一緒に、本番を止めず・既存テストを壊さず段階的に作り替えた記録です。派手な全書き換えではなく、「怖くて誰も手を出せなかった箇所」から順に潰していった実務寄りの話をまとめます。

前提条件

  • 対象: 8年前に書かれたPHP 7.4 + MySQL 5.7 のレガシー業務システム(ある案件で引き継いだもの)
  • 特徴: テストがほぼ無い、巨大テーブル、サブクエリ地獄、ENUM乱用
  • 使ったもの: Claude Code + PHPUnit + EXPLAIN + slow query log。特別なフレームワークは使っていません
  • スタンス: 個人で受けた保守案件。一度に全部は直さない(壊したら戻せないので)

レガシーPHPのモダナイズは「アプリ層のコード」と「データ層」の両輪です。私の経験上、体感の遅さや事故の多くは後者(DB)に潜んでいることが多いので、この記事はデータ層寄りの実例が中心になります。

いきなり全書き換えを狙って一度失敗した話

最初、私は横着して「このコントローラ、全部きれいにリファクタして」とだけClaude Codeに投げました。結果、200行のメソッドが一気に別構造へ書き換わり、既存の挙動と差分が大きすぎてどこで壊れたのか切り分け不能に。テストが薄いので、通らなくなった原因を追えないのです。

これは一度revertしました。そこで方針を変え、「1コミットで1つの安全な変換だけ」 を鉄則にしました。判断基準はシンプルで、次の3つを満たすものだけをClaude Codeに依頼します。

  1. 変換前後で外から見た挙動が変わらないと言い切れる(純粋なリファクタ)
  2. その場でテストか手動確認で検証できる粒度
  3. 失敗してもrevertで元に戻せる単位

この「小さく・検証可能に・戻せる」を守ってから、リファクタリングが一気に進むようになりました。

着手順チートシート(どこから潰すか)

やみくもに直すと事故ります。私は毎回この順で「怖い箇所」を洗い出しています。

順番 対象 見つけ方 Claude Codeへの主な依頼
1 遅いクエリ slow query log / EXPLAINでtype: ALL インデックス設計・JOINへの書き換え
2 神テーブル カラム数・NULL率を集計 正規化案とマイグレーション生成
3 ENUM乱用 SHOW COLUMNSでenum(...)を検索 マスタテーブル化
4 デッドロック SHOW ENGINE INNODB STATUS ロック順序の統一案
5 ファットな関数 行数の多いメソッド抽出 責務分割・サービスクラス化

まず「遅い・壊れる」を先に潰し、コードの見た目(関数分割)は最後です。順序を逆にすると、きれいにした直後に性能問題で作り直すハメになります。

ケース1: 120カラムの「神テーブル」を分割する

一番ヤバかったのが、顧客情報が全部1テーブルに詰め込まれた120カラムの神テーブルでした。NULL率が80%を超え、どのカラムが何に使われているか誰も把握していません。

Claude Codeにはまず、いきなり直させず構造の棚卸しからやらせます。

# 手元で実行したClaude Codeへの依頼(要約)
# 1. 既存データからカラムの使われ方を分析させる
claude "information_schema.COLUMNS と実データを見て、
このテーブルのカラムを『顧客/住所/連絡先/取引条件』の
どれに属するか分類し、ER図(Mermaid)と第3正規形の分割案を出して。
既存のNULL率も添えて。マイグレーションはまだ生成しないで。"

NULL率は自分でもこう測って裏取りしました(AIの分類を鵜呑みにしない)。

SELECT
  COUNT(*) AS total,
  ROUND(100 * SUM(phone IS NULL) / COUNT(*), 1) AS phone_null_pct,
  ROUND(100 * SUM(address2 IS NULL) / COUNT(*), 1) AS addr2_null_pct
FROM customers;

分類結果を私がレビューして確定させてから、マイグレーションを生成させました。最終的に120カラム→顧客・住所・連絡先・取引条件の4テーブルに分離。NULL率は**80%→5%**まで下がり(分離後のテーブル全体での集計値)、新機能追加時に「どこに何を足すか」で悩むことが減りました。

学びとしては、正規化は「テーブルを分ける技術」ではなく「データの意味を明確にする技術」だということ。過度にやるとJOINの嵐になるので、私は第3正規形で止めています。

ケース2: 15秒のクエリと8秒の検索を潰す

売上レポートは表示に15秒かかっていました。中を見るとサブクエリが3段ネストで、人間には読めない代物です。Claude CodeにEXPLAINの結果ごと貼って相談しました。

mysql> EXPLAIN SELECT ... (サブクエリ3段ネスト);
+----+-------------+-------+------+------+-------+
| id | select_type | table | type | rows | Extra |
+----+-------------+-------+------+------+-------+
|  1 | PRIMARY     | ...   | ALL  | 52万 | ...   |  ← フルスキャン
+----+-------------+-------+------+------+-------+

やったことは2つです。サブクエリをJOINに書き換え、さらに集計を中間テーブルにバッチで事前計算する構成に変えました。効果の測り方は、書き換え前後で同じクエリを10回実行し平均レスポンスを比較。この案件のデータ量・環境では15秒→0.3秒でした。月末に固まっていたレポート画面が即表示になりました。

もう一つ、顧客検索(50万レコード)が8秒かかっていた件は、もっと単純でした。EXPLAINでtype: ALL=インデックスが無いだけ。WHERE句に合わせて複合インデックスを1本足すだけです。

-- 検索条件(status + created_at)に合わせた複合インデックス
CREATE INDEX idx_customers_status_created
  ON customers (status, created_at);

これで8秒→0.1秒(同じ条件で計測した値です)。ユーザーから「サクサクになった」と言われました。スロークエリの体感悪化は、私が関わった案件ではインデックスが原因のことが多かったです。EXPLAINでtype: ALLが出たら即対処、を合言葉にしています。

ケース3: ENUM地獄をマスタテーブルに逃がす

ステータス系のカラムが軒並みENUM型でした。ステータスを1つ追加するたびにALTER TABLEが必要で、本番稼働中の変更が地獄です。

Claude CodeにはENUMの定義を読ませ、TINYINT+マスタテーブルへの移行案とデータ移行スクリプトを出させました。

-- Before: 追加のたびに本番でALTERが必要
status ENUM('draft','active','closed')

-- After: 定義はマスタへ。追加はINSERTだけで済む
status TINYINT NOT NULL,  -- statuses.id を参照

これで、ステータス追加がマスタテーブルへのINSERT/UPDATEだけで完結し、スキーマ変更そのものが不要になりました。MySQLのENUMは一見便利ですが、選択肢が将来変わる見込みがあるならマスタテーブル+外部キーにしておく方がよかった、とこの案件で痛感しました。

ケース4: 1日5〜10回のデッドロックを止める

受注処理のピーク時に、デッドロックが1日5〜10回発生してユーザーにエラーが出ていました。原因調査はSHOW ENGINE INNODB STATUSです。

SHOW ENGINE INNODB STATUS\G
-- LATEST DETECTED DEADLOCK セクションを読む
-- → 2つのトランザクションが逆順でテーブルをロックしていた

ログを読ませると、2つのトランザクションが逆順でテーブルをロックしていました。Claude Codeにコードを渡し、ロック取得順を全処理で統一する修正案と、トランザクション範囲を最小化する書き換えを出させました。結果、修正後はデッドロックが発生しなくなりました。ついでにロック競合が減り、スループットも約20%上がっています(同条件の負荷試験での比較です)。

デッドロック対策の基本は「ロック順序の統一」と「トランザクション範囲の最小化」。派手さはないですが、INNODB STATUSのログが原因特定に本当に効きます。

AIに任せる時にCLAUDE.mdへ書いておくルール

レガシー相手だと、AIが「存在しないメソッド」を自信満々で使ってくることが何度かありました。私はうちのCLAUDE.mdに、リファクタ用の制約を明文化しています。

## リファクタリングの鉄則(このリポジトリ固有)
- 1コミット = 1つの安全な変換のみ。挙動を変える提案は別PRに分ける
- 外部ライブラリのメソッドを使う時は公式ドキュメントのURLを添える
- DB変更は必ずマイグレーションで。直接ALTER禁止(本番は pt-online-schema-change)
- 各ステップの最後に該当範囲のテストを実行し、結果を報告する

「信頼するが検証する」を仕組みに落とし込むのがコツで、生成コードの自動テスト実行をワークフローに組み込むと、目視で確かめる手間はかなり減ります(テストの実行時間と失敗時の調査は残ります)。

アンチパターン早見表

引き継ぎ案件で私が繰り返し出会う典型と、その処方箋です。持ち帰り用にどうぞ。

アンチパターン 症状 改善
神テーブル(120カラム) NULLだらけ・意味不明 第3正規形で分割
ENUM型ステータス 追加のたびに本番ALTER TINYINT+マスタテーブル
サブクエリ3段ネスト 激遅・読解不能 JOIN+中間テーブルで事前計算
インデックス無し type: ALLのフルスキャン 条件に合った複合インデックス
バラバラなロック順 デッドロック多発 ロック順序の統一

まとめ

レガシーPHPのリファクタリングでいちばん効いたのは、賢いプロンプトでも一括変換でもなく、**「小さく・検証可能に・戻せる単位でしか触らない」**という進め方でした。Claude Codeは、人間が怖くて手を出せない「動いているコードに触る」作業を淡々とこなしてくれます。だからこそ、暴走させない枠(1コミット1変換、テストで検証、マイグレーション必須)を先に用意しておくことが、速さと安全の両立につながりました。

神テーブルもENUM地獄もデッドロックも、単体では地味な作業です。でも「遅い・壊れる」から順に、戻せる単位で潰していけば、8年もののコードでも本番を止めずに作り替えられる——というのが、今回いちばん伝えたかったことです。


今回はデータ層(テーブル・クエリ)の解体を中心に書きました。アプリ層のファットコントローラをサービスクラスへ分割する話は、分量が多いので別記事で扱う予定です。Claude Codeでのレガシー改修まわりで試したことを不定期に書いているので、続きが気になる方は フォロー をどうぞ。

関連記事

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?