リファクタリングのすすめ:古いコードを蘇らせる手法と効果
統計や研究データでは、ソフトウェアのライフサイクルに占める保守(運用・修正・コード改善)の比率が大きいことが広く指摘されています。60%前後に達するケースがある一方で、業種やシステム特性によって幅がある点も押さえておきたいところです^1^。また、DORAの継続的デリバリー研究では、上位群のチームが低い変更失敗率(例: 0〜15%程度)と高いデプロイ頻度、短いリードタイムを併せ持つことが示されています^2^^3^。公開事例やツールベンダのレポートでも、バグの密集箇所が変更履歴の“ホットスポット”と強い相関を示すという知見が繰り返し報告されています^4^。さらに、ロジックの複雑度や依存が蓄積するほど、変更の読み解きに時間がかかり、結果としてサイクルタイムが伸びがちだという実務的傾向は、コードヘルスの観点からも説明可能です^5^。これらの事実は、リファクタリング(挙動を変えずに内部構造を改善するコード改善)が単なる美意識ではなく、変更容易性や品質を通じて欠陥率とフロー効率の双方を押し下げる投資であることを示唆します。古いコードに手を入れる怖さはよく分かります。しかし、恐れが先行して放置した先に待つのは、予測不能な障害対応と採用・育成コストの増大です。今こそ、測りながら直し、直しながら速くするという現実的なやり方に舵を切りましょう。
なぜ今、リファクタリングか
事業スピードを支えるのは新機能だけではありません。複雑さや依存の増大は、変更の影響範囲を読みにくくし、結果としてサイクルタイムや欠陥リスクに跳ね返る傾向があります^5^。私はまず、DORAの4指標(デプロイ頻度、変更リードタイム、変更失敗率、サービス復旧時間)とコード健全性の関係をチームで合意することから始めます^3^。初心者の方に向けて補足すると、変更失敗率は「本番変更のうち障害やロールバックにつながった割合」を指します。これは不必要な分岐や副作用の多さ(テストしづらさ)と結びつきやすい。デプロイ頻度とリードタイムは、テスト容易性と依存の少なさ(小さく安全に出せるか)に依存します。復旧時間は、障害時に読み解くべき関数やクエリの数を減らすことで短縮できます。これらを要所で可視化し、アーキテクチャの“摩擦”が利益にどう響くかを言葉ではなく数字で示すのが出発点です。
ROIの試算は、欠陥の発見から修正までの平均コストと、変更にかかる時間の分布を使って現実的に行えます。たとえば、月に十件のバグ修正があり一件あたりの総コストが十万円、変更の平均リードタイムが二日で、リファクタリング後に欠陥率を三割、リードタイムを二割改善できる見込みがあるなら、月あたり四十万円相当のコストと計五〜六日のリードタイムが回収対象となります。これはあくまで仮定に基づく概算ですが、人件費で算出したリファクタリングの工数を当て、回収期間を四〜十二週の幅で見積もると、経営の言葉で議論しやすくなります。重要なのは“どれだけ直すか”ではなく“どこから直すか”であり、変更回数が多いのにバグ密度が高いファイルや、循環的複雑度が閾値を超える関数に集中させると、初速が出やすくなります。
ホットスポットの特定と前提環境
前提として、この記事のコード例はNode.js 18以上、TypeScript 5、Jest 29、ESLint 8の環境を想定します。まずはレポジトリ内の“熱い”ファイルを抽出します。Gitの履歴から単純に変更回数を数えるだけでも、優先順位付けの精度が上がります。次の小さなスクリプトは、JavaScriptとTypeScriptの変更頻度を集計して、上位ファイルを出力します^4^。ローカルで実行する際は、対象リポジトリのルートで保存し、Nodeで起動してください。履歴が非常に大きい場合は、対象期間を—sinceや—afterで絞ると安定します。
import { execSync } from 'node:child_process';
function hotspot(limit = 20) {
const log = execSync('git log --name-only --pretty=format: -- "*.ts" "*.js"', { encoding: 'utf8' });
const counts = new Map();
for (const line of log.split('\n')) {
if (!line.trim()) continue;
counts.set(line, (counts.get(line) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
}
try {
const result = hotspot();
for (const [file, times] of result) {
console.log(`${times} ${file}`);
}
} catch (e) {
console.error('Failed to compute hotspots', e);
process.exit(1);
}
このような“ホットスポット”は、影響範囲の推測に時間がかかり、レビューも詰まりやすくなっていることが多い領域です^4^。ここにテストの安全網を敷き、仕様に触れない内部構造の整理(リファクタリング)を段階的に進めると、早期にバグの再発を防ぎながらスループットを上げられます。
壊さないための安全網づくり
古いコードのリファクタリングは、まず挙動の固定から始めます。テストがないなら、設計思想を問い直す前に現状の振る舞いをキャラクタライズするテスト(キャラクタライゼーションテスト)を素早く書きます。目的は正しさの証明ではなく、既存の“癖”を把握し、意図しない挙動変更を検知することです。次のようなJest(JavaScriptのテストフレームワーク)のテストは、割引計算の歴史的仕様を固定するために使えます。初めての方は、itやexpectが「動作の例」と「期待値」を記述するための構文だと理解すれば十分です。
import { describe, it, expect } from '@jest/globals';
import { legacyPrice } from '../src/legacyPrice';
describe('legacyPrice characterization', () => {
it('keeps historical quirks for VIP with coupon', () => {
const price = legacyPrice({ base: 1000, vip: true, coupon: 'SPRING50', date: '2021-03-10' });
expect(price).toBe(450); // 仕様書に残した歴史的挙動
});
});
テストがある程度整ったら、計測も日常化します。パフォーマンスに関する不安は、リファクタリングの最大の心理的障壁になりがちです。そこで、ビジネスに効く指標に直結する計測点を決めます。具体的には重要ユースケースのp95レイテンシ、CPU時間、メモリフットプリント、GCポーズ時間などをAPMで観測し、関数単位のマイクロベンチはNodeのパフォーマンスAPIで補完します。次の簡易ベンチは、リファクタリング前後の合計処理を比較するものです。結果は環境依存であることを前置きした上で、差異が小さいことを確認できれば心理的安全性は高まります。
import { performance } from 'node:perf_hooks';
function legacy(items: number[]) {
let s = 0;
for (let i = 0; i < items.length; i++) s += items[i];
return s;
}
function refactored(items: number[]) {
return items.reduce((a, b) => a + b, 0);
}
function bench(fn: (a: number[]) => number, label: string) {
const data = Array.from({ length: 100000 }, (_, i) => i % 10);
const t0 = performance.now();
let sum = 0;
for (let i = 0; i < 200; i++) sum += fn(data);
const t1 = performance.now();
console.log(`${label}: ${(t1 - t0).toFixed(2)}ms, checksum=${sum}`);
}
bench(legacy, 'legacy');
bench(refactored, 'refactored');
ローカル計測例としては、両者の実行時間が誤差範囲で並ぶことも珍しくありません。意図は速度を上げることではなく、速度を落とさずに変更容易性を上げることにあります。速度の議論は数値で行い、設計の議論はテストで守る。この順序が現場の混乱を減らします。
現場で効く手法とコード例
手法は派手である必要はありません。小さく、テストで守られ、可読性とテスト容易性に寄与する変更から始めます。抽出、命名の改善、依存の反転、引数の整理、分岐の戦略化。どれも習慣化すれば、数週間でチームの生産性がじわりと変わります。
計算処理の抽出と早期リターン
一つの関数で集計と割引を同時に行っているコードは、責務を分割すると読みやすくなります。次の例では合計と割引計算を切り出し、テスト単位を小さくしています。こうした抽出は、典型的なリファクタリングの第一歩です^6^。
// types.ts
export interface DiscountRule { calc(total: number): number; }
// totals.ts
import { DiscountRule } from './types';
export function calcTotal(items: Array<{ price: number; qty: number }>, rules: DiscountRule[]): number {
const subtotal = sum(items.map(i => i.price * i.qty));
const discount = applyDiscounts(subtotal, rules);
return Math.max(0, subtotal - discount);
}
function sum(nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
function applyDiscounts(subtotal: number, rules: DiscountRule[]): number {
return rules.reduce((acc, r) => acc + r.calc(subtotal), 0);
}
抽出により、合計と割引のテストを個別に書けます。例外処理は境界に寄せ、上位でまとめて扱うと、ドメインロジックの可視性が上がります。
引数の整理(Parameter Object)
引数が五つ以上ある関数は、読み手に精神的負荷を与えるだけでなく、順序の取り違えによる欠陥を誘発します。意味のある塊としてオブジェクト化することで、呼び出しの自己記述性を高められます。TypeScriptの型で必須/任意を表現すれば、コンパイル時にエラーとして検知できます。
// before
export function scheduleEmail(to: string, subject: string, body: string, sendAt: Date, retries = 0) {
// ...
}
// after
export interface EmailJob {
to: string;
subject: string;
body: string;
sendAt: Date;
retries?: number;
}
export function scheduleEmail(job: EmailJob) {
// ...
}
この変換は呼び出し元の変更が伴いますが、テストが守ってくれるなら安全に進められます。
依存の反転とテスト容易性の確保
時間や外部リソースに依存する関数は、テストが不安定になりがちです。依存を注入可能にし、境界面で例外を握りつぶさないことが安定の鍵です。次の例では時計とリポジトリを注入し、失敗時には明示的に例外を投げています。これにより、ユニットテストではフェイクの依存を差し替えられるようになります。
// service.ts
export interface Clock { now(): Date }
export interface User { id: string }
export interface UserRepo { findById(id: string): Promise<User | null> }
export function createSessionService(clock: Clock, repo: UserRepo) {
return async function startSession(userId: string) {
const user = await repo.findById(userId);
if (!user) throw new Error('User not found');
return { userId, startedAt: clock.now().toISOString() };
};
}
// usage.ts
import { createSessionService } from './service';
import { MongoUserRepo } from './MongoUserRepo';
const service = createSessionService({ now: () => new Date() }, new MongoUserRepo());
service('123').then(console.log).catch(err => {
console.error('startSession failed', err);
process.exitCode = 1;
});
依存の反転により、テストではフェイクの時計やリポジトリを注入できます。例外は上位のハンドラでログと失敗の可視化に回し、MTTR短縮につなげます。
分岐の戦略化(Strategy)
配送キャリアごとに分岐が増殖するようなコードは、条件分岐を戦略マップに置き換えると、追加・変更の衝突が減ります。Strategyは、条件ごとの振る舞いをオブジェクトや関数に委ねるデザインパターンです。
// shipping.ts
type Carrier = 'SAGAWA' | 'YAMATO' | 'JP';
const fee: Record<Carrier, (kg: number) => number> = {
SAGAWA: kg => 500 + kg * 100,
YAMATO: kg => 600 + kg * 80,
JP: kg => 400 + kg * 120
};
export function shippingFee(carrier: Carrier, kg: number) {
const calc = fee[carrier];
return calc(kg);
}
条件の増減はオブジェクトの追加・削除に収まり、テスト対象が明確になります。バグが出た場合も、対象戦略のテストを特定して集中的に直せます。
副作用の隔離とI/Oの境界
ファイルやネットワークI/Oは境界に押し込み、ドメインの中心は純粋関数に寄せると、変更の安全性が飛躍的に上がります。リトライやタイムアウト、サーキットブレーカーのような横断的関心事も、境界に集約してテストします。境界のモジュールだけが例外を握り、内側は常に失敗をエラーとして伝播させると、観測可能性が担保されます。
チーム運用とスケール
リファクタリングはイベントではなく習慣です。開発プロセスに組み込むことで、惰性に負けない仕組みになります。私はプロダクトバックログに“エンジニアリング起因の価値”を明示し、スプリントごとにホットスポットへ薄く広く手を入れる戦略を取ります。変更頻度とバグ密度が高い領域に対して、まずは挙動をロックするテストを生やし、次に引数整理や抽出、依存の反転のような安全な変更を差し込む。レビューでは循環的複雑度の不変か減少、単方向依存の維持、テストの追加を合意事項として扱い、Definition of Doneに織り込みます。小さな約束の積み重ねが、数ヶ月後に大きな差になります。
計測は週次とスプリント単位で切り分けます。週次では関数レベルのメトリクスやホットスポットの更新、スプリントではDORA指標を眺め、変更失敗率とリードタイムの変化がリファクタリングのどの施策と結びついたかを振り返ります^3^。部署横断の合意が取れていれば、改善が売上や顧客体験に波及する時間差も受け入れられます。導入期間の目安は、テストの安全網が疎な場合はおおよそ三ヶ月を見込み、最初の二〜三週間でホットスポットの把握とテストの種まき、次の四〜六週間で反復的な小変更の定着、最後の期間でアーキテクチャ的な依存の反転に踏み込みます。短い区間で成功体験を刻み、次の区間で難易度を一段上げるという筋道が、現場を無理なく前に進めます。
社内の学習コストは軽くありません。だからこそ、プルリクの説明では改善の意図と影響範囲、リスクとロールバックの容易さ、計測結果を一枚のコメントでまとめ、レビューアが判断しやすい材料を提供します。自動化された静的解析やスタイルの議論はツールに任せ、レビューは設計とテストに集中させます。学習投資を踏まえたうえで、入社三ヶ月のエンジニアでも安全に変更できる設計かという観点を常に持てば、採用とオンボーディングのROIも好転します。
社内ナレッジと参照記事
継続的な改善には、共通言語が欠かせません。DORA指標の解説は整理に役立ちます。テスト設計の基礎と応用やレビューの観点が詳しくまとまっています。チームで読み合わせ、定義を合わせ続ける仕組みが、改善の速度を落としません。
まとめ
古いコードは敵ではありません。事業を支えてきた歴史そのものであり、敬意を払いながら測って、守って、整える対象です。数字で語り、テストで守り、手法は小さく地味に。これを数週間、数ヶ月と続けることで、気づけば変更失敗率は下がり、リードタイムは短くなり、深夜の障害連絡は減っていきます。あなたのチームは、どのホットスポットから挙動をロックし、どの引数整理から着手しますか。次のスプリントの計画に、たった一つの小さなリファクタリングを入れてみてください。それが、古いコードを蘇らせる最初の一歩になります。
参考文献
- SWEBOK v3.0: Software Maintenance. IEEE Computer Society. https://www.computer.org/education/bodies-of-knowledge/software-engineering
- DORA: Change Failure Rate — Definitions. https://dora.dev/definitions/change-failure-rate/
- DORA: Research and Reports (State of DevOps). https://dora.dev/research/
- CodeScene Docs: Hotspots — An excellent starting point for refactorings. https://docs.enterprise.codescene.io/versions/3.6.1/guides/technical/hotspots.html
- Software Engineering at Google — Code Health and Complexity. https://abseil.io/resources/swe-book
- Martin Fowler. Refactoring: Improving the Design of Existing Code (2nd Edition). Addison-Wesley, 2018. https://refactoring.com/