引き継いだのは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に依頼します。
- 変換前後で外から見た挙動が変わらないと言い切れる(純粋なリファクタ)
- その場でテストか手動確認で検証できる粒度
- 失敗しても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でのレガシー改修まわりで試したことを不定期に書いているので、続きが気になる方は フォロー をどうぞ。