freee人事労務開発チームでアプリケーションエンジニアをやっている @massan です。
2021年10月に入社して以来、freee人事労務開発チームのメンバーとして新規の機能開発や改善に取り組んでいます。
今回は、freee人事労務のメイン機能の一つである従業員一覧画面のパフォーマンス改善・リアーキテクチャに取り組んだ話を紹介します。
従業員一覧画面とは
従業員一覧画面はfreee人事労務で最も利用頻度が高い画面の一つで、従業員に対する様々な操作の起点となる画面です。

ごく一般的な一覧画面で、フィルタリング・ページネーション・ソートといった機能を持っています。 これらの機能にはパフォーマンス上の問題があり、今後の事業所あたりの従業員数の増大に耐えられなくなるリスクをはらんでいました。
また、freee人事労務開発チームではコードベースのレイヤードアーキテクチャ化を推進していますが、 従業員一覧画面は新アーキテクチャへの移行が進んでいない状態であり、また、既存のコードは気軽に変更するのが難しい状況でした。
そのため、1ヶ月間腰を据えて、この画面のリアーキテクチャ・パフォーマンス改善プロジェクトに取り組むことになりました。 なおこのプロジェクトは、リファクタリングやアーキテクチャ改善に関するノウハウに詳しい品質改善チームと、ドメイン知識やプロダクト仕様に詳しい機能開発チームのコラボレーションによって実現しました。
新アーキテクチャの検討
この画面を再実装するにあたり、候補に上がったアーキテクチャは 1. Railsバックエンドで処理する方法、 2. BFF層で処理する方法、 3. フロントエンドで処理する方法 でした。
Railsバックエンドで処理する方法: 従来の実装はこの方法で、データベースや他マイクロサービスへのリクエストを直列実行するシンプルな実装となっており、パフォーマンス問題を抱えていました。 リクエスト処理を並列化してパフォーマンス改善することも考えられましたが、コードベース上でリクエストを並列化して処理するプラクティスがまだなく、導入に際して検証に時間がかかることが予想されました。 また別な問題として、freee人事労務ではコードベースのレイヤードアーキテクチャ化を推進していますが、そのような前提のもとで、複雑な仕様を持ったフィルタリング・ソート・ページネーションの機能を実装しようとすると、実装コストがかさんでしまうことが予想されました。
BFF層で処理する方法: モダンなアーキテクチャとして案には上がりましたが、得られるメリットの割に構築や運用にかかるコストが大きすぎると考え、今回はあまり深く検討しませんでした。
フロントエンドで処理する方法: 初回の画面描画時に一覧の全件を取得してフロントエンドにキャッシュし、フロントエンドでフィルタリング・ソート・ページネーションの処理をする案です。 大前提として、従業員一覧画面(やその他のfreee人事労務の多くの一覧画面)は高々数千件の表示にとどまります。 最初に全件取得するため、初回の画面描画には時間がかかると考えられますが、それ以降フィルタリング・ソート・ページネーションの条件を変更した際はバックエンド通信が一切行われないため、高々数千件程度のデータ量であればほとんど瞬時に画面の再描画が完了し、ユーザビリティの劇的な向上が期待できます。 また、Promise.all を使ってHTTPリクエストの発行を並列化するだけの簡単な実装で、パフォーマンス改善が見込めるという点も利点です。
比較検討の結果、 3. フロントエンドで処理する方法 が妥当であると結論づけました。 最初に全件取得する際のパフォーマンス劣化の懸念がクリアでき、実装コストが小さく、かつ、ユーザビリティ面においても大幅な改善が見込めると判断したためです。
以下の画像は、数千人の従業員が存在する事業所におけるリリース前後のパフォーマンス変化です。青い線がリリース前のレスポンス時間、紫の線がリリース後のレスポンス時間になります。
なお、リリース前のグラフはバックエンドでフィルタリング・ソート・ページネーションを行った結果を返すまでのレスポンスタイム(すなわち、フィルタリング・ソート・ページネーション条件を変える毎の再描画にかかる時間)、リリース後のグラフはバックエンドから全件取得した結果を返すまでのレスポンスタイム(すなわち、つまり初回の画面描画にかかる時間)です。 見ての通り、リリース後の全件取得による初回表示と比べても、パフォーマンスは悪化どころか改善方向に向かっています。 これで実装上の懸念を払拭することができたので、本格的に実装を進めることにしました。

ただし注意点として、リリース後の実装は下図のようにリクエストが2段階に分かれており、上記のグラフは一個目のリクエストのレスポンスタイムだけをサンプリングしています。 そのため、実際のユーザーの体感としては、上記で計測したレスポンスタイムのおよそ2倍の時間だけ待たされる形になります。(それでもやはり、改善後の方がレスポンスタイムは短い!)
リクエストが2段階に分かれているのは、初回のリクエストのレスポンスから従業員のID一覧を取得し、そのID一覧を起点に直後のリクエストを送信する必要があったためです。 この際、IDのリストは数千個になることがある関係上、該当のAPIはGETで実装することができなかったため、POSTで実装しています。

フロントエンドの実装のパッケージ化
さてこうなると、バックエンドの実装はかなりシンプルになり、ロジックの主体はフロントエンド側に移ります。
フロントエンドのフィルタリング・ソート・ページネーションロジックは、Reactコンポーネントのカスタムフックとして再利用可能な形でパッケージ化しました。 他画面でも流用可能な資産として残すことで、開発生産性の向上はもちろん、仕様一貫性を増すことを目指しました。
カスタムフックのインターフェイス
カスタムフックは、入力値として一覧の配列を与えるとフィルタリング・ページネーション・ソート処理された結果が配列で返ってくるシンプルな仕様にしました。 フィルタリング・ページネーション・ソート処理に使用される各種状態はカスタムフック内部に閉じ込めています。
なお、フィルタリングにはさまざまなニーズが想定されるため、具体的なユースケースに基づいたインターフェイスではなく、フィルタリング関数を自由に実装できるようにしています。 以下は実際にカスタムフックを利用しているコード箇所の引用です。
const { changeFilterOpts, // フィルタリング条件を変更するハンドラ changePage, // ページネーション(現在のページ)を変更するハンドラ changePer, // ページネーション(ページあたりの表示件数)を変更するハンドラ changeSort, // ソート対象とソート順を変更するハンドラ filterOpts, // フィルタリング条件の状態 filteredItems, // フィルタリングされた後の配列(ソートやページネーションは未実施) listOpts, // ページネーション・ソート条件の状態 showingItems, // フィルタリング・ソート・ページネーション処理された後の配列 } = useFunctionalList({ compareSettings: { employeeNum: strWithNumCompare, // ソート対象の属性名とカスタムソート関数の指定 }, filter: employeeListFilter, // フィルタリング関数の指定 initialFilterOpts: props.initialFilterOpts, // フィルタ条件の初期値の指定 initialListOpts, // ソート・ページネーション条件の初期値の指定 items, // フィルタリング・ソート・ページネーション処理される前の配列 });
ページネーションとソートの機能のみ実装したい場合には、フィルタの機能を非活性にしてコンパクトに使用することもできるようになっています。 現状このカスタムフックは、freee人事労務上のいくつかの画面で共通利用されています。
最後に
利用頻度が高い画面のパフォーマンスが大きく改善されたため、リリース後の社内での反響は大きく、大変やりがいのある取り組みとなりました。

今後も、ユーザーにマジ価値を爆速で届けられるよう、新規機能のリリースや改善に向けて取り組んでいこうと思います。