Article

リファクタリングで1万行削除して気づいた、コードの本当の価値

高田晃太郎
リファクタリングで1万行削除して気づいた、コードの本当の価値

ソフトウェアのライフサイクルコストのうち、70〜90%は保守に費やされるという古典的な研究結果は、Webサービスが複雑化した現在でも大きくは変わっていません¹。さらに、StripeのDeveloper Coefficientでは開発者が週あたり約13.5時間を技術的負債や待ち時間に奪われると報告されています²。私たちが増やしているのは価値か、それとも将来コストか。この記事では、1万行規模の削除を伴うリファクタリングを題材に、どこをどう減らせば価値が最大化されるのかを、実装・計測・運用の視点で具体化します。p95応答時間(全リクエストの95%が収まるレイテンシ)の短縮、障害起因バグの減少、デプロイ頻度の向上はしばしば観測される改善であり、サンプル構成ではp95が180msから120msへと短縮される例も確認できます。ここで見えてくるのは、コードの価値は行数ではなく、意思決定をどれだけ安全に圧縮して届けられるかという視点です。

コードの価値は行数ではない

行数は努力の量を示すことはあっても成果の質を保証しません。工学的にも、欠陥密度はKLOC(千行あたりの行数)に比例しやすく、複雑性が増すほど変更の局所性は崩れます³。つまり、増やすほどバグの母集団が膨らむ構造的な圧力が働く。価値は、顧客が体験する応答時間や可用性、機能の正確さ、変更の容易さ(変更にかかる時間や影響範囲)といったアウトカムで測るべきで、コードは価値を運ぶ容器でしかない。容器が小さくシンプルであるほど、移送は速く安全になります。

本稿ではこの前提に立ち、まず価値の定義を数値として固定します。例えば、ユーザー向けはp95・p99(99パーセンタイル)のレイテンシと課金ロジックの一致率、運用はMTTR(平均復旧時間)と変更失敗率、チームはPRリードタイムとレビュー通過率。ここに対して、削除がどう効くかを追跡します⁴。削除とは単に消す行為ではなく、設計レベルでは抽象の再整理、実装レベルでは分岐と依存の縮約です。複雑性を支えるのではなく、複雑性の源泉を狙って断つ。この姿勢が、のちの「1万行削除」を現実的な選択肢に変えます。

1万行削除プロジェクトの現場記録

対象はB2B向けのサブスクリプションSaaSを想定したサンプルです。TypeScriptのモノリスAPIとReactフロントエンド、バックエンドはNode.jsとPostgreSQL。拡張の歴史の中で、料金計算や割引、キャンペーンの分岐が雪だるま式に増え、同義の分岐が異なるレイヤに重複しているという、よくある構図から始めます。まずは動作を固定するためのキャラクタリゼーションテスト(現状の振る舞いを凍結し意図せぬ変化を検知するテスト)を追加します。

// tests/pricing.characterization.test.ts
import {describe, it, expect} from '@jest/globals';
import {calcPrice} from '../src/domain/pricing';
import fixtures from './fixtures/pricing.json';

describe('pricing (characterization)', () => {
  for (const f of fixtures.cases) {
    it(`sku=${f.sku} plan=${f.plan} promo=${f.promo}`, () => {
      const out = calcPrice(f.input);
      expect(out.total).toBe(f.output.total);
      expect(out.breakdown).toMatchObject(f.output.breakdown);
    });
  }
});

重複していた料金ロジックは巨大なswitch文として現れていました。これを見通しの悪さの象徴として、まずは現状の一部を抜粋します。

// BEFORE: src/domain/pricing.ts (抜粋)
import {Db} from '../infra/db';

type Input = {sku: string; plan: 'basic'|'pro'|'ent'; promo?: string};
export function calcPrice(input: Input) {
  let base = 0;
  switch (input.plan) {
    case 'basic': base = 1000; break;
    case 'pro': base = 3000; break;
    case 'ent': base = 10000; break;
  }
  if (input.sku.startsWith('legacy-')) base *= 0.9;
  if (input.promo === 'BLACKFRIDAY') base *= 0.7;
  // ここに季節・国・顧客属性などの分岐が延々と続く…
  // さらにDB参照が散在し、I/Oでテストが不安定
  return {total: Math.round(base), breakdown: {base}};
}

ロジックを宣言的なポリシーへ移し、関数合成で評価する方式に変えます。I/O(外部との入出力)は境界に寄せ、ドメインは純粋関数(副作用のない関数)でテスト負荷を下げます。

// AFTER: src/domain/pricing.ts
import {Policy, evaluate} from './pricing/policy';
import {getCatalog} from '../infra/catalog';

export type Input = {sku: string; plan: 'basic'|'pro'|'ent'; promo?: string; country?: string};
const policies: Policy<Input, number>[] = [
  (i, acc) => acc + ({basic:1000, pro:3000, ent:10000}[i.plan]),
  (i, acc) => i.sku.startsWith('legacy-') ? acc * 0.9 : acc,
  (i, acc) => i.promo === 'BLACKFRIDAY' ? acc * 0.7 : acc,
  (i, acc) => i.country === 'JP' ? Math.max(acc - 100, 0) : acc,
];

export async function calcPrice(input: Input) {
  const cat = await getCatalog(input.sku); // I/Oは境界に限定
  const start = cat.override ?? 0;
  const total = Math.round(evaluate(policies, input, start));
  return {total, breakdown: {start, policies: policies.length}};
}

// src/domain/pricing/policy.ts
export type Policy<I, A> = (i: I, acc: A) => A;
export const evaluate = <I, A>(ps: Policy<I, A>[], i: I, init: A) => ps.reduce((a, p) => p(i, a), init);

外部APIアクセスの失敗と再試行の重複も除去対象です。各所で素朴なfetchが書かれており、タイムアウトやサーキットブレーカー(連続失敗時に一時的に呼び出しを遮断する仕組み)がなく、尾を引く障害を誘発していました。ここは耐障害性を共通クライアントで包み直します。

// infra/http.ts
import fetch, {RequestInit} from 'node-fetch';
import CircuitBreaker from 'opossum';

class HttpError extends Error {status?: number;}

const breaker = new CircuitBreaker(async (url: string, init?: RequestInit) => {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), 5000);
  try {
    const res = await fetch(url, {...init, signal: controller.signal});
    if (!res.ok) {
      const e = new HttpError(`HTTP ${res.status}`);
      e.status = res.status; throw e;
    }
    return res;
  } finally {
    clearTimeout(id);
  }
}, {timeout: 6000, errorThresholdPercentage: 50, resetTimeout: 10000});

export async function httpGetJson<T>(url: string, init?: RequestInit): Promise<T> {
  let lastErr: unknown;
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const res = await breaker.fire(url, init);
      return await (res as any).json();
    } catch (err) {
      lastErr = err;
      if (attempt === 3) throw err;
      await new Promise(r => setTimeout(r, attempt * 200));
    }
  }
  throw lastErr as Error;
}

この差し替えだけでも、外部依存が不安定なときの遅延の尾部(p99など)が短縮されることが多い。サンプルでは約180msの改善が見られました。料金ロジックの純化は、テスト時間を短縮しつつ、同時に計算のばらつきを減らします。ベンチマークはローカルとステージングで段階的に測り、プロダクション相当の負荷ではダークローンチ(結果を公開せずに並走評価)で影響を記録する、という流れが安全です。

// tools/bench.ts
import {performance} from 'node:perf_hooks';
import {calcPrice} from '../src/domain/pricing';

async function main() {
  const cases = Array.from({length: 1000}, (_, i) => ({sku: `sku-${i}`, plan: 'pro' as const}));
  const t0 = performance.now();
  for (const c of cases) await calcPrice(c);
  const t1 = performance.now();
  console.log(`avg=${((t1-t0)/cases.length).toFixed(2)}ms`);
}
main().catch(e => {console.error(e); process.exit(1);});

ステージングの一例として、料金APIのp95は180msから120msへ、ガーベジコレクション(GC)時間は約15%減という測定結果が得られました。重要なのは、削除が行数の削減に留まらず、遅延と失敗確率の低下として顧客体験に還元される点です。

安全に大規模削除を進める技術

削除の本質は、正しさの境界を見つけてずらすことです。私は「純粋なコアと命令的なシェル」を意識して、ドメイン関数からI/Oを剥がし、周辺に寄せます。すると、テストは速く堅くなり、安心して切り落とせる。堅牢性を担保するため、プロパティベーステスト(仕様を例ではなく性質で検証する手法)を導入します。不変条件で守るやり方です。

// tests/pricing.properties.test.ts
import {describe, it} from '@jest/globals';
import fc from 'fast-check';
import {calcPrice} from '../src/domain/pricing';

describe('pricing properties', () => {
  it('non-negative total and monotonic by discount', async () => {
    await fc.assert(fc.asyncProperty(
      fc.record({
        sku: fc.string(),
        plan: fc.constantFrom('basic','pro','ent'),
        promo: fc.option(fc.constant('BLACKFRIDAY')),
        country: fc.option(fc.constant('JP'))
      }),
      async (input) => {
        const out = await calcPrice(input as any);
        if (out.total < 0) throw new Error('negative');
      }
    ), {numRuns: 200});
  });
});

サブシステム削除の足場として、観測とアラートも作り直します。可視化できないものは安全に壊せません。OpenTelemetryを用いて論理的なスパン(処理の区間)を一貫して付け、削除の影響と改善幅をメトリクスに結びます。

// infra/telemetry.ts
import {diag, DiagConsoleLogger, DiagLogLevel} from '@opentelemetry/api';
import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node';
import {SimpleSpanProcessor} from '@opentelemetry/sdk-trace-base';
import {OTLPTraceExporter} from '@opentelemetry/exporter-trace-otlp-http';

diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR);
const provider = new NodeTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(new OTLPTraceExporter({url: process.env.OTLP_URL})));
provider.register();
export const tracer = provider.getTracer('app');

// usage
// const span = tracer.startSpan('pricing.calc');
// try { await calc(); } finally { span.end(); }

また、古いフラグや分岐が温存されるリスクはCIで塞ぎます。90日以上未更新の機能フラグにビルド失敗を与え、削除の未着手を可視化する。こうすると「いつか消す」が「今すぐ直す」に変わります。

// tools/check-stale-flags.ts
import {readFileSync} from 'node:fs';
import path from 'node:path';

type Flag = {name: string; updatedAt: string};
const flags: Flag[] = JSON.parse(readFileSync(path.join(__dirname, '../config/flags.json'),'utf8'));
const now = Date.now();
const stale = flags.filter(f => now - new Date(f.updatedAt).getTime() > 1000*60*60*24*90);
if (stale.length) {
  console.error(`stale flags: ${stale.map(s => s.name).join(', ')}`);
  process.exit(1);
}

安全を支える順序も重要です。まず読みやすさを上げる小さな抽出から着手し、可視化とテストでベースラインを固定、次にI/Oの境界を後退させ、最後に機能フラグの廃止とデッドコードの削除に進む。この順にすることで、毎日のデプロイの中に削除を溶かし込み、削除をイベントではなく習慣にできます。

削除がもたらすビジネス効果を測り切る

削除の価値は、速度・品質・安定性の改善として現れます。評価軸としては、DORAメトリクス(デプロイ頻度・変更のリードタイム・変更失敗率・サービス復旧時間)が有効で、チームの変化を定量で追えます⁴。機能単位ではリードタイムとコードレビュー滞留時間、システム単位ではp95・p99のレイテンシとエラー率、組織単位では運用アラートのノイズ率とMTTRを追う、といったレイヤ別の見方が有効です。削除施策の期間に、デプロイ頻度が向上し、変更失敗率やMTTRが低下するケースは少なくありません。サンプルでは、レビューの待ち時間短縮によりPRリードタイムが2〜3割改善することも確認できます。

ROIの観点では、削除に投じた開発時間を、運用削減時間と売上影響で回収できているかをモデル化します。Stripeの調査が示すように生産性は待ち時間に敏感です²。PRリードタイムが20〜30%短縮すると、機能の市場投入が早まり、ABテストの学習サイクルが速く回り始めます。削除は開発速度を上げる最短経路であり、売上の学習速度を上げるレバーになり得ます。また、近年の開発者体験(DX)報告でも、DXを優先する組織ほど開発者のパフォーマンス向上が示唆されています⁵。関係者に説明するには、削除に伴うレイテンシ改善量とコンバージョンの弾性値を掛け合わせた概算利益をダッシュボードで可視化し、投資判断に耐える形に整えるのが有効です。

削除と同時に、将来の増加にブレーキを掛ける仕組みも導入します。新規コードは必ずドメイン境界で純粋関数に閉じ込め、インフラやI/Oはアダプタに集約する方針をコードレビューに明文化。トレーサビリティとオブザーバビリティの標準化により、次の削除がいつでも可能な状態を保ちます。削除の価値は一過性ではなく、継続的に複雑性のスロープを緩くする力として効き続けます。

まとめ:削除を恐れず、価値に集中する

行数は資産ではありません。価値は、顧客が体験する速さと正確さ、そして組織が変更できる自由度の中にあります。1万行削除という極端に見える取り組みも、テストと観測で挙動を固定し、I/Oを境界に押し出し、小さな削除を積み上げることで、安全に日常化できます。もし今、あなたのチームが遅さや不具合の増加を「忙しさ」で覆い隠しているなら、まず挙動を固定するテストと観測を用意し、目に見える小さな削除から始めてみてください。数週間後、p95のグラフとPRの滞留時間が、正しい方向へじわりと傾き始めるはずです。

コードの価値は、書いた瞬間ではなく、削ってもなお残る意思決定の純度で決まります。次に削除できる10行はどこか。今日のデプロイに、ひとかけらの軽さを混ぜ込んでいきましょう。

参考文献

  1. ZDNet Japan. IT部門の予算の約70%が既存インフラの保守に費やされる(記事). https://japan.zdnet.com/article/20413690/
  2. Lightspeed Venture Partners. The Developer Productivity Manifesto Part 3: Leaving Software on the Table. https://medium.com/lightspeed-venture-partners/the-developer-productivity-manifesto-part-3-leaving-software-on-the-table-fd2720c85b65
  3. DX. Lines of Code: Why LOC is a poor measure and how complexity grows with size. https://getdx.com/blog/lines-of-code/
  4. Open Practice Library. Accelerate metrics: software delivery performance measurement. https://openpracticelibrary.com/blog/accelerate-metrics-software-delivery-performance-measurement/
  5. Atlassian. Developer Experience Report 2024. https://www.atlassian.com/blog/developer/developer-experience-report-2024