画面表示速度を約10倍にした話

published_at: 2024-03-11

概要

画面表示の速度を10倍にした際の振り返り

背景

サービスの成長に伴いクライアントが増えてきて、データ量も倍々で増えてきており、ある画面の表示速度がかなり遅くなっていた。

ローディング中のまま一向に必要な要素が表示されないという状況。

それにより作業効率が低下しており、課題として顕在化していた。

表示速度はデータ量によってばらつきがあるが、30秒ほど時間がかかっているケースもあった。

原因調査

そもそも該当画面でGraphQLのクエリが叩かれまくっていたのとN+1が起きていたので、他のエンジニアがN+1を修正していたが、それでもまだ遅かった。

コードを確認すると、画面表示するために必要なデータを全部取ってくるまでローディングをしていることが分かった。

画面構成要素の一部であるセレクトボックスの選択肢に表示するデータを全件取得しようとしていた。

下記のようにPromise.allを使っているので、Promiseが全解決するまでずっとローディング中のままになっていた。

1Promise.all([promise1, promise2, promise3]).then((val) => { ... })

しかも、ページネーション等も存在しておらず、全てのデータをセレクトボックスの数だけ持ってきてたので、データ量が増えればその分遅くなるという構造になっていた。

方針

上述の課題を解決するために下記の方針を立てた。

  • 初回の画面表示は行いつつ、セレクトボックス側が描画されたタイミングでレコードを取得する
  • 全件取得を避けるためにページネーションを用意

つまり、セレクトボックスの選択肢に必要なレコードは初回描画時に遅延ローディングする。

セレクトボックスごとにレコードを取得することで、APIリクエストを並列化させて全体のリードタイムを抑えつつ、ページネーションを用意して初回取得上限を設ける。

これによりセレクトボックスの選択肢が揃うまで、他の要素の設定(テキストボックス入力など)をしておける状態に持っていける。

 

また、特定件数だけ取得して残りはページネーションを用意することで、大量にレコード取得してGraphQLのクエリが何回も発行されることを避けるようにする。(元々全件取得するために無限ローディングしていた)

まとめると、遅延ローディング + デマンドフェッチ。

上記方針で改善を進めていった。

やったこと

  • 描画時にレコードを取得して選択肢を格納するセレクトボックスコンポーネントの作成
    • 以下、遅延ローディングコンポーネント
  • セレクトボックスでページネーションを導入
  • Promise.allを剥がす
  • GraphQLを用いた各種APIリクエストの調整

基本的にはセレクトボックスを新規作成して、Promise.allを剥がしていった。

遅延ローディングコンポーネントの作成

ライブラリのバージョンアップグレード

セレクトボックスのコンポーネントは react-select を使っていた。

react-selectのセレクトボックスを使って遅延ローディングするには、Asyncのpropsを利用する必要があるようだったので、ライブラリのバージョンをアップグレードした。

その上で遅延ローディングのコンポーネントを作成してみたが、関数が発火せずレコードが取得できず調査が難航した。

OSSのコードを引っこ抜いて自前で実装

上述した通りうまく作成できなかったので、他のライブラリで実現できないかを調査した。

調査したところ、react-select-async-pagenateが利用できそうだった。

しかし、リリースノートやタグが切られておらず、破壊的な変更がサイレントで入ってくるリスクがあったので採用できず。

そのため、同ライブラリのコードの中身を引っ張ってきて自前で実装する決断をした。

 

https://github.com/vtaits/react-select-async-paginate/blob/52f2e2f0fe99f57bb8536b202ecda409a558b9a5/src/index.jsx

上述のURLは同ライブラリの遅延ローディングの部分であり、クラスコンポーネントで実装されている。

それを関数コンポーネントに直しつつ微調整していった。

最初は難しいかもなと思っていたが、そんなに難しいことはなく初回描画時にAPIリクエストして、stateに詰めているだけなので思ったより簡単。

ページネーションを実装

上述したコンポーネントを作成後、そのコンポーネントにページネーションの仕組みを導入した。

リストを最後までスクロールするとGraphQLクエリを発行して次ページを取得するようにした。

加えて、入力値を用いてリスト検索した場合、GraphQLクエリを発行して取得するように実装した。

これにより、無限ローディングを剥がして、デマンドフェッチの仕組みを実装した。

余談

本アプリケーションのフロントエンドはリポジトリパターンで構築されている。

画面描画時の親コンポーネントで必要なデータを取得するために、リポジトリ層を呼び出してレコードを取得している。(ユースケース層を呼び出した方が良いと思うが)

それによって、親コンポーネントのimportがどんどん増えていき、ファイル行数がかなり多くなっており、見通しが悪い状態。700くらいだった。

それが気持ち悪かったので、それを解消するためにレコードを一覧取得する関数だけまとめたファイルを作成して、それをimportする形に変更した。

150くらいファイル行数が減っただけでなく、データ取得の責務をそちらのファイルに分割できた。(神クラスみたいにならないよう運用する際の注意が必要。)

小さくデプロイ

コンポーネント自体が完了した後、既存のセレクトボックスを徐々に書き換えるために小さくデプロイをし続けた。

影響範囲を小さく保ち不具合を起こさないようにするため。

全部で20回くらいデプロイしたが、不具合を起こしたのは1回だけだった。(0に抑えたかったが)

フロントエンドのコンテナがメモリ不足で落ちる問題を解消

開発時にメモリ不足でコンテナが落ちまくっており、流石に腹が立って 改善チャンスだったので対応した。

10行くらいファイルを変更して反映を待っていたら、コンテナがheap out of memoryで毎回落ちていた。

どうやらwebpackがホットリロード時に変更対象ファイルを検知してメモリに載せすぎていたよう。node_modulesまで検知しようとしていた。

また、webpackのビルド結果をキャッシュするように設定を追加した。

この変更により問題がかなり解消された。

結果

レコード数によってまちまちだったが、遅い企業様で画面表示されるまで約25秒かかっていたところを、約1-2秒までに短縮した。

1-2秒は全企業様で固定値だったので、かなり改善できた。

開発リードタイムを早めるためにできたこと

  • 最初からOSSのコードを参考にする
  • コンテナ落ちる問題を早めに対処しておくべきだった
  • レビュー会の実施

最初からOSSのコードを参考にする

最初は、海外のエンジニアがブログ等に載せているコードを参考に検証を進めていたが、うまく動かなかった。

OSSコードを参考にして検証した方がより確実に動くし早かった。

OSSへの敷居の高さみたいなものを感じていたのと、おそらく自身が読み解くのに面倒さを感じてしまっていた。

カジュアルな内容をもとに簡単に実装できたら良いなという願望のもとに動いていた気がする。

検証段階こそ正確な情報を参照すべきだと痛感した。

コンテナ落ちる問題を早めに対処しておくべきだった

開発時間の1/4はコンテナ再起動に使っていたかも。

最初は原因がわからずに後回しにしていたが、早い段階から着手しておけばよかった。

本筋以外だとどうしても優先度が下がってしまいがちだが、そこの時間を測ってインパクトを定量化しておくべき。

レビュー会の実施

20回もデプロイするくらいなのでPR数も多くなっていた。

レビューリードタイムを削減するために、周りを適切に巻き込む必要があった。

ある程度の規模の開発をレビューするとなると、口頭で確認しあうタイミングを設けないとリードタイム削減はしにくいなと感じた。

コンテキストを埋めるのが難しいだけでなく、そこに思考コストがかかるので後回しになりがち。

まとめ

パフォーマンス改善のタスクってかなり達成感があるのでやっぱり好きだなと感じた。

ただ、この開発を通じて改善点を見つけられたので、今後に活かしていきたい。