Ruby on Rails|安全なリファクタリングのための影響範囲特定術 親子テーブルのデータ重複を解消した事例
はじめに
こんにちは!グロービスでDevEx(デベロッパーエクスペリエンス)チームに所属するemi084です。
弊社が提供する「GLOBIS 学び放題」は、リリースから9年以上が経過しました。長く運用を続ける中でいくつかの技術的負債が見られるようになっており、その一つが、ユーザー情報が複数のテーブルにまたがって存在していたことでした。この問題を解消すべく、ユーザー情報を一つのマスターテーブルに集約する取り組みを開始したのですが、コードの影響範囲を特定する作業は、想像以上に困難でした。
本記事では、コードを静的に眺めるだけでは見つけられない影響箇所を、複数の調査手法を駆使して特定した、実践的なアプローチをご紹介します。
問題の背景:データ重複が引き起こす課題
まず、私たちが向き合ったテーブル構造をご覧ください。歴史的な経緯により、auth_users
(親テーブル)とusers
(子テーブル)の両方にname
やemail
といった情報が重複して存在する状態でした。
auth_users (親テーブル) |
users (子テーブル) |
---|---|
id (PK) | id (PK) |
name (氏名) | auth_user_id (FK) |
email (メールアドレス) | name (氏名) |
(その他、認証関連情報) | email (メールアドレス) |
(その他、ビジネスドメイン情報) |
このデータ重複は、開発現場で地味ながらも厄介な問題を頻発させていました。
- データ不整合のリスク: ユーザー情報更新時に片方のテーブルしか更新されず、データが食い違う危険性。
-
開発効率の低下: 「どちらの
name
を参照するのが正だっけ?」という確認が毎回発生し、開発やレビューの効率を阻害。
これらの課題を根本から解決するため、users
テーブルから重複しているname
とemail
を削除し、auth_users
にデータを集約することを決断しました。
統合戦略:段階的なアプローチが鍵
データ統合のような影響の大きい変更は、一度に行うとリスクが非常に高まります。そこで、私たちは安全性を最優先し、段階的に進めるアプローチを取りました。
delegate
でシンプルな参照を片付ける
ステップ1:まず、最もシンプルな参照箇所を修正するため、Railsのdelegate
メソッドを活用しました。
# app/models/user.rb
class User < ApplicationRecord
# Userは一つのAuthUserを持つ
belongs_to :auth_user
# user.name や user.email が呼ばれたら、
# 関連するauth_userの同名メソッドを呼び出すように委譲
delegate :name, :email, to: :auth_user
end
この一行で、user.name
のような単純な参照は、自動的にuser.auth_user.name
を呼び出すようになります。多くのコードを書き換えることなく、影響範囲をぐっと絞り込むことができました。
delegate
では対応できないケースの特定
ステップ2:しかし、delegate
で対応できるのは単純な参照のみです。以下のような複雑な処理は、個別に発見し、修正する必要がありました。
-
where
句での検索:User.where(name: 'John Doe')
-
更新処理:
user.update(name: 'New Name')
-
生SQLやArel:
User.find_by_sql("SELECT * FROM users WHERE ...")
ここからが、地道な調査作業の始まりでした。
影響範囲の特定手法:3つのアプローチ
残りの影響箇所を特定するため、私たちは以下の調査手法を組み合わせました。
⚠️ 重要な注意点
これからの調査手法は、開発環境やステージング環境でのみ実施してください。本番環境で実行すると、パフォーマンスへの影響、ログ容量の増大、予期せぬ副作用などのリスクがあります。
where
句の監視:ActiveRecord::PredicateBuilder
でクエリを動的に捕捉
1. where
句での使用箇所を特定するため、Railsのクエリ生成部分(ActiveRecord::PredicateBuilder
)を一時的に拡張し、該当クエリの実行時にログを出力する仕組みを導入しました。
グロービスのSlackには、かみぽ(@kamipo)さんに参加していただき、不定期でRailsの困りごとについて相談に乗っていただいています。このアイディアも、かみぽさんから教えていただいたものです。
# config/initializers/predicate_builder_logger.rb
# ActiveRecordのクエリビルダーにログ出力機能を追加するモンキーパッチ
module PredicateBuilderExtension
def build(attribute, value)
target_columns = ['name', 'email']
target_table = 'users'
# 監視対象のテーブルのカラムがクエリ条件で使われたらログに出力
if attribute.to_s.in?(target_columns) && relation.table.name == target_table
# 呼び出し元のコード箇所を特定し、ログに出力
Rails.logger.warn "DETECTED QUERY: Table `#{relation.table.name}` column `#{attribute}` used. CALLED FROM: #{caller(2, 1).first}"
end
# 本来の処理を呼び出す
super
end
end
# Railsアプリケーションに上記拡張を適用
ActiveRecord::PredicateBuilder.prepend(PredicateBuilderExtension)
この状態でテストを実行したり、ステージング環境でアプリケーションを動かしたりすることで、User.where(name: ...)
のようなコードがどのファイルの何行目で実行されているかを正確に特定できました。
after_commit
コールバックで変更を検知
2. 更新処理の追跡:バッチ処理や非同期ジョブなど、見つけにくい更新処理を洗い出すため、after_commit
コールバックを利用しました。DBへの保存が成功した直後に、変更内容と呼び出し元をログに出力します。
# app/models/user.rb (調査期間中のみ追加)
class User < ApplicationRecord
# nameかemailが変更されたコミット後に実行(本番環境では動かないように制御)
after_commit :track_name_email_updates, if: -> {
(saved_change_to_name? || saved_change_to_email?) && !Rails.env.production?
}
private
def track_name_email_updates
changes = saved_changes.slice('name', 'email')
# gemsやライブラリ内部からの呼び出しを除外し、アプリケーションコードの呼び出し元を特定
caller_info = caller.find { |line| line.include?(Rails.root.to_s) && !line.include?('/gems/') }
Rails.logger.warn "DETECTED UPDATE: User(id: #{id}) updated. CHANGES: #{changes}. CALLED FROM: #{caller_info}"
end
end
この仕組みにより、管理画面からの手動更新からバッチ処理まで、あらゆるデータ更新のトリガーを網羅的に洗い出すことができました。
grep
による網羅的な静的検索
3. 生SQL・Arelの発見:上記の方法でも見つけられない生SQLやArelは、最終的にgrep
でコードベースを網羅的に検索しました。地道ですが、見落としを防ぐための重要な最終チェックです。
# 'users.' でテーブルを直接参照している箇所を検索 (コメントは除外)
grep -r "users\\." app/ lib/ --include="*.rb" | grep -v "#"
# より精度を上げ、nameかemailに絞って検索
grep -rn "users\\.\\(name\\|email\\)" app/ lib/
# 文字列リテラル内で使われている箇所も検索
grep -r '#{.*users' app/ lib/
このgrep
調査によって、レポート機能で使われていた複雑なSQLなど、忘れ去られていた参照箇所も発見できました。
調査からの学び
今回のリファクタリングでは、各手法の使い分けと、そこから得られた学びが成功の鍵でした。
手法 | 主な使いどころ |
---|---|
delegate |
まず最初にやるべきシンプルな参照の整理。 |
ActiveRecord::PredicateBuilder |
where 句など、検索条件での動的な使われ方を発見したいとき。 |
after_commit |
同期・非同期を問わず、全てのデータ更新処理を追跡したいとき。 |
grep 検索 |
上記で見つからない箇所を静的コードから洗い出す最終チェック。 |
この経験から、私たちは以下の3つの重要な教訓を得ました。
- 静的解析だけでは不十分。動的な調査が不可欠: コードを読むだけでは実際の使われ方は分かりません。アプリケーションを動かしてログを取ることで、見えない利用箇所を発見できました。
- 手法の組み合わせで調査の網羅性を高める: どの手法も万能ではありません。それぞれの長所を活かし、組み合わせることで調査の精度が向上します。
- 急がば回れ。調査こそが成功への近道: 初期調査に時間をかけることは、後の手戻りを防ぎ、結果的にリファクタリングを最も早く、安全に完了させるための鍵でした。
まとめ
今回の成功の鍵は、「調査に時間をかけ、影響範囲を正確に把握する」という段階的なアプローチそのものでした。
delegate
による単純な箇所の整理から始め、Railsの機能を活用した動的な追跡、そしてgrep
による静的な網羅的チェックといったように、複数の調査手法を組み合わせたことで、9年分の歴史を持つコードベースでも安全にデータ集約を進めることができました。大規模なリファクタリングに挑む際は、ぜひ「急がば回れ」の精神で、徹底的な調査に時間をかけてみてください。
今回のデータ重複の解消は、私たちが継続的に行っている技術的負債への取り組みの一例です。グロービスでは、このような改善活動により、安定したサービス提供と、開発者が健全な環境で開発できることの両立を目指しています。
この記事で紹介したアプローチが、皆さんの開発の参考になれば幸いです。
Discussion