メインスレッドブロッキングの改善事例

メインスレッドブロッキングの改善事例

1. 概要

大容量データを扱う場合、データを取得することよりも、取得したデータをどのように加工し、画面に表示するかがパフォーマンスに大きな影響を与えることがあります。

実際のプロジェクトで、数万件のデータを加工してGridに表示する画面を開発した経験があります。単純にサーバーから受け取ったデータをそのまま出力する構成ではなく、互いに分離されたデータを特定の基準で関連付け、複数の値を加工した結果を1つのCellに表示する必要がありました。

開発初期は比較的少ないデータでテストしていたため、パフォーマンスの問題はそれほど顕在化しませんでした。しかし、実際のデータが数万件以上に増加すると、Gridにデータを反映する時点での処理時間が長くなり、データの読み込み中に別の操作を行うと画面がフリーズすることもありました。

最初はGrid自体のレンダリング性能が原因だと考えました。しかし、処理過程を確認すると、Cellデータを作成するために繰り返し実行されていたデータの検索や加工も、かなりのコストを発生させていることが分かりました。

この記事では、当時の経験をもとに、大容量データの処理過程で発生したパフォーマンス問題をどのように改善したのか、また、このような問題がMain Thread BlockingおよびLong Taskとどのような関係にあるのかを整理します。

2. 要件と問題状況

 要件と問題状況は次のとおりでした。

要件

  1. ページングではなくスクロールで表示

  2. 数十個を超えるカラムにフィルター機能を適用する必要がある(全データが必要)

  3. Cellの値に応じて異なる背景色を表示

問題状況

  1. データ受信後の加工および表示に長い時間がかかる

  2. 読み込み中に別の画面操作を行うと画面がフリーズする

3. 大容量データで発生した反復処理

初期実装では、Main Dataとは別に管理されているRelated DataをCell単位で組み合わせていました。

簡略化すると、次のような形でした。

예시)
const columnDefs = [
  {
    headerName: 'Related Data',
    valueGetter: params => {
      const relatedItems = relatedData.filter(
        item => item.targetId === params.data.id
      );
      return relatedItems
        .map(item => item.name)
        .join(', ');
    },
  },
];

データが少ないときは問題になりませんでしたが、Main DataとRelated Dataがともに増加すると、Cellの値を作成するための検索も繰り返し増加しました。

特にGridでは、データの更新やソート、フィルターなどの操作に応じて、Cellの値が再計算されることがあります。

結局、問題は単に「Gridにデータが多い」ということではありませんでした。

データ量が増加すると同時に、各データを画面に表示するための検索と加工も繰り返し行われていたことが問題でした。

4. Main Threadの観点から問題を確認

ブラウザのMain Threadでは、JavaScriptの実行だけでなく、ユーザーイベントの処理やレンダリングに必要なさまざまな処理も実行されます。

そのため、JavaScriptの演算がMain Threadを長時間占有すると、ユーザー入力や次のレンダリングにも影響が及ぶ可能性があります。

当時の画面でデータを受け取ってから実際に画面に表示するまでには、次のような処理が必要でした。

API Response

     ↓

データ検索

     ↓

データの結合および変換

     ↓

Gridデータの構成

     ↓

Grid Rendering

     ↓

Layout / Paint

それぞれの演算自体は大きくありませんでしたが、数万件のデータに対して繰り返し実行されることで、JavaScript全体の実行コストが増加しました。

このような問題はLong Taskとも関連します。ブラウザでは、Main Thread上で50msを超えて実行されるTaskをLong Taskとして分類します。

Long Taskの実行中にユーザーインタラクションが発生すると、その処理が終了するまでイベント処理が遅延する可能性があります。

したがって、大容量データのパフォーマンス問題は、全体の処理時間を短縮するだけでなく、Main Thread上で一度に実行する処理量を減らすという観点からも検討する必要がありました。

5. 事前のデータ加工

まず、Cellデータの生成時に繰り返し実行していたデータ検索を減らすことにしました。

従来は、Cellの値を計算するたびに、Related Dataから現在のRowに関連するデータを探すためにfilter()を実行していました。

Main DataとRelated DataがそれぞれN件、M件の場合、各Cellの値を計算する過程でRelated Dataを繰り返し走査することになるため、全体としてO(N × M)レベルの検索コストが発生する可能性があります。

これを改善するため、Related DataをtargetIdを基準にあらかじめ加工し、Lookup構造を構成しました。

예시)
const relatedNameMap = new Map<string, string[]>();

relatedData.forEach( item => {
	const names = relatedNameMap.get(item.targetId) ?? [];
	names.push(item.name);
	relatedNameMap.set(item.targetId, names);
});

6. 加工したデータをGridに渡す

Related Dataをあらかじめ加工した後、その結果をMain Dataと結合してGridに渡すように変更しました。

従来は、Cellが生成される時点で必要なデータを検索し、値を組み合わせていました。

Cellの生成

    ↓

Related Dataの探索

    ↓

データの組み合わせと変換

    ↓

Cellの表示

この方式では、Gridのデータ更新や並べ替え、フィルターなどの操作に応じて、Cellの値が再計算される可能性がありました。

改善後は、先ほど生成したrelatedNameMapを利用し、Gridにデータを渡す前に必要な値をMain Dataに含めました。

예시)
const gridData = mainData.map( row => ({
 ...row,
 relatedNames: relatedNameMap.get(row.id)?.join(“, ”) ?? “",
 }));

const columnDefs = [ {
 field: 'relatedNames',
  headerName: 'Related Data',
 },];

これにより、Cellが生成されるたびに繰り返されていたデータの探索と加工を、レンダリングの過程から取り除くことができました。

特に、データ更新や並べ替え、フィルターなどの操作が発生しても、Cellの値を構成するためにRelated Dataを再度探索する必要がなくなりました。Gridは、すでに計算された値を画面に表示する役割に集中できるようになりました。

結果として、単にCellのレンダリング速度を改善しただけでなく、繰り返し実行されていた処理をレンダリングの過程から分離し、計算結果を再利用する構造に変更した点に意義がありました。

7. Virtualizationだけでは解決できない問題

数万件のデータをGridに表示する際、すべてのRowを実際のDOMとして生成するのは非効率的です。使用していたAG Gridも、Virtualizationによって現在のViewportを中心に必要なRowだけをレンダリングするため、実際のDOMに生成される要素の範囲は限定されていました。

しかし、Virtualizationが適用されているからといって、大量データの処理過程で発生するすべてのパフォーマンス問題が解決されるわけではありません。Virtualizationは主に画面にレンダリングされるDOMの範囲を縮小する役割を担いますが、Gridに渡すデータを構成したり、Cellに表示する値を計算したりする過程で発生するJavaScriptの処理まで減らしてくれるわけではありません。

当時の画面では、数万件のデータを処理しながら、互いに分離されたデータを探索して組み合わせる必要がありました。そのため、Grid自体のレンダリング範囲よりも、Gridのデータを構成したりCellの値を計算したりする過程で繰り返される探索と加工のコストを減らすことが重要な改善ポイントでした。

8. まとめ

今回の経験をMain Threadの観点から振り返ると、適用した改善方法は、最終的にMain Threadが実行しなければならない処理を減らす方向のものでした。

問題

改善の方向性

繰り返し行われるデータ探索

Lookup構造の構築

Cell単位のデータ加工

事前計算

同じ計算の繰り返し

計算結果の再利用

これらの方法を適用した後も実際の計算自体が重い場合は、Taskを分割したり、Web Workerを活用したりする方法も検討できます。

しかし、最初から複雑な方法を適用するよりも、現在のMain Threadでどのような処理が繰り返し実行されているのかをまず確認することが重要だと考えました。

実際、不要な探索と重複した計算を取り除くだけでも、Main Threadが処理しなければならない作業量そのものを減らせるからです。

9. おわりに

今回の経験を通じて、大量データを扱う際には、単にレンダリングのパフォーマンスだけを確認するのではなく、データを画面に表示するまでにどのような処理が繰り返されているのかも併せて確認する必要があると分かりました。また、パフォーマンス最適化では、特定の関数の実行時間を短縮するだけでなく、不要な計算自体が繰り返されないようにデータ処理の流れを設計することが重要だと学びました。


Jung

Site footer