🚀

余計なuseMemoを1つ消したらtscが135秒高速化した話

に公開

余計なuseMemoを1つ消したらtscが135秒高速化した話

はじめに

大規模なReact + TypeScriptプロジェクトにおいて、無意味なオブジェクトマッピングを行うuseMemoを1つ削除することで、TypeScriptコンパイル時間が146秒から11秒に短縮された(92%改善)事例について報告します。

根本原因の完全な特定には至らなかったものの、実際に発生した現象と問題特定のプロセスを記録します。

本件では、ある程度論理的に原因の当たりをつけたあと、先入観を排して特定するためにAIコーディングエージェントを活用して愚直な二分探索での検証を行わせ、最終的に135秒にも及ぶ遅延の原因となっていた行を特定できました。

問題の発見

大規模なReact + TypeScriptプロジェクトにおいて、yarn typecheckの実行時間が異常に長くなる現象が発生しました。

# 問題発生時
$ time yarn typecheck
# 146秒(2分26秒!)

146秒(2分26秒)という実行時間は、開発効率に深刻な影響を与えるレベルでした。

TypeScript Traceによる問題ファイルの特定

問題の詳細な分析のため、tsc --generateTraceを使用してパフォーマンストレースを取得しました:

$ tsc --noEmit --generateTrace trace
# chrome://tracing/ で trace/trace.json を分析

このコマンドは、TypeScriptコンパイラの内部処理を詳細に記録し、Chrome DevToolsのTracing機能で可視化できるトレースファイルを生成します。

TypeScript Trace結果

Chrome DevToolsのTracing画面。hooks.tsファイルの処理時間が異常に長いことが視覚的に確認できる

トレース結果の分析により、以下の事実が明らかになりました:

checkSourceFile: hooks.ts
Total time: 119,784.362 ms (119.7秒)

単一ファイルの型チェックに119.7秒を要しており、これは全体のコンパイル時間146秒の82%を占めていました。

人間による分析の限界

問題ファイルが特定できたので、該当のhooks.tsファイルを詳しく調査しました。このhooksファイルは400行もある異常に長いファイルでした(本来なら分割すべきサイズです)。

しかし、400行のコードを目視で確認しても、パフォーマンス問題の原因箇所を特定することは困難でした。コード内容は一般的なReact hooksとuseMemoの組み合わせであり、明らかな問題は確認できませんでした。

問題ファイルの構造分析

400行のhooksファイルには、以下のような処理が含まれていました:

export const useCalculateMetrics = ({ /* パラメータ */ }) => {
  // 1. Zod + React Hook Form による複雑なフォームバリデーション
  const form = useForm({
    resolver: zodResolver(complexSchema),
    // 複雑なデフォルト値設定
  });

  // 2. 複数のSWR呼び出しによるデータフェッチ
  const { data: salesData } = useSWR(
    saleId ? `/api/sales/${saleId}` : null,
    fetcher
  );
  const { data: metricsData } = useSWR(
    `/api/metrics/${targetId}`,
    fetcher
  );

  // 3. 複数のuseMemoによる計算処理
  const salesData: Sale[] = useMemo(
    () =>
      (targetSales ?? []).map((sale) => ({
        sale_id: sale.sale_id,
        product_title: sale.product_title,
        payment_type: sale.payment_type,
        user_paid_at: sale.user_paid_at,
        processing_minutes: sale.processing_minutes,
      })),
    [targetSales],
  );

  const calculatedMetrics = useMemo(() => {
    // 複雑な計算ロジック
  }, [salesData, metricsData]);

  // 4. 複数のuseEffectによる副作用処理
  useEffect(() => {
    // 複雑な副作用処理
  }, [form.watch(), salesData, calculatedMetrics]);

  // ... その他の処理
};

このような複雑な構成の中で、TypeScriptコンパイルの遅延を引き起こしている箇所はどこでしょうか?

手動での原因特定が困難であったため、コーディングエージェントを活用して「二分探索的にコードを削除してパフォーマンステストを繰り返す」というアプローチを採用し、系統的な実験を実行しました:

二分探索による原因箇所の特定

# ベースライン測定
$ time yarn typecheck
# → 146秒(2分26秒)

# 実験1: zodResolver + useForm を削除
$ timeout 40s yarn typecheck
# → タイムアウト(効果なし)

# 実験2: 複数のSWR呼び出しを削除
$ timeout 40s yarn typecheck
# → タイムアウト(効果なし)

# 実験3: useMemoのオブジェクトマッピングを削除
$ time yarn typecheck
# → 11秒(大幅な改善を確認)

行レベルでの影響測定

以下に実験結果の一部を示します。

変更内容 該当行 コンパイル時間 改善効果
オリジナル - 146s -
useMemo全体削除 170-180行 11s 92%改善
map操作のみ削除 172-178行 11s 92%改善
useEffect依存配列簡略化 168行 144s ❌ ほぼ効果なし
別のuseMemo削除 185-194行 146s ❌ 効果なし

実験により、172-178行のオブジェクトマッピング処理がパフォーマンス問題の主要因であることが特定されました。

注目すべきは、複雑なZodバリデーション、複数のSWR呼び出し、その他の計算処理など処理負荷が高いと予想される箇所は全く影響がなく、単純なオブジェクトマッピング処理が主要因(トリガー)であったことです。

原因コードの特定

実験により特定された問題のコードは以下でした:

// 🚨 問題のコード:146秒のコンパイル時間
const salesData: Sale[] = useMemo(
  () =>
    (targetSales ?? []).map((sale) => ({
      sale_id: sale.sale_id,
      product_title: sale.product_title,
      payment_type: sale.payment_type,
      user_paid_at: sale.user_paid_at,
      processing_minutes: sale.processing_minutes,
    })),
  [targetSales],
);

解決策:シンプルな1行の修正

// ✅ 修正後:11秒のコンパイル時間
const salesData: Sale[] = targetSales ?? [];

修正内容:複雑なuseMemoとオブジェクトマッピングを、シンプルな代入に置換しただけです。この1行の変更で146秒→11秒(13倍高速化)、当該ファイル単体では**119.7秒→0.8秒(149倍高速化)**を実現しました。

(本来であればsalesDataの利用箇所も調査して適切にリファクタリングすべきところ、余計な行を変更してランタイムに影響させたくなかったため、最小限の変更に留めました)

削除したメモ化処理は、元のオブジェクトから全く同じプロパティを抜き出して新しいオブジェクトを作成するだけの完全に不要な変換でした。おそらく歴史的経緯で過去にあった有意味な変換処理がある時点からただのマッピングになり、そのあと放置されていたと思われます。

根本原因の考察

本事例において、なぜこの特定の処理がコンパイル時間の大幅な増加を引き起こしたのかについて、完全な解明には至っていません。

今回のケースでは、SWRやuseMemoなど複数のジェネリクス依存処理が重なる大規模なファイルにおいて、何らかの理由でTypeScriptコンパイラの処理時間が増大していたと思われます。

複雑なZodスキーマやSWRの型推論など「明らかに重そう」と思われる処理ではなく、単純で無害に見えるオブジェクトマッピングを削除することで解決したのは意外でした。今回は先入観を排除して系統的に検証したことで、予想外の箇所を特定することができました(もちろん長すぎるHooksは定期的に短くしましょう、という教訓もあります)。

修正後の結果

修正後の同じファイルのTrace結果:

checkSourceFile: hooks.ts (修正後)
Total time: 0.8秒

119.7秒 → 0.8秒 = 149倍の高速化が実測で確認されました。

# 修正後の全体時間
$ time yarn typecheck
# 11.2秒で完了

まとめ

今回の体験で分かったのは、TypeScriptのパフォーマンス問題が発生した時の特定アプローチの重要性です。

論理的アプローチと非論理的アプローチの組み合わせ

今回の事例では、以下の二段階のアプローチが有効でした:

  1. 論理的特定(TypeScript Trace): 問題ファイルの特定

    • tsc --generateTraceによる客観的データ分析
    • 119.7秒かかっているファイルを論理的に発見
  2. 非論理的特定(試行錯誤): 具体的な問題行の特定

    • 二分探索的なコード削除実験
    • 愚直な試行錯誤による原因箇所の絞り込み

今回分かったこと:

  • 論理だけでは限界がある: Traceで問題ファイルは特定できても、具体的な行は分からない
  • 非論理だけでは非効率: 闇雲な試行錯誤では時間がかかりすぎる
  • 両者の組み合わせが有効: 論理的手法で範囲を絞り、試行錯誤で詳細を特定

実践的なデバッグ戦略

  1. まずTypeScript Traceで問題ファイルを論理的に特定
  2. 次に二分探索的な実験で具体的な原因箇所を特定
  3. 人間とエージェントの役割分担: 全体戦略立案は人間、愚直な実験作業はエージェント

本事例では、不要なuseMemoを1つ削除することで135秒という大幅なコンパイル時間短縮を実現することができました。

Discussion